Skip to main content

lenso_app_authoring/
lib.rs

1//! Validated, atomic authoring operations for one Lenso App Plugin Root.
2//!
3//! Every mutation resolves the complete candidate against the Host Catalog
4//! before changing visible App-owned files. Runtime Generation staging and
5//! switching remain the responsibility of the running Host.
6
7pub mod host_authoring;
8pub mod identity;
9
10use host_authoring::{GeneratedHostBuild, HOST_BUILD, HostInput};
11
12use std::{
13    collections::{BTreeMap, BTreeSet},
14    env, fs,
15    path::{Path, PathBuf},
16};
17
18use anyhow::{Context, bail};
19use lenso_app_plan::authoring::{
20    DependencyChoice, PluginDescriptor, PluginInstanceId, PluginRootInstance, PluginRootSnapshot,
21    ResolvedApp,
22};
23use lenso_app_plan::{ExecutionClassId, PLUGIN_AUTHORING_V2_RUNTIME_PROFILE};
24use lenso_plugin_bundle::{
25    ImplementationPolicy, RuntimeAdmission, VerifiedBundle, read_bundle_manifest,
26    resolve_implementation, verify_bundle_directory,
27};
28use serde::{Deserialize, Serialize};
29use serde_json::Value;
30use sha2::{Digest as _, Sha256};
31
32use crate::identity::{
33    classify_existing_plugin_id, validate_plugin_id_v1, validate_release_version,
34};
35
36mod configuration_authority;
37mod selection_authority;
38
39pub use configuration_authority::{
40    LocalPluginRootAuthority, PluginConfigurationApplication, PluginConfigurationAuthority,
41    PluginConfigurationAuthoritySource, PluginConfigurationDiagnostic, PluginConfigurationProposal,
42    PluginConfigurationProposalStatus, PluginConfigurationPublication,
43    PluginConfigurationSourceConflict, PluginConfigurationSourceDigest, PluginRootRevision,
44    PluginRootRevisionConflict, PluginRootRevisionParseError, propose_instance_configuration,
45    publish_instance_configuration,
46};
47pub use selection_authority::{
48    PluginSelectionAuthority, PluginSelectionPublication, set_instance_enabled_fenced,
49};
50
51const PLUGIN_ROOT: &str = "plugins";
52const HOST_CATALOG: &str = ".lenso/host-catalog.json";
53const BUNDLE_NAME: &str = "plugin.lenso-plugin";
54const DEPENDENCY_SELECTIONS: &str = "dependencies.json";
55pub const DEPENDENCY_SELECTIONS_SCHEMA: &str = "lenso.plugin-dependencies.v1";
56const AUTHORING_LOCK: &str = ".lenso/plugin-root-authoring.lock";
57const MAX_CONFIGURATION_BYTES: u64 = 256 * 1024;
58const MAX_RESOURCE_FILES: usize = 4_096;
59const MAX_RESOURCE_FILE_BYTES: u64 = 1024 * 1024;
60const MAX_RESOURCE_TOTAL_BYTES: u64 = 16 * 1024 * 1024;
61const MAX_RESOURCE_DEPTH: usize = 32;
62
63/// Resolves the App selected by one project root's Host Catalog and Plugin Root.
64pub fn load_resolved_app(root: &Path) -> anyhow::Result<ResolvedApp> {
65    let host = load_host_catalog(root)?;
66    let snapshot = snapshot_plugin_root(root, &host)?;
67    host.resolve(&snapshot).map_err(anyhow::Error::msg)
68}
69
70/// Exact runtime input resolved from immutable distribution authority and one external Root.
71#[derive(Clone, Debug, Serialize)]
72pub struct RuntimeAppResolution {
73    schema: &'static str,
74    app_id: String,
75    authority_digest: String,
76    host_build_digest: String,
77    plugin_root_revision: String,
78    plan: lenso_app_plan::ResolvedAppPlan,
79}
80
81/// Resolves an external Plugin Root without allowing it to replace distribution authority.
82pub fn resolve_runtime_app(root: &Path, host_build: &Path) -> anyhow::Result<RuntimeAppResolution> {
83    let root = fs::canonicalize(root).context("locate external App root")?;
84    if !fs::metadata(&root)?.is_dir() {
85        bail!("external App root must be a directory: {}", root.display());
86    }
87    for competing in [HOST_BUILD, HOST_CATALOG] {
88        match fs::symlink_metadata(root.join(competing)) {
89            Ok(_) => bail!(
90                "external App root cannot replace distribution Host authority with `{competing}`"
91            ),
92            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
93            Err(error) => return Err(error).context("inspect external Host authority"),
94        }
95    }
96    let metadata = fs::symlink_metadata(host_build)
97        .with_context(|| format!("inspect distribution Host build {}", host_build.display()))?;
98    if !metadata.file_type().is_file() {
99        bail!(
100            "distribution Host build must be a regular file: {}",
101            host_build.display()
102        );
103    }
104    let host_bytes = fs::read(host_build)
105        .with_context(|| format!("read distribution Host build {}", host_build.display()))?;
106    let host: GeneratedHostBuild =
107        serde_json::from_slice(&host_bytes).context("invalid distribution Host build")?;
108    host.validate()?;
109    let snapshot = snapshot_plugin_root(&root, &HostInput::Generated(host.clone()))?;
110    let plugin_root_revision = configuration_authority::revision_for_snapshot(&snapshot)?;
111    let resolved = host.resolve(&snapshot).map_err(anyhow::Error::msg)?;
112    let host_build_digest = runtime_sha256(&host_bytes);
113    let authority = serde_json::to_vec(&serde_json::json!({
114        "schema": "lenso.runtime-authority.v1",
115        "host_build_digest": host_build_digest,
116        "plugin_root_revision": plugin_root_revision.as_str(),
117    }))?;
118    Ok(RuntimeAppResolution {
119        schema: "lenso.runtime-app-resolution.v1",
120        app_id: host.host_id().to_owned(),
121        authority_digest: runtime_sha256(&authority),
122        host_build_digest,
123        plugin_root_revision: plugin_root_revision.as_str().to_owned(),
124        plan: resolved.plan().clone(),
125    })
126}
127
128fn runtime_sha256(bytes: &[u8]) -> String {
129    let digest = Sha256::digest(bytes);
130    let mut value = String::with_capacity(71);
131    value.push_str("sha256:");
132    for byte in digest {
133        use std::fmt::Write as _;
134        write!(value, "{byte:02x}").expect("writing to String cannot fail");
135    }
136    value
137}
138
139/// Read-only authoring state for one Plugin Instance.
140///
141/// This describes only the App-owned difference and the Host policy needed to
142/// present it safely. The resolved Plan remains Host-owned runtime input.
143#[derive(Clone, Debug, Eq, PartialEq)]
144pub struct PluginInstanceAuthoringState {
145    id: PluginInstanceId,
146    origin: PluginInstanceOrigin,
147    selection: PluginInstanceSelection,
148    root_configuration_toml: Option<String>,
149    source_digest: PluginConfigurationSourceDigest,
150}
151
152/// Authority that introduced one visible Plugin Instance.
153#[derive(Clone, Copy, Debug, Eq, PartialEq)]
154pub enum PluginInstanceOrigin {
155    HostDefault { disableable: bool },
156    PluginRoot,
157}
158
159/// Current desired selection derived from the Plugin Root.
160#[derive(Clone, Copy, Debug, Eq, PartialEq)]
161pub enum PluginInstanceSelection {
162    Enabled,
163    DisabledByRoot,
164}
165
166impl PluginInstanceAuthoringState {
167    pub const fn id(&self) -> &PluginInstanceId {
168        &self.id
169    }
170
171    pub const fn is_enabled(&self) -> bool {
172        matches!(self.selection, PluginInstanceSelection::Enabled)
173    }
174
175    pub const fn is_host_default(&self) -> bool {
176        matches!(self.origin, PluginInstanceOrigin::HostDefault { .. })
177    }
178
179    pub const fn is_disableable(&self) -> bool {
180        match self.origin {
181            PluginInstanceOrigin::HostDefault { disableable } => disableable,
182            PluginInstanceOrigin::PluginRoot => true,
183        }
184    }
185
186    pub fn root_configuration_toml(&self) -> Option<&str> {
187        self.root_configuration_toml.as_deref()
188    }
189
190    pub const fn source_digest(&self) -> &PluginConfigurationSourceDigest {
191        &self.source_digest
192    }
193
194    pub const fn is_disabled_by_root(&self) -> bool {
195        matches!(self.selection, PluginInstanceSelection::DisabledByRoot)
196    }
197
198    pub const fn has_root_difference(&self) -> bool {
199        self.root_configuration_toml.is_some() || self.is_disabled_by_root()
200    }
201}
202
203/// Read-only authoring state for one Plugin Release visible to the App owner.
204#[derive(Clone, Debug, Eq, PartialEq)]
205pub struct PluginAuthoringState {
206    configuration_defaults: Value,
207    configuration_schema: Option<Value>,
208    plugin_id: String,
209    release_version: String,
210    root_supplied: bool,
211    instances: Vec<PluginInstanceAuthoringState>,
212}
213
214impl PluginAuthoringState {
215    pub const fn configuration_schema(&self) -> Option<&Value> {
216        self.configuration_schema.as_ref()
217    }
218
219    pub const fn configuration_defaults(&self) -> &Value {
220        &self.configuration_defaults
221    }
222
223    pub fn plugin_id(&self) -> &str {
224        &self.plugin_id
225    }
226
227    pub fn release_version(&self) -> &str {
228        &self.release_version
229    }
230
231    pub const fn is_root_supplied(&self) -> bool {
232        self.root_supplied
233    }
234
235    pub fn instances(&self) -> &[PluginInstanceAuthoringState] {
236        &self.instances
237    }
238}
239
240/// Complete read-only management projection for the current Plugin Root.
241#[derive(Clone, Debug, Eq, PartialEq)]
242pub struct PluginRootAuthoringState {
243    revision: PluginRootRevision,
244    resolved: ResolvedApp,
245    plugins: Vec<PluginAuthoringState>,
246}
247
248impl PluginRootAuthoringState {
249    pub const fn revision(&self) -> &PluginRootRevision {
250        &self.revision
251    }
252
253    pub const fn resolved(&self) -> &ResolvedApp {
254        &self.resolved
255    }
256
257    pub fn plugins(&self) -> &[PluginAuthoringState] {
258        &self.plugins
259    }
260}
261
262/// Inspects the current Host Catalog and Plugin Root without changing either.
263#[expect(
264    clippy::too_many_lines,
265    reason = "keeps one atomic read-only Root projection"
266)]
267pub fn inspect_plugin_root(root: &Path) -> anyhow::Result<PluginRootAuthoringState> {
268    let host = load_host_catalog(root)?;
269    let snapshot = snapshot_plugin_root(root, &host)?;
270    let revision = configuration_authority::revision_for_snapshot(&snapshot)?;
271    let resolved = host.resolve(&snapshot).map_err(anyhow::Error::msg)?;
272    let enabled = resolved
273        .instances()
274        .iter()
275        .map(|instance| instance.id().clone())
276        .collect::<BTreeSet<_>>();
277    let disabled = snapshot.disabled().iter().cloned().collect::<BTreeSet<_>>();
278    let root_instances = snapshot
279        .instances()
280        .iter()
281        .map(|instance| instance.id().clone())
282        .collect::<BTreeSet<_>>();
283    let host_defaults = host
284        .defaults()
285        .iter()
286        .map(|instance| (instance.id().clone(), instance.is_disableable()))
287        .collect::<BTreeMap<_, _>>();
288
289    let ids = root_instances
290        .iter()
291        .chain(disabled.iter())
292        .chain(host_defaults.keys())
293        .cloned()
294        .collect::<BTreeSet<_>>();
295    let root_releases = snapshot
296        .releases()
297        .iter()
298        .map(|release| release.plugin_id().to_owned())
299        .collect::<BTreeSet<_>>();
300    let mut releases = host
301        .plugins()
302        .iter()
303        .map(|release| {
304            let descriptor = release.descriptor();
305            (
306                descriptor.plugin_id().to_owned(),
307                (
308                    descriptor.release_version().to_owned(),
309                    descriptor.configuration_schema().cloned(),
310                    descriptor.configuration_defaults().clone(),
311                ),
312            )
313        })
314        .chain(snapshot.releases().iter().map(|release| {
315            (
316                release.plugin_id().to_owned(),
317                (
318                    release.release_version().to_owned(),
319                    release.configuration_schema().cloned(),
320                    release.configuration_defaults().clone(),
321                ),
322            )
323        }))
324        .collect::<BTreeMap<_, _>>();
325    for id in &ids {
326        releases
327            .entry(id.plugin_id().to_owned())
328            .or_insert_with(|| {
329                (
330                    String::new(),
331                    None,
332                    Value::Object(serde_json::Map::default()),
333                )
334            });
335    }
336
337    let mut plugins = Vec::with_capacity(releases.len());
338    for (plugin_id, (release_version, configuration_schema, configuration_defaults)) in releases {
339        let plugin_ids = ids
340            .iter()
341            .filter(|id| id.plugin_id() == plugin_id)
342            .cloned()
343            .collect::<Vec<_>>();
344        let mut instances = Vec::with_capacity(plugin_ids.len());
345        for id in plugin_ids {
346            let configuration_path = root
347                .join(PLUGIN_ROOT)
348                .join(id.plugin_id())
349                .join(format!("{}.toml", id.instance_key()));
350            let root_configuration_toml = if root_instances.contains(&id) {
351                Some(fs::read_to_string(&configuration_path).with_context(|| {
352                    format!(
353                        "read Plugin configuration source {}",
354                        configuration_path.display()
355                    )
356                })?)
357            } else {
358                None
359            };
360            let source_digest = instance_source_digest(&id, root_configuration_toml.as_deref());
361            let host_disableable = host_defaults.get(&id).copied();
362            instances.push(PluginInstanceAuthoringState {
363                origin: host_disableable.map_or(PluginInstanceOrigin::PluginRoot, |disableable| {
364                    PluginInstanceOrigin::HostDefault { disableable }
365                }),
366                selection: if enabled.contains(&id) {
367                    PluginInstanceSelection::Enabled
368                } else {
369                    PluginInstanceSelection::DisabledByRoot
370                },
371                root_configuration_toml,
372                source_digest,
373                id,
374            });
375        }
376        plugins.push(PluginAuthoringState {
377            configuration_defaults,
378            configuration_schema,
379            root_supplied: root_releases.contains(&plugin_id),
380            plugin_id,
381            release_version,
382            instances,
383        });
384    }
385    Ok(authoring_state(revision, resolved, plugins))
386}
387
388fn instance_source_digest(
389    id: &PluginInstanceId,
390    source: Option<&str>,
391) -> PluginConfigurationSourceDigest {
392    configuration_authority::source_digest_for_bytes(
393        id.plugin_id(),
394        id.instance_key(),
395        source.map(str::as_bytes),
396    )
397}
398
399fn authoring_state(
400    revision: PluginRootRevision,
401    resolved: ResolvedApp,
402    plugins: Vec<PluginAuthoringState>,
403) -> PluginRootAuthoringState {
404    PluginRootAuthoringState {
405        revision,
406        resolved,
407        plugins,
408    }
409}
410
411fn load_host_catalog(root: &Path) -> anyhow::Result<HostInput> {
412    let generated = root.join(HOST_BUILD);
413    match fs::symlink_metadata(&generated) {
414        Ok(metadata) => {
415            if !metadata.file_type().is_file() {
416                bail!("Host build must be a regular file: {}", generated.display());
417            }
418            match fs::symlink_metadata(root.join(HOST_CATALOG)) {
419                Ok(_) => bail!(
420                    "competing Host authorities: install one complete Host build instead of mixing authority files"
421                ),
422                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
423                Err(error) => return Err(error).context("inspect existing Host Catalog authority"),
424            }
425            let build: GeneratedHostBuild = serde_json::from_slice(&fs::read(&generated)?)
426                .context("invalid generated Host build")?;
427            build.validate()?;
428            return Ok(HostInput::Generated(build));
429        }
430        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
431        Err(error) => return Err(error).context("inspect generated Host build"),
432    }
433    let path = root.join(HOST_CATALOG);
434    let metadata = fs::symlink_metadata(&path).with_context(|| {
435        format!(
436            "Host Catalog is unavailable at {}; build or install the current Host first",
437            path.display()
438        )
439    })?;
440    if !metadata.file_type().is_file() {
441        bail!("Host Catalog must be a regular file: {}", path.display());
442    }
443    let bytes = fs::read(&path).with_context(|| format!("read Host Catalog {}", path.display()))?;
444    serde_json::from_slice(&bytes)
445        .map(HostInput::Legacy)
446        .with_context(|| format!("Host Catalog is invalid: {}", path.display()))
447}
448
449fn snapshot_plugin_root(root: &Path, host: &HostInput) -> anyhow::Result<PluginRootSnapshot> {
450    let plugin_root = root.join(PLUGIN_ROOT);
451    match fs::symlink_metadata(&plugin_root) {
452        Ok(metadata) if metadata.file_type().is_dir() => {}
453        Ok(_) => bail!(
454            "Plugin Root must be a regular directory: {}",
455            plugin_root.display()
456        ),
457        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
458            return Ok(PluginRootSnapshot::default());
459        }
460        Err(error) => {
461            return Err(error).with_context(|| format!("inspect {}", plugin_root.display()));
462        }
463    }
464
465    let mut releases = Vec::new();
466    let mut instances = Vec::new();
467    let mut disabled = Vec::new();
468    let mut dependency_selections = None;
469    let mut plugin_names = BTreeMap::<String, String>::new();
470    let mut directories = read_entries(&plugin_root)?;
471    directories.sort_by_key(fs::DirEntry::file_name);
472    for entry in directories {
473        let name = utf8_name(&entry.path(), &entry.file_name())?;
474        if is_ignored_os_metadata(&name) {
475            continue;
476        }
477        let file_type = entry.file_type()?;
478        if name == DEPENDENCY_SELECTIONS {
479            if !file_type.is_file() {
480                bail!(
481                    "Plugin dependency selections must be a regular file: {}",
482                    entry.path().display()
483                );
484            }
485            let document: DependencySelectionsDocument = serde_json::from_slice(
486                &fs::read(entry.path()).context("read Plugin dependency selections")?,
487            )
488            .context("invalid Plugin dependency selections")?;
489            if document.schema != DEPENDENCY_SELECTIONS_SCHEMA {
490                bail!(
491                    "unsupported Plugin dependency selection schema `{}`",
492                    document.schema
493                );
494            }
495            dependency_selections = Some(document.selections);
496            continue;
497        }
498        if !file_type.is_dir() {
499            bail!("unknown Plugin Root entry: {}", entry.path().display());
500        }
501        let plugin_id = name;
502        validate_existing_plugin_id(&plugin_id)?;
503        reject_case_collision(&mut plugin_names, &plugin_id, "Plugin ID")?;
504        scan_plugin_directory(
505            &entry.path(),
506            &plugin_id,
507            &mut releases,
508            &mut instances,
509            &mut disabled,
510            host,
511        )?;
512    }
513    let snapshot = PluginRootSnapshot::new(releases, instances, disabled);
514    Ok(match dependency_selections {
515        Some(selections) => snapshot.with_dependency_choices(selections),
516        None => snapshot,
517    })
518}
519
520#[derive(Debug, Deserialize, Serialize)]
521#[serde(deny_unknown_fields)]
522pub struct DependencySelectionsDocument {
523    pub schema: String,
524    pub selections: Vec<DependencyChoice>,
525}
526
527fn preserve_dependency_selections(
528    candidate: PluginRootSnapshot,
529    current: &PluginRootSnapshot,
530) -> PluginRootSnapshot {
531    if current.dependency_selection_adopted() {
532        candidate.with_dependency_choices(current.dependency_choices().to_vec())
533    } else {
534        candidate
535    }
536}
537
538fn scan_plugin_directory(
539    directory: &Path,
540    plugin_id: &str,
541    releases: &mut Vec<PluginDescriptor>,
542    instances: &mut Vec<PluginRootInstance>,
543    disabled: &mut Vec<PluginInstanceId>,
544    host: &HostInput,
545) -> anyhow::Result<()> {
546    let mut normalized = BTreeMap::<String, String>::new();
547    let mut configured_instances = BTreeSet::new();
548    let mut resource_directories = BTreeMap::<String, PathBuf>::new();
549    let mut entries = read_entries(directory)?;
550    entries.sort_by_key(fs::DirEntry::file_name);
551    for entry in entries {
552        let name = utf8_name(&entry.path(), &entry.file_name())?;
553        if is_ignored_os_metadata(&name) {
554            continue;
555        }
556        reject_case_collision(&mut normalized, &name, "Plugin filename")?;
557        let file_type = entry.file_type()?;
558        if name == BUNDLE_NAME {
559            if !file_type.is_dir() {
560                bail!(
561                    "Plugin Bundle must be a regular directory: {}",
562                    entry.path().display()
563                );
564            }
565            releases.push(read_bundle_descriptor(&entry.path(), plugin_id, host)?);
566            continue;
567        }
568        if file_type.is_dir() {
569            validate_instance_filename(&name)?;
570            resource_directories.insert(name, entry.path());
571            continue;
572        }
573        if !file_type.is_file() {
574            bail!(
575                "Plugin entries cannot be symlinks or special files: {}",
576                entry.path().display()
577            );
578        }
579        if let Some(instance) = name.strip_suffix(".toml") {
580            validate_instance_filename(instance)?;
581            configured_instances.insert(instance.to_owned());
582            instances.push(
583                PluginRootInstance::new(plugin_id, instance)
584                    .with_configuration(read_configuration(&entry.path())?),
585            );
586        } else if let Some(instance) = name.strip_suffix(".disabled") {
587            validate_instance_filename(instance)?;
588            if fs::metadata(entry.path())?.len() != 0 {
589                bail!("disabled marker must be empty: {}", entry.path().display());
590            }
591            disabled.push(PluginInstanceId::new(plugin_id, instance));
592        } else {
593            bail!("unknown Plugin file: {}", entry.path().display());
594        }
595    }
596    for (instance, resource_directory) in resource_directories {
597        if !configured_instances.contains(&instance) {
598            bail!(
599                "orphan Plugin resource directory without `{instance}.toml`: {}",
600                resource_directory.display()
601            );
602        }
603        validate_resource_directory(&resource_directory)?;
604    }
605    Ok(())
606}
607
608fn validate_resource_directory(path: &Path) -> anyhow::Result<()> {
609    let mut file_count = 0_usize;
610    let mut total_size = 0_u64;
611    let mut pending = vec![(path.to_path_buf(), 0_usize)];
612    while let Some((directory, depth)) = pending.pop() {
613        if depth > MAX_RESOURCE_DEPTH {
614            bail!(
615                "Plugin resource directory exceeds {MAX_RESOURCE_DEPTH} levels: {}",
616                directory.display()
617            );
618        }
619        let mut entries = read_entries(&directory)?;
620        entries.sort_by_key(fs::DirEntry::file_name);
621        for entry in entries {
622            let entry_path = entry.path();
623            let name = utf8_name(&entry_path, &entry.file_name())?;
624            if is_ignored_os_metadata(&name) {
625                continue;
626            }
627            let file_type = entry.file_type()?;
628            if file_type.is_dir() {
629                pending.push((entry_path, depth + 1));
630                continue;
631            }
632            if !file_type.is_file() {
633                bail!(
634                    "Plugin resources cannot contain symlinks or special files: {}",
635                    entry_path.display()
636                );
637            }
638            if file_count == MAX_RESOURCE_FILES {
639                bail!(
640                    "Plugin resources exceed {MAX_RESOURCE_FILES} files: {}",
641                    path.display()
642                );
643            }
644            let metadata = fs::symlink_metadata(&entry_path)?;
645            if metadata.file_type().is_symlink() || !metadata.file_type().is_file() {
646                bail!(
647                    "Plugin resources must be regular files: {}",
648                    entry_path.display()
649                );
650            }
651            if metadata.len() > MAX_RESOURCE_FILE_BYTES {
652                bail!("Plugin resource exceeds 1 MiB: {}", entry_path.display());
653            }
654            let bytes = fs::read(&entry_path)?;
655            let byte_count = u64::try_from(bytes.len()).with_context(|| {
656                format!("Plugin resource is too large: {}", entry_path.display())
657            })?;
658            if byte_count > MAX_RESOURCE_FILE_BYTES {
659                bail!("Plugin resource exceeds 1 MiB: {}", entry_path.display());
660            }
661            total_size = total_size
662                .checked_add(byte_count)
663                .with_context(|| format!("Plugin resource size overflow: {}", path.display()))?;
664            if total_size > MAX_RESOURCE_TOTAL_BYTES {
665                bail!("Plugin resources exceed 16 MiB: {}", path.display());
666            }
667            file_count += 1;
668        }
669    }
670    Ok(())
671}
672
673fn is_ignored_os_metadata(name: &str) -> bool {
674    name == ".DS_Store"
675}
676
677fn read_bundle_descriptor(
678    path: &Path,
679    plugin_id: &str,
680    host: &HostInput,
681) -> anyhow::Result<PluginDescriptor> {
682    validate_existing_plugin_id(plugin_id)?;
683    let verified = verify_bundle_directory(path)
684        .with_context(|| format!("verify Plugin Bundle {}", path.display()))?;
685    if verified.plugin_id != plugin_id {
686        bail!("Plugin Bundle ID does not match its directory");
687    }
688    host.select_bundle(path, &verified)
689}
690
691fn read_verified_bundle_descriptor(
692    path: &Path,
693    plugin_id: &str,
694    verified: &VerifiedBundle,
695) -> anyhow::Result<PluginDescriptor> {
696    if verified.plugin_id != plugin_id {
697        bail!(
698            "Plugin Bundle ID `{}` does not match directory `{plugin_id}`",
699            verified.plugin_id
700        );
701    }
702    let manifest = read_bundle_manifest(path)
703        .with_context(|| format!("read Plugin Manifest {}", path.display()))?;
704    let descriptor = resolve_implementation(
705        &manifest,
706        &ImplementationPolicy {
707            host_target: format!("{}-unknown-{}", env::consts::ARCH, env::consts::OS),
708            runtimes: [
709                ("lenso.quickjs@1", "lenso.quickjs@1"),
710                ("lenso.process@1", "lenso.process-stdio@2"),
711                ("lenso.process@1", "lenso.process@1"),
712                ("lenso.wasm-component@1", "lenso.wasm-component@1"),
713                ("lenso.bun-process@1", PLUGIN_AUTHORING_V2_RUNTIME_PROFILE),
714                ("lenso.bun-process@1", "lenso.bun-process@1"),
715            ]
716            .into_iter()
717            .map(|(execution_class, runtime_profile)| RuntimeAdmission {
718                execution_class: ExecutionClassId::new(execution_class),
719                runtime_profile: runtime_profile.to_owned(),
720            })
721            .collect(),
722        },
723    )?
724    .descriptor;
725    if descriptor.plugin_id() != plugin_id
726        || descriptor.release_version() != verified.release_version
727    {
728        bail!("Plugin Descriptor identity does not match the verified Bundle");
729    }
730    Ok(descriptor)
731}
732
733fn read_configuration(path: &Path) -> anyhow::Result<serde_json::Value> {
734    let metadata = fs::metadata(path)?;
735    if metadata.len() > MAX_CONFIGURATION_BYTES {
736        bail!("Plugin configuration exceeds 256 KiB: {}", path.display());
737    }
738    let text = fs::read_to_string(path)
739        .with_context(|| format!("read Plugin configuration {}", path.display()))?;
740    let table: toml::Table = toml::from_str(&text)
741        .with_context(|| format!("parse Plugin configuration {}", path.display()))?;
742    serde_json::to_value(table).context("convert Plugin configuration to portable values")
743}
744
745fn read_entries(path: &Path) -> anyhow::Result<Vec<fs::DirEntry>> {
746    fs::read_dir(path)
747        .with_context(|| format!("read directory {}", path.display()))?
748        .collect::<Result<Vec<_>, _>>()
749        .with_context(|| format!("read directory entries {}", path.display()))
750}
751
752fn utf8_name(path: &Path, name: &std::ffi::OsStr) -> anyhow::Result<String> {
753    name.to_str()
754        .map(str::to_owned)
755        .with_context(|| format!("Plugin path is not UTF-8: {}", path.display()))
756}
757
758fn validate_instance_filename(instance: &str) -> anyhow::Result<()> {
759    validate_path_identity(instance, "Instance key")?;
760    if instance.starts_with('.') || instance == "plugin" {
761        bail!("reserved Plugin Instance key `{instance}`");
762    }
763    Ok(())
764}
765
766fn validate_existing_plugin_id(plugin_id: &str) -> anyhow::Result<()> {
767    validate_path_identity(plugin_id, "Plugin ID")?;
768    classify_existing_plugin_id(plugin_id).map(|_| ())
769}
770
771fn validate_path_identity(value: &str, label: &str) -> anyhow::Result<()> {
772    if value.trim() != value
773        || value.is_empty()
774        || value == "."
775        || value == ".."
776        || value.contains(['/', '\0', '\\'])
777    {
778        bail!("invalid {label} `{value}`");
779    }
780    Ok(())
781}
782
783fn reject_case_collision(
784    normalized: &mut BTreeMap<String, String>,
785    value: &str,
786    label: &str,
787) -> anyhow::Result<()> {
788    let key = value.to_lowercase();
789    if let Some(previous) = normalized.insert(key, value.to_owned())
790        && previous != value
791    {
792        bail!("case-colliding {label}s `{previous}` and `{value}`");
793    }
794    Ok(())
795}
796
797/// Adds one verified external Plugin Bundle after resolving the complete candidate App.
798pub fn add_bundle(root: &Path, bundle: &Path) -> anyhow::Result<(String, String, ResolvedApp)> {
799    prepare_bundle_mutation(root, bundle, BundleMutation::Add)?.commit()
800}
801
802/// Desired root-Bundle mutation validated before visible bytes change.
803#[derive(Clone, Copy, Debug, Eq, PartialEq)]
804pub enum BundleMutation {
805    Add,
806    Replace,
807    /// Restore bytes already retained for a legacy or v1 root Plugin.
808    Restore,
809}
810
811/// Stable staged bytes and candidate resolution for one pending Bundle mutation.
812///
813/// Callers may inspect the verified identity before committing, which lets a
814/// catalog compare its signed metadata without re-reading or re-hashing the
815/// Bundle. The staged directory is removed automatically unless `commit` is
816/// called.
817#[derive(Debug)]
818pub struct PreparedBundleMutation {
819    authority: fs::File,
820    destination: PathBuf,
821    mutation: BundleMutation,
822    resolved: ResolvedApp,
823    staging: tempfile::TempDir,
824    verified: VerifiedBundle,
825}
826
827impl PreparedBundleMutation {
828    pub const fn verified(&self) -> &VerifiedBundle {
829        &self.verified
830    }
831
832    pub const fn resolved(&self) -> &ResolvedApp {
833        &self.resolved
834    }
835
836    pub fn destination(&self) -> &Path {
837        &self.destination
838    }
839
840    /// Atomically makes the already-validated staged Bundle visible.
841    pub fn commit(self) -> anyhow::Result<(String, String, ResolvedApp)> {
842        let Self {
843            authority,
844            destination,
845            mutation,
846            resolved,
847            staging,
848            verified,
849        } = self;
850        let commit = commit_staged_bundle(&destination, mutation, staging);
851        drop(authority);
852        commit?;
853        Ok((verified.plugin_id, verified.release_version, resolved))
854    }
855}
856
857fn commit_staged_bundle(
858    destination: &Path,
859    mutation: BundleMutation,
860    staging: tempfile::TempDir,
861) -> anyhow::Result<()> {
862    commit_staged_bundle_with(
863        destination,
864        mutation,
865        staging,
866        atomic_publish_bundle,
867        tempfile::TempDir::close,
868    )
869}
870
871fn commit_staged_bundle_with<Publish, Retire>(
872    destination: &Path,
873    mutation: BundleMutation,
874    staging: tempfile::TempDir,
875    publish: Publish,
876    retire: Retire,
877) -> anyhow::Result<()>
878where
879    Publish: FnOnce(&Path, &Path, BundleMutation) -> std::io::Result<()>,
880    Retire: FnOnce(tempfile::TempDir) -> std::io::Result<()>,
881{
882    let parent = destination
883        .parent()
884        .context("Bundle destination has no parent")?;
885    if mutation == BundleMutation::Add && destination.exists() {
886        bail!("Plugin Bundle already exists: {}", destination.display());
887    }
888    let created_parent = mutation == BundleMutation::Add && !parent.exists();
889    if mutation == BundleMutation::Add {
890        fs::create_dir_all(parent)?;
891    }
892    let publication =
893        publish(staging.path(), destination, mutation).with_context(|| match mutation {
894            BundleMutation::Add => format!("commit Plugin Bundle {}", destination.display()),
895            BundleMutation::Replace | BundleMutation::Restore => {
896                format!("atomically replace Plugin Bundle {}", destination.display())
897            }
898        });
899    if let Err(error) = publication {
900        if created_parent
901            && let Err(cleanup_error) = fs::remove_dir(parent)
902            && cleanup_error.kind() != std::io::ErrorKind::NotFound
903            && cleanup_error.kind() != std::io::ErrorKind::DirectoryNotEmpty
904        {
905            return Err(error.context(format!(
906                "also failed to remove empty Plugin directory {}: {cleanup_error}",
907                parent.display()
908            )));
909        }
910        return Err(error);
911    }
912
913    if mutation != BundleMutation::Add
914        && let Err(error) = retire(staging)
915    {
916        // EXCHANGE is the commit point: the new Bundle is already visible and
917        // the old one is isolated at the hidden staging path. Cleanup failure
918        // must not misreport a successfully committed mutation as rejected.
919        eprintln!("warning: Plugin Bundle committed, but retired Bundle cleanup failed: {error}");
920    }
921    Ok(())
922}
923
924#[cfg(any(target_os = "linux", target_vendor = "apple"))]
925fn atomic_publish_bundle(
926    staging: &Path,
927    destination: &Path,
928    mutation: BundleMutation,
929) -> std::io::Result<()> {
930    use rustix::fs::{CWD, RenameFlags, renameat_with};
931
932    let flags = match mutation {
933        BundleMutation::Add => RenameFlags::NOREPLACE,
934        BundleMutation::Replace | BundleMutation::Restore => RenameFlags::EXCHANGE,
935    };
936    renameat_with(CWD, staging, CWD, destination, flags).map_err(std::io::Error::from)
937}
938
939#[cfg(windows)]
940fn atomic_publish_bundle(
941    staging: &Path,
942    destination: &Path,
943    mutation: BundleMutation,
944) -> std::io::Result<()> {
945    match mutation {
946        // MoveFileW is intentionally used without a replacement flag: it is
947        // one atomic rename and fails if a concurrent writer won the target.
948        BundleMutation::Add => winsafe::MoveFile(
949            staging.to_str().ok_or_else(|| {
950                std::io::Error::new(
951                    std::io::ErrorKind::InvalidInput,
952                    "Plugin Bundle staging path is not Unicode",
953                )
954            })?,
955            destination.to_str().ok_or_else(|| {
956                std::io::Error::new(
957                    std::io::ErrorKind::InvalidInput,
958                    "Plugin Bundle destination path is not Unicode",
959                )
960            })?,
961        )
962        .map_err(|error| std::io::Error::from_raw_os_error(error.raw() as i32)),
963        BundleMutation::Replace | BundleMutation::Restore => Err(std::io::Error::new(
964            std::io::ErrorKind::Unsupported,
965            "atomic Plugin Bundle replacement is unavailable on this platform",
966        )),
967    }
968}
969
970#[cfg(not(any(target_os = "linux", target_vendor = "apple", windows)))]
971fn atomic_publish_bundle(
972    _staging: &Path,
973    _destination: &Path,
974    _mutation: BundleMutation,
975) -> std::io::Result<()> {
976    Err(std::io::Error::new(
977        std::io::ErrorKind::Unsupported,
978        "atomic Plugin Bundle publication is unavailable on this platform",
979    ))
980}
981
982/// Copies one candidate into stable staging, validates it once, and resolves
983/// the complete candidate App before any Plugin Root bytes change.
984pub fn prepare_bundle_mutation(
985    root: &Path,
986    bundle: &Path,
987    mutation: BundleMutation,
988) -> anyhow::Result<PreparedBundleMutation> {
989    let staging = tempfile::Builder::new()
990        .prefix(".plugin-bundle-")
991        .tempdir_in(root)?;
992    copy_directory(bundle, staging.path())?;
993    let authority = lock_plugin_root(root)?;
994    let host = load_host_catalog(root)?;
995    let (verified, descriptor) = verify_bundle_mutation(staging.path(), mutation, &host)?;
996    let resolved = resolve_bundle_mutation(root, mutation, &verified, descriptor, &host)?;
997    let destination = root
998        .join(PLUGIN_ROOT)
999        .join(&verified.plugin_id)
1000        .join(BUNDLE_NAME);
1001    Ok(PreparedBundleMutation {
1002        authority,
1003        destination,
1004        mutation,
1005        resolved,
1006        staging,
1007        verified,
1008    })
1009}
1010
1011/// Verifies one Bundle and resolves the complete candidate App for an add or replacement.
1012///
1013/// `prepare_bundle_mutation` is the preferred mutation boundary because it
1014/// also owns stable staged bytes and the atomic commit.
1015pub fn validate_bundle_mutation(
1016    root: &Path,
1017    bundle: &Path,
1018    mutation: BundleMutation,
1019) -> anyhow::Result<(lenso_plugin_bundle::VerifiedBundle, ResolvedApp)> {
1020    let _lock = lock_plugin_root(root)?;
1021    let host = load_host_catalog(root)?;
1022    let (verified, descriptor) = verify_bundle_mutation(bundle, mutation, &host)?;
1023    let resolved = resolve_bundle_mutation(root, mutation, &verified, descriptor, &host)?;
1024    Ok((verified, resolved))
1025}
1026
1027fn verify_bundle_mutation(
1028    bundle: &Path,
1029    mutation: BundleMutation,
1030    host: &HostInput,
1031) -> anyhow::Result<(VerifiedBundle, PluginDescriptor)> {
1032    let verified = verify_bundle_directory(bundle)
1033        .with_context(|| format!("verify Plugin Bundle {}", bundle.display()))?;
1034    match mutation {
1035        BundleMutation::Add | BundleMutation::Replace => {
1036            validate_plugin_id_v1(&verified.plugin_id)?;
1037        }
1038        BundleMutation::Restore => {
1039            classify_existing_plugin_id(&verified.plugin_id)?;
1040        }
1041    }
1042    validate_release_version(&verified.release_version)?;
1043    let descriptor = host.select_bundle(bundle, &verified)?;
1044    Ok((verified, descriptor))
1045}
1046
1047fn resolve_bundle_mutation(
1048    root: &Path,
1049    mutation: BundleMutation,
1050    verified: &VerifiedBundle,
1051    descriptor: PluginDescriptor,
1052    host: &HostInput,
1053) -> anyhow::Result<ResolvedApp> {
1054    let current = snapshot_plugin_root(root, host)?;
1055    let has_current = current
1056        .releases()
1057        .iter()
1058        .any(|release| release.plugin_id() == verified.plugin_id);
1059    match (mutation, has_current) {
1060        (BundleMutation::Add, true) => {
1061            bail!("Plugin `{}` already has a root Bundle", verified.plugin_id)
1062        }
1063        (BundleMutation::Replace | BundleMutation::Restore, false) => {
1064            bail!(
1065                "Plugin `{}` has no root Bundle to update",
1066                verified.plugin_id
1067            )
1068        }
1069        _ => {}
1070    }
1071    let candidate = preserve_dependency_selections(
1072        PluginRootSnapshot::new(
1073            current
1074                .releases()
1075                .iter()
1076                .filter(|release| release.plugin_id() != verified.plugin_id)
1077                .cloned()
1078                .chain([descriptor]),
1079            current.instances().iter().cloned(),
1080            current.disabled().iter().cloned(),
1081        ),
1082        &current,
1083    );
1084    let resolved = host.resolve(&candidate).map_err(anyhow::Error::msg)?;
1085    Ok(resolved)
1086}
1087
1088/// Atomically writes one typed Instance patch after resolving the complete candidate App.
1089pub fn configure_instance(
1090    root: &Path,
1091    plugin_id: &str,
1092    instance: &str,
1093    bytes: &[u8],
1094) -> anyhow::Result<ResolvedApp> {
1095    let base_revision = inspect_plugin_root(root)?.revision().clone();
1096    let proposal =
1097        propose_instance_configuration(root, &base_revision, plugin_id, instance, bytes)?;
1098    let publication = publish_instance_configuration(root, &proposal)?;
1099    Ok(publication.into_resolved())
1100}
1101
1102/// Atomically changes one Instance selection marker after candidate resolution.
1103pub fn set_instance_disabled(
1104    root: &Path,
1105    plugin_id: &str,
1106    instance: &str,
1107    disabled_state: bool,
1108) -> anyhow::Result<ResolvedApp> {
1109    set_instance_disabled_inner(root, plugin_id, instance, disabled_state, None)
1110        .map(|(_, _, resolved)| resolved)
1111}
1112
1113/// Saves one exact App-owned dependency choice after validating the complete candidate App.
1114pub fn set_dependency_selection(
1115    root: &Path,
1116    consumer: PluginInstanceId,
1117    requirement_id: &str,
1118    provider: Option<PluginInstanceId>,
1119) -> anyhow::Result<ResolvedApp> {
1120    let selection = DependencyChoice {
1121        consumer,
1122        requirement_id: requirement_id.to_owned(),
1123        provider,
1124    };
1125    set_dependency_selections(root, [selection])
1126}
1127
1128/// Atomically applies one or more App-owned dependency choices as one validated candidate.
1129pub fn set_dependency_selections(
1130    root: &Path,
1131    replacements: impl IntoIterator<Item = DependencyChoice>,
1132) -> anyhow::Result<ResolvedApp> {
1133    let replacements = replacements.into_iter().collect::<Vec<_>>();
1134    if replacements.is_empty() {
1135        bail!("at least one dependency selection is required");
1136    }
1137    let mut replacement_keys = BTreeSet::new();
1138    for selection in &replacements {
1139        validate_existing_plugin_id(selection.consumer.plugin_id())?;
1140        validate_instance_filename(selection.consumer.instance_key())?;
1141        if selection.requirement_id.trim().is_empty() {
1142            bail!("dependency requirement identity must not be empty");
1143        }
1144        if let Some(provider) = &selection.provider {
1145            validate_existing_plugin_id(provider.plugin_id())?;
1146            validate_instance_filename(provider.instance_key())?;
1147        }
1148        if !replacement_keys.insert((&selection.consumer, selection.requirement_id.as_str())) {
1149            bail!(
1150                "duplicate dependency selection for `{}` requirement `{}`",
1151                selection.consumer,
1152                selection.requirement_id
1153            );
1154        }
1155    }
1156    let _lock = lock_plugin_root(root)?;
1157    let host = load_host_catalog(root)?;
1158    let current = snapshot_plugin_root(root, &host)?;
1159    let mut selections = current.dependency_choices().to_vec();
1160    selections.retain(|selection| {
1161        !replacement_keys.contains(&(&selection.consumer, selection.requirement_id.as_str()))
1162    });
1163    drop(replacement_keys);
1164    selections.extend(replacements);
1165    selections.sort_by(|left, right| {
1166        left.consumer
1167            .cmp(&right.consumer)
1168            .then_with(|| left.requirement_id.cmp(&right.requirement_id))
1169    });
1170    let (resolved, selections) = resolve_adopted_dependencies(&host, &current, selections)?;
1171    let document = DependencySelectionsDocument {
1172        schema: DEPENDENCY_SELECTIONS_SCHEMA.to_owned(),
1173        selections,
1174    };
1175    let bytes = serde_json::to_vec_pretty(&document).context("encode dependency selections")?;
1176    atomic_write(&root.join(PLUGIN_ROOT).join(DEPENDENCY_SELECTIONS), &bytes)?;
1177    Ok(resolved)
1178}
1179
1180fn resolve_adopted_dependencies(
1181    host: &HostInput,
1182    current: &PluginRootSnapshot,
1183    mut selections: Vec<DependencyChoice>,
1184) -> anyhow::Result<(ResolvedApp, Vec<DependencyChoice>)> {
1185    selections.sort_by(|left, right| {
1186        left.consumer
1187            .cmp(&right.consumer)
1188            .then_with(|| left.requirement_id.cmp(&right.requirement_id))
1189    });
1190    let candidate = PluginRootSnapshot::new(
1191        current.releases().iter().cloned(),
1192        current.instances().iter().cloned(),
1193        current.disabled().iter().cloned(),
1194    )
1195    .with_dependency_choices(selections);
1196    let proposed = host.propose(&candidate).map_err(anyhow::Error::msg)?;
1197    let selections = proposed.dependency_choices().to_vec();
1198    let materialized = PluginRootSnapshot::new(
1199        current.releases().iter().cloned(),
1200        current.instances().iter().cloned(),
1201        current.disabled().iter().cloned(),
1202    )
1203    .with_dependency_choices(selections.clone());
1204    let resolved = host.resolve(&materialized).map_err(anyhow::Error::msg)?;
1205    Ok((resolved, selections))
1206}
1207
1208fn set_instance_disabled_inner(
1209    root: &Path,
1210    plugin_id: &str,
1211    instance: &str,
1212    disabled_state: bool,
1213    expected_revision: Option<&PluginRootRevision>,
1214) -> anyhow::Result<(PluginRootRevision, PluginRootRevision, ResolvedApp)> {
1215    validate_existing_plugin_id(plugin_id)?;
1216    validate_instance_filename(instance)?;
1217    let _lock = lock_plugin_root(root)?;
1218    let host = load_host_catalog(root)?;
1219    let current = snapshot_plugin_root(root, &host)?;
1220    let base_revision = configuration_authority::revision_for_snapshot(&current)?;
1221    if let Some(expected_revision) = expected_revision {
1222        configuration_authority::ensure_revision(expected_revision, &base_revision)?;
1223    }
1224    let id = PluginInstanceId::new(plugin_id, instance);
1225    let mut disabled = current.disabled().iter().cloned().collect::<BTreeSet<_>>();
1226    if disabled_state {
1227        disabled.insert(id.clone());
1228    } else if !disabled.remove(&id) {
1229        bail!("Plugin Instance `{id}` is not disabled");
1230    }
1231    let candidate = preserve_dependency_selections(
1232        PluginRootSnapshot::new(
1233            current.releases().iter().cloned(),
1234            current.instances().iter().cloned(),
1235            disabled,
1236        ),
1237        &current,
1238    );
1239    let candidate_revision = configuration_authority::revision_for_snapshot(&candidate)?;
1240    let resolved = host.resolve(&candidate).map_err(anyhow::Error::msg)?;
1241    let marker = root
1242        .join(PLUGIN_ROOT)
1243        .join(plugin_id)
1244        .join(format!("{instance}.disabled"));
1245    if disabled_state {
1246        atomic_write(&marker, &[])?;
1247    } else {
1248        fs::remove_file(&marker)
1249            .with_context(|| format!("remove disabled marker {}", marker.display()))?;
1250    }
1251    Ok((base_revision, candidate_revision, resolved))
1252}
1253
1254/// Removes one App-owned Instance difference after validating the remaining App.
1255pub fn remove_instance_difference(
1256    root: &Path,
1257    plugin_id: &str,
1258    instance: &str,
1259) -> anyhow::Result<ResolvedApp> {
1260    validate_existing_plugin_id(plugin_id)?;
1261    validate_instance_filename(instance)?;
1262    let _lock = lock_plugin_root(root)?;
1263    let host = load_host_catalog(root)?;
1264    let current = snapshot_plugin_root(root, &host)?;
1265    let id = PluginInstanceId::new(plugin_id, instance);
1266    let candidate = preserve_dependency_selections(
1267        PluginRootSnapshot::new(
1268            current.releases().iter().cloned(),
1269            current
1270                .instances()
1271                .iter()
1272                .filter(|item| item.id() != &id)
1273                .cloned(),
1274            current
1275                .disabled()
1276                .iter()
1277                .filter(|item| *item != &id)
1278                .cloned(),
1279        ),
1280        &current,
1281    );
1282    let resolved = host.resolve(&candidate).map_err(anyhow::Error::msg)?;
1283    let plugin_directory = root.join(PLUGIN_ROOT).join(plugin_id);
1284    remove_if_exists(&plugin_directory.join(format!("{instance}.toml")))?;
1285    remove_if_exists(&plugin_directory.join(format!("{instance}.disabled")))?;
1286    Ok(resolved)
1287}
1288
1289/// Moves one root-supplied Plugin to recoverable trash after validating the remaining App.
1290pub fn remove_plugin(root: &Path, plugin_id: &str) -> anyhow::Result<(ResolvedApp, PathBuf)> {
1291    validate_existing_plugin_id(plugin_id)?;
1292    let _lock = lock_plugin_root(root)?;
1293    let host = load_host_catalog(root)?;
1294    let current = snapshot_plugin_root(root, &host)?;
1295    let candidate = preserve_dependency_selections(
1296        PluginRootSnapshot::new(
1297            current
1298                .releases()
1299                .iter()
1300                .filter(|release| release.plugin_id() != plugin_id)
1301                .cloned(),
1302            current
1303                .instances()
1304                .iter()
1305                .filter(|instance| instance.id().plugin_id() != plugin_id)
1306                .cloned(),
1307            current
1308                .disabled()
1309                .iter()
1310                .filter(|instance| instance.plugin_id() != plugin_id)
1311                .cloned(),
1312        ),
1313        &current,
1314    );
1315    let resolved = host.resolve(&candidate).map_err(anyhow::Error::msg)?;
1316    let plugin_directory = root.join(PLUGIN_ROOT).join(plugin_id);
1317    if !plugin_directory.exists() {
1318        bail!("Plugin `{plugin_id}` has no Plugin Root directory");
1319    }
1320    let trash = root
1321        .join(".lenso/trash")
1322        .join(format!("{plugin_id}-{}", uuid::Uuid::now_v7()));
1323    fs::create_dir_all(trash.parent().expect("trash has a parent"))?;
1324    fs::rename(&plugin_directory, &trash)?;
1325    Ok((resolved, trash))
1326}
1327
1328fn atomic_write(path: &Path, bytes: &[u8]) -> anyhow::Result<()> {
1329    let parent = path.parent().context("Plugin file has no parent")?;
1330    fs::create_dir_all(parent)?;
1331    let temporary = tempfile::NamedTempFile::new_in(parent)?;
1332    fs::write(temporary.path(), bytes)?;
1333    temporary
1334        .persist(path)
1335        .map_err(|error| error.error)
1336        .with_context(|| format!("commit Plugin file {}", path.display()))?;
1337    Ok(())
1338}
1339
1340fn lock_plugin_root(root: &Path) -> anyhow::Result<fs::File> {
1341    let path = root.join(AUTHORING_LOCK);
1342    let parent = path.parent().context("Plugin Root lock has no parent")?;
1343    fs::create_dir_all(parent)?;
1344    let file = fs::OpenOptions::new()
1345        .create(true)
1346        .read(true)
1347        .write(true)
1348        .truncate(false)
1349        .open(&path)
1350        .with_context(|| format!("open Plugin Root authoring lock {}", path.display()))?;
1351    file.lock()
1352        .with_context(|| format!("lock Plugin Root authoring authority {}", path.display()))?;
1353    Ok(file)
1354}
1355fn copy_directory(source: &Path, destination: &Path) -> anyhow::Result<()> {
1356    for entry in read_entries(source)? {
1357        let file_type = entry.file_type()?;
1358        if file_type.is_dir() {
1359            let child = destination.join(entry.file_name());
1360            fs::create_dir_all(&child)?;
1361            copy_directory(&entry.path(), &child)?;
1362            continue;
1363        }
1364        if !file_type.is_file() {
1365            bail!(
1366                "Plugin Bundle contains a non-file entry: {}",
1367                entry.path().display()
1368            );
1369        }
1370        fs::copy(entry.path(), destination.join(entry.file_name()))?;
1371    }
1372    Ok(())
1373}
1374
1375fn remove_if_exists(path: &Path) -> anyhow::Result<()> {
1376    match fs::remove_file(path) {
1377        Ok(()) => Ok(()),
1378        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1379        Err(error) => Err(error).with_context(|| format!("remove {}", path.display())),
1380    }
1381}
1382
1383#[cfg(test)]
1384mod tests {
1385    use super::*;
1386    use lenso_app_plan::authoring::{
1387        HostBinding, HostCatalog, HostDefaultPlugin, HostPluginRelease, HostSlot,
1388    };
1389    use lenso_app_plan::{CapabilityEndpointPlan, CapabilityRequirementPlan};
1390
1391    fn fixture_root() -> tempfile::TempDir {
1392        let root = tempfile::tempdir().unwrap();
1393        fs::create_dir_all(root.path().join(".lenso")).unwrap();
1394        let host = HostCatalog::new(
1395            [HostSlot::one("agent")],
1396            [HostPluginRelease::new(PluginDescriptor::new(
1397                "example.agent",
1398                "1.0.0",
1399                "agent",
1400            ))],
1401            [HostDefaultPlugin::new("example.agent", "default")],
1402        );
1403        fs::write(
1404            root.path().join(HOST_CATALOG),
1405            serde_json::to_vec(&host).unwrap(),
1406        )
1407        .unwrap();
1408        root
1409    }
1410
1411    #[test]
1412    fn missing_plugin_root_resolves_the_host_default_app() {
1413        let root = fixture_root();
1414        let resolved = load_resolved_app(root.path()).unwrap();
1415
1416        assert_eq!(resolved.instances().len(), 1);
1417        assert_eq!(
1418            resolved.instances()[0].id().to_string(),
1419            "example.agent/default"
1420        );
1421    }
1422
1423    #[test]
1424    fn dependency_choice_is_materialized_and_survives_a_new_compatible_provider() {
1425        let root = tempfile::tempdir().unwrap();
1426        fs::create_dir_all(root.path().join(".lenso")).unwrap();
1427        let consumer = PluginDescriptor::new("example.copy", "1.0.0", "copy")
1428            .with_authoring(2, "lenso.test-authoring@2")
1429            .with_requirement(
1430                CapabilityRequirementPlan::one("example.store@1", "1.0.0")
1431                    .with_requirement_id("source"),
1432            );
1433        let store = |plugin_id: &str| {
1434            PluginDescriptor::new(plugin_id, "1.0.0", "store").with_capability(
1435                CapabilityEndpointPlan::new("example.store@1", "1.0.0", ["get"]),
1436            )
1437        };
1438        let host = HostCatalog::new(
1439            [HostSlot::one("copy"), HostSlot::many("store")],
1440            [
1441                HostPluginRelease::new(consumer.clone()),
1442                HostPluginRelease::new(store("example.store.a")),
1443            ],
1444            [
1445                HostDefaultPlugin::new("example.copy", "default"),
1446                HostDefaultPlugin::new("example.store.a", "default"),
1447            ],
1448        )
1449        .with_bindings([HostBinding::new(
1450            PluginInstanceId::new("example.copy", "default"),
1451            "example.store@1",
1452            "store",
1453        )
1454        .with_requirement_id("source")
1455        .selectable(None)]);
1456        fs::write(
1457            root.path().join(HOST_CATALOG),
1458            serde_json::to_vec(&host).unwrap(),
1459        )
1460        .unwrap();
1461
1462        set_dependency_selection(
1463            root.path(),
1464            PluginInstanceId::new("example.copy", "default"),
1465            "source",
1466            Some(PluginInstanceId::new("example.store.a", "default")),
1467        )
1468        .unwrap();
1469        assert!(root.path().join("plugins/dependencies.json").is_file());
1470
1471        let expanded = HostCatalog::new(
1472            [HostSlot::one("copy"), HostSlot::many("store")],
1473            [
1474                HostPluginRelease::new(consumer),
1475                HostPluginRelease::new(store("example.store.a")),
1476                HostPluginRelease::new(store("example.store.b")),
1477            ],
1478            [
1479                HostDefaultPlugin::new("example.copy", "default"),
1480                HostDefaultPlugin::new("example.store.a", "default"),
1481                HostDefaultPlugin::new("example.store.b", "default"),
1482            ],
1483        )
1484        .with_bindings([HostBinding::new(
1485            PluginInstanceId::new("example.copy", "default"),
1486            "example.store@1",
1487            "store",
1488        )
1489        .with_requirement_id("source")
1490        .selectable(None)]);
1491        fs::write(
1492            root.path().join(HOST_CATALOG),
1493            serde_json::to_vec(&expanded).unwrap(),
1494        )
1495        .unwrap();
1496
1497        let resolved = load_resolved_app(root.path()).unwrap();
1498        assert_eq!(
1499            resolved.plan().capability_bindings()[0].provider_instance(),
1500            "example.store.a/default"
1501        );
1502    }
1503
1504    #[test]
1505    fn first_bind_repairs_the_requested_legacy_ambiguity_and_materializes_unique_choices() {
1506        let root = tempfile::tempdir().unwrap();
1507        fs::create_dir_all(root.path().join(".lenso")).unwrap();
1508        let consumer = PluginDescriptor::new("example.copy", "1.0.0", "copy")
1509            .with_authoring(2, "lenso.test-authoring@2")
1510            .with_requirement(
1511                CapabilityRequirementPlan::one("example.store@1", "1.0.0")
1512                    .with_requirement_id("source"),
1513            )
1514            .with_requirement(
1515                CapabilityRequirementPlan::one("example.audit@1", "1.0.0")
1516                    .with_requirement_id("audit"),
1517            );
1518        let store = |plugin_id: &str| {
1519            PluginDescriptor::new(plugin_id, "1.0.0", "store").with_capability(
1520                CapabilityEndpointPlan::new("example.store@1", "1.0.0", ["get"]),
1521            )
1522        };
1523        let audit = PluginDescriptor::new("example.audit", "1.0.0", "audit").with_capability(
1524            CapabilityEndpointPlan::new("example.audit@1", "1.0.0", ["record"]),
1525        );
1526        let host = HostCatalog::new(
1527            [
1528                HostSlot::one("copy"),
1529                HostSlot::many("store"),
1530                HostSlot::one("audit"),
1531            ],
1532            [
1533                HostPluginRelease::new(consumer),
1534                HostPluginRelease::new(store("example.store.a")),
1535                HostPluginRelease::new(store("example.store.b")),
1536                HostPluginRelease::new(audit),
1537            ],
1538            [
1539                HostDefaultPlugin::new("example.copy", "default"),
1540                HostDefaultPlugin::new("example.store.a", "default"),
1541                HostDefaultPlugin::new("example.store.b", "default"),
1542                HostDefaultPlugin::new("example.audit", "default"),
1543            ],
1544        )
1545        .with_bindings([HostBinding::new(
1546            PluginInstanceId::new("example.copy", "default"),
1547            "example.store@1",
1548            "store",
1549        )
1550        .with_requirement_id("source")
1551        .selectable(None)]);
1552        fs::write(
1553            root.path().join(HOST_CATALOG),
1554            serde_json::to_vec(&host).unwrap(),
1555        )
1556        .unwrap();
1557
1558        let resolved = set_dependency_selection(
1559            root.path(),
1560            PluginInstanceId::new("example.copy", "default"),
1561            "source",
1562            Some(PluginInstanceId::new("example.store.b", "default")),
1563        )
1564        .unwrap();
1565        let bindings = resolved
1566            .plan()
1567            .capability_bindings()
1568            .iter()
1569            .map(|binding| (binding.requirement_id(), binding.provider_instance()))
1570            .collect::<BTreeMap<_, _>>();
1571
1572        assert_eq!(bindings["source"], "example.store.b/default");
1573        assert_eq!(bindings["audit"], "example.audit/default");
1574        let document: DependencySelectionsDocument = serde_json::from_slice(
1575            &fs::read(root.path().join("plugins/dependencies.json")).unwrap(),
1576        )
1577        .unwrap();
1578        assert_eq!(document.selections.len(), 1);
1579    }
1580
1581    #[test]
1582    fn batch_bind_adopts_two_ambiguous_requirements_atomically() {
1583        let root = tempfile::tempdir().unwrap();
1584        fs::create_dir_all(root.path().join(".lenso")).unwrap();
1585        let consumer = PluginDescriptor::new("example.copy", "1.0.0", "copy")
1586            .with_authoring(2, "lenso.test-authoring@2")
1587            .with_requirement(
1588                CapabilityRequirementPlan::one("example.store@1", "1.0.0")
1589                    .with_requirement_id("source"),
1590            )
1591            .with_requirement(
1592                CapabilityRequirementPlan::one("example.store@1", "1.0.0")
1593                    .with_requirement_id("destination"),
1594            );
1595        let store = |plugin_id: &str| {
1596            PluginDescriptor::new(plugin_id, "1.0.0", "store").with_capability(
1597                CapabilityEndpointPlan::new("example.store@1", "1.0.0", ["get"]),
1598            )
1599        };
1600        let host = HostCatalog::new(
1601            [HostSlot::one("copy"), HostSlot::many("store")],
1602            [
1603                HostPluginRelease::new(consumer),
1604                HostPluginRelease::new(store("example.store.a")),
1605                HostPluginRelease::new(store("example.store.b")),
1606            ],
1607            [
1608                HostDefaultPlugin::new("example.copy", "default"),
1609                HostDefaultPlugin::new("example.store.a", "default"),
1610                HostDefaultPlugin::new("example.store.b", "default"),
1611            ],
1612        )
1613        .with_bindings([
1614            HostBinding::new(
1615                PluginInstanceId::new("example.copy", "default"),
1616                "example.store@1",
1617                "store",
1618            )
1619            .with_requirement_id("source")
1620            .selectable(None),
1621            HostBinding::new(
1622                PluginInstanceId::new("example.copy", "default"),
1623                "example.store@1",
1624                "store",
1625            )
1626            .with_requirement_id("destination")
1627            .selectable(None),
1628        ]);
1629        fs::write(
1630            root.path().join(HOST_CATALOG),
1631            serde_json::to_vec(&host).unwrap(),
1632        )
1633        .unwrap();
1634        let consumer = PluginInstanceId::new("example.copy", "default");
1635
1636        let resolved = set_dependency_selections(
1637            root.path(),
1638            [
1639                DependencyChoice {
1640                    consumer: consumer.clone(),
1641                    requirement_id: "source".to_owned(),
1642                    provider: Some(PluginInstanceId::new("example.store.a", "default")),
1643                },
1644                DependencyChoice {
1645                    consumer,
1646                    requirement_id: "destination".to_owned(),
1647                    provider: Some(PluginInstanceId::new("example.store.b", "default")),
1648                },
1649            ],
1650        )
1651        .unwrap();
1652        let bindings = resolved
1653            .plan()
1654            .capability_bindings()
1655            .iter()
1656            .map(|binding| (binding.requirement_id(), binding.provider_instance()))
1657            .collect::<BTreeMap<_, _>>();
1658
1659        assert_eq!(bindings["source"], "example.store.a/default");
1660        assert_eq!(bindings["destination"], "example.store.b/default");
1661    }
1662
1663    #[test]
1664    fn inspection_separates_host_defaults_from_root_differences() {
1665        let root = fixture_root();
1666        let plugin = root.path().join("plugins/example.agent");
1667        fs::create_dir_all(&plugin).unwrap();
1668        fs::write(plugin.join("default.toml"), "").unwrap();
1669
1670        let state = inspect_plugin_root(root.path()).unwrap();
1671        let plugin = state
1672            .plugins()
1673            .iter()
1674            .find(|plugin| plugin.plugin_id() == "example.agent")
1675            .unwrap();
1676        let instance = &plugin.instances()[0];
1677
1678        assert_eq!(plugin.release_version(), "1.0.0");
1679        assert!(!plugin.is_root_supplied());
1680        assert!(instance.is_enabled());
1681        assert!(instance.is_host_default());
1682        assert!(!instance.is_disableable());
1683        assert_eq!(instance.root_configuration_toml(), Some(""));
1684        assert!(instance.source_digest().as_str().starts_with("sha256:"));
1685        assert!(instance.has_root_difference());
1686    }
1687
1688    #[test]
1689    fn inspection_reports_disabled_host_default_without_losing_the_instance() {
1690        let root = tempfile::tempdir().unwrap();
1691        fs::create_dir_all(root.path().join(".lenso")).unwrap();
1692        let host = HostCatalog::new(
1693            [HostSlot::optional("optional")],
1694            [HostPluginRelease::new(PluginDescriptor::new(
1695                "example.optional",
1696                "1.0.0",
1697                "optional",
1698            ))],
1699            [HostDefaultPlugin::new("example.optional", "default").disableable()],
1700        );
1701        fs::write(
1702            root.path().join(HOST_CATALOG),
1703            serde_json::to_vec(&host).unwrap(),
1704        )
1705        .unwrap();
1706        let plugin = root.path().join("plugins/example.optional");
1707        fs::create_dir_all(&plugin).unwrap();
1708        fs::write(plugin.join("default.disabled"), "").unwrap();
1709
1710        let state = inspect_plugin_root(root.path()).unwrap();
1711        let instance = &state.plugins()[0].instances()[0];
1712
1713        assert!(!instance.is_enabled());
1714        assert!(instance.is_host_default());
1715        assert!(instance.is_disableable());
1716        assert!(instance.is_disabled_by_root());
1717    }
1718
1719    #[test]
1720    fn local_selection_authority_disables_and_enables_one_instance() {
1721        let root = tempfile::tempdir().unwrap();
1722        fs::create_dir_all(root.path().join(".lenso")).unwrap();
1723        let host = HostCatalog::new(
1724            [HostSlot::optional("optional")],
1725            [HostPluginRelease::new(PluginDescriptor::new(
1726                "example.optional",
1727                "1.0.0",
1728                "optional",
1729            ))],
1730            [HostDefaultPlugin::new("example.optional", "default").disableable()],
1731        );
1732        fs::write(
1733            root.path().join(HOST_CATALOG),
1734            serde_json::to_vec(&host).unwrap(),
1735        )
1736        .unwrap();
1737        let authority = LocalPluginRootAuthority::new(root.path());
1738        let base = inspect_plugin_root(root.path()).unwrap().revision().clone();
1739
1740        let disabled = authority
1741            .set_enabled(&base, "example.optional", "default", false)
1742            .unwrap();
1743        assert_eq!(disabled.base_revision(), &base);
1744        assert!(!disabled.enabled());
1745        assert_eq!(disabled.plugin_id(), "example.optional");
1746        assert_eq!(disabled.instance(), "default");
1747        assert!(
1748            root.path()
1749                .join("plugins/example.optional/default.disabled")
1750                .is_file()
1751        );
1752
1753        let enabled = authority
1754            .set_enabled(disabled.revision(), "example.optional", "default", true)
1755            .unwrap();
1756        assert!(enabled.enabled());
1757        assert!(
1758            !root
1759                .path()
1760                .join("plugins/example.optional/default.disabled")
1761                .exists()
1762        );
1763    }
1764
1765    #[test]
1766    fn local_selection_authority_rejects_a_stale_revision_without_mutating() {
1767        let root = tempfile::tempdir().unwrap();
1768        fs::create_dir_all(root.path().join(".lenso")).unwrap();
1769        let host = HostCatalog::new(
1770            [HostSlot::optional("optional")],
1771            [HostPluginRelease::new(PluginDescriptor::new(
1772                "example.optional",
1773                "1.0.0",
1774                "optional",
1775            ))],
1776            [HostDefaultPlugin::new("example.optional", "default").disableable()],
1777        );
1778        fs::write(
1779            root.path().join(HOST_CATALOG),
1780            serde_json::to_vec(&host).unwrap(),
1781        )
1782        .unwrap();
1783        let authority = LocalPluginRootAuthority::new(root.path());
1784        let stale = inspect_plugin_root(root.path()).unwrap().revision().clone();
1785        authority
1786            .set_enabled(&stale, "example.optional", "default", false)
1787            .unwrap();
1788
1789        let error = authority
1790            .set_enabled(&stale, "example.optional", "default", true)
1791            .unwrap_err();
1792
1793        assert!(error.downcast_ref::<PluginRootRevisionConflict>().is_some());
1794        assert!(
1795            root.path()
1796                .join("plugins/example.optional/default.disabled")
1797                .is_file()
1798        );
1799    }
1800
1801    #[test]
1802    fn macos_metadata_at_plugin_root_is_ignored() {
1803        let root = fixture_root();
1804        fs::create_dir(root.path().join("plugins")).unwrap();
1805        fs::write(root.path().join("plugins/.DS_Store"), b"Finder metadata").unwrap();
1806
1807        let resolved = load_resolved_app(root.path()).unwrap();
1808
1809        assert_eq!(resolved.instances().len(), 1);
1810    }
1811
1812    #[test]
1813    fn macos_metadata_inside_plugin_directory_is_ignored() {
1814        let root = fixture_root();
1815        let plugin = root.path().join("plugins/example.agent");
1816        fs::create_dir_all(&plugin).unwrap();
1817        fs::write(plugin.join(".DS_Store"), b"Finder metadata").unwrap();
1818
1819        let resolved = load_resolved_app(root.path()).unwrap();
1820
1821        assert_eq!(resolved.instances().len(), 1);
1822    }
1823
1824    #[test]
1825    fn accepts_a_bounded_resource_directory_paired_with_an_instance() {
1826        let root = fixture_root();
1827        let plugin = root.path().join("plugins/example.agent");
1828        fs::create_dir_all(plugin.join("default/prompts")).unwrap();
1829        fs::write(plugin.join("default.toml"), "").unwrap();
1830        fs::write(plugin.join("default/prompts/system.md"), "hello").unwrap();
1831        fs::write(plugin.join("default/prompts/.DS_Store"), "metadata").unwrap();
1832
1833        let resolved = load_resolved_app(root.path()).unwrap();
1834
1835        assert!(
1836            resolved
1837                .instances()
1838                .iter()
1839                .any(|instance| instance.id().to_string() == "example.agent/default")
1840        );
1841    }
1842
1843    #[test]
1844    fn rejects_an_orphan_resource_directory() {
1845        let root = fixture_root();
1846        let resources = root.path().join("plugins/example.agent/custom");
1847        fs::create_dir_all(&resources).unwrap();
1848        fs::write(resources.join("prompt.md"), "orphan").unwrap();
1849
1850        let error = load_resolved_app(root.path()).unwrap_err();
1851
1852        assert!(
1853            error
1854                .to_string()
1855                .contains("orphan Plugin resource directory")
1856        );
1857    }
1858
1859    #[cfg(unix)]
1860    #[test]
1861    fn rejects_a_resource_symlink() {
1862        use std::os::unix::fs::symlink;
1863
1864        let root = fixture_root();
1865        let plugin = root.path().join("plugins/example.agent");
1866        fs::create_dir_all(plugin.join("custom")).unwrap();
1867        fs::write(plugin.join("custom.toml"), "").unwrap();
1868        fs::write(root.path().join("secret"), "not admitted").unwrap();
1869        symlink(root.path().join("secret"), plugin.join("custom/secret")).unwrap();
1870
1871        let error = load_resolved_app(root.path()).unwrap_err();
1872
1873        assert!(error.to_string().contains("cannot contain symlinks"));
1874    }
1875
1876    #[test]
1877    fn failed_configuration_candidate_does_not_write_the_plugin_root() {
1878        let root = fixture_root();
1879
1880        let error = configure_instance(
1881            root.path(),
1882            "example.agent",
1883            "default",
1884            b"unexpected = true\n",
1885        )
1886        .unwrap_err();
1887
1888        assert!(error.to_string().contains("non-empty configuration"));
1889        assert!(
1890            !root
1891                .path()
1892                .join("plugins/example.agent/default.toml")
1893                .exists()
1894        );
1895    }
1896
1897    #[test]
1898    fn required_default_disable_fails_before_writing_a_marker() {
1899        let root = fixture_root();
1900
1901        let error =
1902            set_instance_disabled(root.path(), "example.agent", "default", true).unwrap_err();
1903
1904        assert!(error.to_string().contains("cannot be disabled"));
1905        assert!(
1906            !root
1907                .path()
1908                .join("plugins/example.agent/default.disabled")
1909                .exists()
1910        );
1911    }
1912
1913    #[test]
1914    fn case_colliding_plugin_identities_fail_closed() {
1915        let mut normalized = BTreeMap::new();
1916        reject_case_collision(&mut normalized, "Example.Agent", "Plugin ID").unwrap();
1917        let error =
1918            reject_case_collision(&mut normalized, "example.agent", "Plugin ID").unwrap_err();
1919
1920        assert!(error.to_string().contains("case-colliding Plugin IDs"));
1921    }
1922
1923    #[test]
1924    fn add_replace_and_restore_publish_failures_leave_visible_bytes_unchanged() {
1925        for mutation in [
1926            BundleMutation::Add,
1927            BundleMutation::Replace,
1928            BundleMutation::Restore,
1929        ] {
1930            let root = tempfile::tempdir().unwrap();
1931            let destination = root
1932                .path()
1933                .join("plugins/example.agent/plugin.lenso-plugin");
1934            if mutation == BundleMutation::Add {
1935                fs::create_dir(root.path().join("plugins")).unwrap();
1936            } else {
1937                fs::create_dir_all(&destination).unwrap();
1938                fs::write(destination.join("marker"), "old").unwrap();
1939            }
1940            let staging = tempfile::tempdir_in(root.path()).unwrap();
1941            fs::write(staging.path().join("marker"), "new").unwrap();
1942
1943            let error = commit_staged_bundle_with(
1944                &destination,
1945                mutation,
1946                staging,
1947                |_, _, _| {
1948                    Err(std::io::Error::new(
1949                        std::io::ErrorKind::PermissionDenied,
1950                        "injected publish failure",
1951                    ))
1952                },
1953                |_| panic!("retirement cannot run before publication succeeds"),
1954            )
1955            .unwrap_err();
1956
1957            assert!(error.to_string().contains("Plugin Bundle"));
1958            if mutation == BundleMutation::Add {
1959                assert!(!destination.exists());
1960                assert!(!destination.parent().unwrap().exists());
1961            } else {
1962                assert_eq!(
1963                    fs::read_to_string(destination.join("marker")).unwrap(),
1964                    "old"
1965                );
1966            }
1967        }
1968    }
1969
1970    #[test]
1971    fn portable_bundle_add_publishes_with_one_atomic_rename() {
1972        let root = tempfile::tempdir().unwrap();
1973        let destination = root
1974            .path()
1975            .join("plugins/example.agent/plugin.lenso-plugin");
1976        let staging = tempfile::tempdir_in(root.path()).unwrap();
1977        fs::write(staging.path().join("marker"), "new").unwrap();
1978
1979        commit_staged_bundle(&destination, BundleMutation::Add, staging).unwrap();
1980
1981        assert_eq!(
1982            fs::read_to_string(destination.join("marker")).unwrap(),
1983            "new"
1984        );
1985    }
1986
1987    #[cfg(any(target_os = "linux", target_vendor = "apple", windows))]
1988    #[test]
1989    fn portable_bundle_add_never_replaces_a_concurrent_destination() {
1990        let root = tempfile::tempdir().unwrap();
1991        let destination = root.path().join("destination");
1992        fs::create_dir(&destination).unwrap();
1993        fs::write(destination.join("marker"), "old").unwrap();
1994        let staging = tempfile::tempdir_in(root.path()).unwrap();
1995        fs::write(staging.path().join("marker"), "new").unwrap();
1996
1997        atomic_publish_bundle(staging.path(), &destination, BundleMutation::Add).unwrap_err();
1998
1999        assert_eq!(
2000            fs::read_to_string(destination.join("marker")).unwrap(),
2001            "old"
2002        );
2003        assert_eq!(
2004            fs::read_to_string(staging.path().join("marker")).unwrap(),
2005            "new"
2006        );
2007    }
2008
2009    #[cfg(not(any(target_os = "linux", target_vendor = "apple")))]
2010    #[test]
2011    fn portable_bundle_replace_fails_closed_when_exchange_is_unavailable() {
2012        let root = tempfile::tempdir().unwrap();
2013        let destination = root
2014            .path()
2015            .join("plugins/example.agent/plugin.lenso-plugin");
2016        fs::create_dir_all(&destination).unwrap();
2017        fs::write(destination.join("marker"), "old").unwrap();
2018        let staging = tempfile::tempdir_in(root.path()).unwrap();
2019        fs::write(staging.path().join("marker"), "new").unwrap();
2020
2021        let error =
2022            commit_staged_bundle(&destination, BundleMutation::Replace, staging).unwrap_err();
2023
2024        assert_eq!(
2025            error
2026                .root_cause()
2027                .downcast_ref::<std::io::Error>()
2028                .unwrap()
2029                .kind(),
2030            std::io::ErrorKind::Unsupported
2031        );
2032        assert_eq!(
2033            fs::read_to_string(destination.join("marker")).unwrap(),
2034            "old"
2035        );
2036    }
2037
2038    #[cfg(any(target_os = "linux", target_vendor = "apple"))]
2039    #[test]
2040    fn replace_and_restore_commit_atomically_even_when_retirement_cleanup_fails() {
2041        for mutation in [BundleMutation::Replace, BundleMutation::Restore] {
2042            let root = tempfile::tempdir().unwrap();
2043            let destination = root
2044                .path()
2045                .join("plugins/example.agent/plugin.lenso-plugin");
2046            fs::create_dir_all(&destination).unwrap();
2047            fs::write(destination.join("marker"), "old").unwrap();
2048            let staging = tempfile::tempdir_in(root.path()).unwrap();
2049            fs::write(staging.path().join("marker"), "new").unwrap();
2050            let mut retired = None;
2051
2052            commit_staged_bundle_with(
2053                &destination,
2054                mutation,
2055                staging,
2056                atomic_publish_bundle,
2057                |staging| {
2058                    retired = Some(staging.keep());
2059                    Err(std::io::Error::other("injected cleanup failure"))
2060                },
2061            )
2062            .unwrap();
2063
2064            assert_eq!(
2065                fs::read_to_string(destination.join("marker")).unwrap(),
2066                "new"
2067            );
2068            let retired = retired.unwrap();
2069            assert_eq!(fs::read_to_string(retired.join("marker")).unwrap(), "old");
2070            fs::remove_dir_all(retired).unwrap();
2071        }
2072    }
2073}