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::ExecutionClassId;
20use lenso_app_plan::authoring::{
21    DependencyChoice, PluginDescriptor, PluginInstanceId, PluginRootInstance, PluginRootSnapshot,
22    ResolvedApp,
23};
24use lenso_plugin_bundle::{
25    ImplementationPolicy, VerifiedBundle, read_bundle_manifest, resolve_implementation,
26    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            execution_classes: vec![
709                ExecutionClassId::new("lenso.quickjs@1"),
710                ExecutionClassId::new("lenso.process@1"),
711                ExecutionClassId::new("lenso.wasm-component@1"),
712                ExecutionClassId::new("lenso.bun-process@1"),
713            ],
714        },
715    )?
716    .descriptor;
717    if descriptor.plugin_id() != plugin_id
718        || descriptor.release_version() != verified.release_version
719    {
720        bail!("Plugin Descriptor identity does not match the verified Bundle");
721    }
722    Ok(descriptor)
723}
724
725fn read_configuration(path: &Path) -> anyhow::Result<serde_json::Value> {
726    let metadata = fs::metadata(path)?;
727    if metadata.len() > MAX_CONFIGURATION_BYTES {
728        bail!("Plugin configuration exceeds 256 KiB: {}", path.display());
729    }
730    let text = fs::read_to_string(path)
731        .with_context(|| format!("read Plugin configuration {}", path.display()))?;
732    let table: toml::Table = toml::from_str(&text)
733        .with_context(|| format!("parse Plugin configuration {}", path.display()))?;
734    serde_json::to_value(table).context("convert Plugin configuration to portable values")
735}
736
737fn read_entries(path: &Path) -> anyhow::Result<Vec<fs::DirEntry>> {
738    fs::read_dir(path)
739        .with_context(|| format!("read directory {}", path.display()))?
740        .collect::<Result<Vec<_>, _>>()
741        .with_context(|| format!("read directory entries {}", path.display()))
742}
743
744fn utf8_name(path: &Path, name: &std::ffi::OsStr) -> anyhow::Result<String> {
745    name.to_str()
746        .map(str::to_owned)
747        .with_context(|| format!("Plugin path is not UTF-8: {}", path.display()))
748}
749
750fn validate_instance_filename(instance: &str) -> anyhow::Result<()> {
751    validate_path_identity(instance, "Instance key")?;
752    if instance.starts_with('.') || instance == "plugin" {
753        bail!("reserved Plugin Instance key `{instance}`");
754    }
755    Ok(())
756}
757
758fn validate_existing_plugin_id(plugin_id: &str) -> anyhow::Result<()> {
759    validate_path_identity(plugin_id, "Plugin ID")?;
760    classify_existing_plugin_id(plugin_id).map(|_| ())
761}
762
763fn validate_path_identity(value: &str, label: &str) -> anyhow::Result<()> {
764    if value.trim() != value
765        || value.is_empty()
766        || value == "."
767        || value == ".."
768        || value.contains(['/', '\0', '\\'])
769    {
770        bail!("invalid {label} `{value}`");
771    }
772    Ok(())
773}
774
775fn reject_case_collision(
776    normalized: &mut BTreeMap<String, String>,
777    value: &str,
778    label: &str,
779) -> anyhow::Result<()> {
780    let key = value.to_lowercase();
781    if let Some(previous) = normalized.insert(key, value.to_owned())
782        && previous != value
783    {
784        bail!("case-colliding {label}s `{previous}` and `{value}`");
785    }
786    Ok(())
787}
788
789/// Adds one verified external Plugin Bundle after resolving the complete candidate App.
790pub fn add_bundle(root: &Path, bundle: &Path) -> anyhow::Result<(String, String, ResolvedApp)> {
791    prepare_bundle_mutation(root, bundle, BundleMutation::Add)?.commit()
792}
793
794/// Desired root-Bundle mutation validated before visible bytes change.
795#[derive(Clone, Copy, Debug, Eq, PartialEq)]
796pub enum BundleMutation {
797    Add,
798    Replace,
799    /// Restore bytes already retained for a legacy or v1 root Plugin.
800    Restore,
801}
802
803/// Stable staged bytes and candidate resolution for one pending Bundle mutation.
804///
805/// Callers may inspect the verified identity before committing, which lets a
806/// catalog compare its signed metadata without re-reading or re-hashing the
807/// Bundle. The staged directory is removed automatically unless `commit` is
808/// called.
809#[derive(Debug)]
810pub struct PreparedBundleMutation {
811    authority: fs::File,
812    destination: PathBuf,
813    mutation: BundleMutation,
814    resolved: ResolvedApp,
815    staging: tempfile::TempDir,
816    verified: VerifiedBundle,
817}
818
819impl PreparedBundleMutation {
820    pub const fn verified(&self) -> &VerifiedBundle {
821        &self.verified
822    }
823
824    pub const fn resolved(&self) -> &ResolvedApp {
825        &self.resolved
826    }
827
828    pub fn destination(&self) -> &Path {
829        &self.destination
830    }
831
832    /// Atomically makes the already-validated staged Bundle visible.
833    pub fn commit(self) -> anyhow::Result<(String, String, ResolvedApp)> {
834        let Self {
835            authority,
836            destination,
837            mutation,
838            resolved,
839            staging,
840            verified,
841        } = self;
842        let commit = commit_staged_bundle(&destination, mutation, staging);
843        drop(authority);
844        commit?;
845        Ok((verified.plugin_id, verified.release_version, resolved))
846    }
847}
848
849fn commit_staged_bundle(
850    destination: &Path,
851    mutation: BundleMutation,
852    staging: tempfile::TempDir,
853) -> anyhow::Result<()> {
854    commit_staged_bundle_with(
855        destination,
856        mutation,
857        staging,
858        atomic_publish_bundle,
859        tempfile::TempDir::close,
860    )
861}
862
863fn commit_staged_bundle_with<Publish, Retire>(
864    destination: &Path,
865    mutation: BundleMutation,
866    staging: tempfile::TempDir,
867    publish: Publish,
868    retire: Retire,
869) -> anyhow::Result<()>
870where
871    Publish: FnOnce(&Path, &Path, BundleMutation) -> std::io::Result<()>,
872    Retire: FnOnce(tempfile::TempDir) -> std::io::Result<()>,
873{
874    let parent = destination
875        .parent()
876        .context("Bundle destination has no parent")?;
877    if mutation == BundleMutation::Add && destination.exists() {
878        bail!("Plugin Bundle already exists: {}", destination.display());
879    }
880    let created_parent = mutation == BundleMutation::Add && !parent.exists();
881    if mutation == BundleMutation::Add {
882        fs::create_dir_all(parent)?;
883    }
884    let publication =
885        publish(staging.path(), destination, mutation).with_context(|| match mutation {
886            BundleMutation::Add => format!("commit Plugin Bundle {}", destination.display()),
887            BundleMutation::Replace | BundleMutation::Restore => {
888                format!("atomically replace Plugin Bundle {}", destination.display())
889            }
890        });
891    if let Err(error) = publication {
892        if created_parent
893            && let Err(cleanup_error) = fs::remove_dir(parent)
894            && cleanup_error.kind() != std::io::ErrorKind::NotFound
895            && cleanup_error.kind() != std::io::ErrorKind::DirectoryNotEmpty
896        {
897            return Err(error.context(format!(
898                "also failed to remove empty Plugin directory {}: {cleanup_error}",
899                parent.display()
900            )));
901        }
902        return Err(error);
903    }
904
905    if mutation != BundleMutation::Add
906        && let Err(error) = retire(staging)
907    {
908        // EXCHANGE is the commit point: the new Bundle is already visible and
909        // the old one is isolated at the hidden staging path. Cleanup failure
910        // must not misreport a successfully committed mutation as rejected.
911        eprintln!("warning: Plugin Bundle committed, but retired Bundle cleanup failed: {error}");
912    }
913    Ok(())
914}
915
916#[cfg(any(target_os = "linux", target_vendor = "apple"))]
917fn atomic_publish_bundle(
918    staging: &Path,
919    destination: &Path,
920    mutation: BundleMutation,
921) -> std::io::Result<()> {
922    use rustix::fs::{CWD, RenameFlags, renameat_with};
923
924    let flags = match mutation {
925        BundleMutation::Add => RenameFlags::NOREPLACE,
926        BundleMutation::Replace | BundleMutation::Restore => RenameFlags::EXCHANGE,
927    };
928    renameat_with(CWD, staging, CWD, destination, flags).map_err(std::io::Error::from)
929}
930
931#[cfg(windows)]
932fn atomic_publish_bundle(
933    staging: &Path,
934    destination: &Path,
935    mutation: BundleMutation,
936) -> std::io::Result<()> {
937    match mutation {
938        // MoveFileW is intentionally used without a replacement flag: it is
939        // one atomic rename and fails if a concurrent writer won the target.
940        BundleMutation::Add => winsafe::MoveFile(
941            staging.to_str().ok_or_else(|| {
942                std::io::Error::new(
943                    std::io::ErrorKind::InvalidInput,
944                    "Plugin Bundle staging path is not Unicode",
945                )
946            })?,
947            destination.to_str().ok_or_else(|| {
948                std::io::Error::new(
949                    std::io::ErrorKind::InvalidInput,
950                    "Plugin Bundle destination path is not Unicode",
951                )
952            })?,
953        )
954        .map_err(|error| std::io::Error::from_raw_os_error(error.raw() as i32)),
955        BundleMutation::Replace | BundleMutation::Restore => Err(std::io::Error::new(
956            std::io::ErrorKind::Unsupported,
957            "atomic Plugin Bundle replacement is unavailable on this platform",
958        )),
959    }
960}
961
962#[cfg(not(any(target_os = "linux", target_vendor = "apple", windows)))]
963fn atomic_publish_bundle(
964    _staging: &Path,
965    _destination: &Path,
966    _mutation: BundleMutation,
967) -> std::io::Result<()> {
968    Err(std::io::Error::new(
969        std::io::ErrorKind::Unsupported,
970        "atomic Plugin Bundle publication is unavailable on this platform",
971    ))
972}
973
974/// Copies one candidate into stable staging, validates it once, and resolves
975/// the complete candidate App before any Plugin Root bytes change.
976pub fn prepare_bundle_mutation(
977    root: &Path,
978    bundle: &Path,
979    mutation: BundleMutation,
980) -> anyhow::Result<PreparedBundleMutation> {
981    let staging = tempfile::Builder::new()
982        .prefix(".plugin-bundle-")
983        .tempdir_in(root)?;
984    copy_directory(bundle, staging.path())?;
985    let authority = lock_plugin_root(root)?;
986    let host = load_host_catalog(root)?;
987    let (verified, descriptor) = verify_bundle_mutation(staging.path(), mutation, &host)?;
988    let resolved = resolve_bundle_mutation(root, mutation, &verified, descriptor, &host)?;
989    let destination = root
990        .join(PLUGIN_ROOT)
991        .join(&verified.plugin_id)
992        .join(BUNDLE_NAME);
993    Ok(PreparedBundleMutation {
994        authority,
995        destination,
996        mutation,
997        resolved,
998        staging,
999        verified,
1000    })
1001}
1002
1003/// Verifies one Bundle and resolves the complete candidate App for an add or replacement.
1004///
1005/// `prepare_bundle_mutation` is the preferred mutation boundary because it
1006/// also owns stable staged bytes and the atomic commit.
1007pub fn validate_bundle_mutation(
1008    root: &Path,
1009    bundle: &Path,
1010    mutation: BundleMutation,
1011) -> anyhow::Result<(lenso_plugin_bundle::VerifiedBundle, ResolvedApp)> {
1012    let _lock = lock_plugin_root(root)?;
1013    let host = load_host_catalog(root)?;
1014    let (verified, descriptor) = verify_bundle_mutation(bundle, mutation, &host)?;
1015    let resolved = resolve_bundle_mutation(root, mutation, &verified, descriptor, &host)?;
1016    Ok((verified, resolved))
1017}
1018
1019fn verify_bundle_mutation(
1020    bundle: &Path,
1021    mutation: BundleMutation,
1022    host: &HostInput,
1023) -> anyhow::Result<(VerifiedBundle, PluginDescriptor)> {
1024    let verified = verify_bundle_directory(bundle)
1025        .with_context(|| format!("verify Plugin Bundle {}", bundle.display()))?;
1026    match mutation {
1027        BundleMutation::Add | BundleMutation::Replace => {
1028            validate_plugin_id_v1(&verified.plugin_id)?;
1029        }
1030        BundleMutation::Restore => {
1031            classify_existing_plugin_id(&verified.plugin_id)?;
1032        }
1033    }
1034    validate_release_version(&verified.release_version)?;
1035    let descriptor = host.select_bundle(bundle, &verified)?;
1036    Ok((verified, descriptor))
1037}
1038
1039fn resolve_bundle_mutation(
1040    root: &Path,
1041    mutation: BundleMutation,
1042    verified: &VerifiedBundle,
1043    descriptor: PluginDescriptor,
1044    host: &HostInput,
1045) -> anyhow::Result<ResolvedApp> {
1046    let current = snapshot_plugin_root(root, host)?;
1047    let has_current = current
1048        .releases()
1049        .iter()
1050        .any(|release| release.plugin_id() == verified.plugin_id);
1051    match (mutation, has_current) {
1052        (BundleMutation::Add, true) => {
1053            bail!("Plugin `{}` already has a root Bundle", verified.plugin_id)
1054        }
1055        (BundleMutation::Replace | BundleMutation::Restore, false) => {
1056            bail!(
1057                "Plugin `{}` has no root Bundle to update",
1058                verified.plugin_id
1059            )
1060        }
1061        _ => {}
1062    }
1063    let candidate = preserve_dependency_selections(
1064        PluginRootSnapshot::new(
1065            current
1066                .releases()
1067                .iter()
1068                .filter(|release| release.plugin_id() != verified.plugin_id)
1069                .cloned()
1070                .chain([descriptor]),
1071            current.instances().iter().cloned(),
1072            current.disabled().iter().cloned(),
1073        ),
1074        &current,
1075    );
1076    let resolved = host.resolve(&candidate).map_err(anyhow::Error::msg)?;
1077    Ok(resolved)
1078}
1079
1080/// Atomically writes one typed Instance patch after resolving the complete candidate App.
1081pub fn configure_instance(
1082    root: &Path,
1083    plugin_id: &str,
1084    instance: &str,
1085    bytes: &[u8],
1086) -> anyhow::Result<ResolvedApp> {
1087    let base_revision = inspect_plugin_root(root)?.revision().clone();
1088    let proposal =
1089        propose_instance_configuration(root, &base_revision, plugin_id, instance, bytes)?;
1090    let publication = publish_instance_configuration(root, &proposal)?;
1091    Ok(publication.into_resolved())
1092}
1093
1094/// Atomically changes one Instance selection marker after candidate resolution.
1095pub fn set_instance_disabled(
1096    root: &Path,
1097    plugin_id: &str,
1098    instance: &str,
1099    disabled_state: bool,
1100) -> anyhow::Result<ResolvedApp> {
1101    set_instance_disabled_inner(root, plugin_id, instance, disabled_state, None)
1102        .map(|(_, _, resolved)| resolved)
1103}
1104
1105/// Saves one exact App-owned dependency choice after validating the complete candidate App.
1106pub fn set_dependency_selection(
1107    root: &Path,
1108    consumer: PluginInstanceId,
1109    requirement_id: &str,
1110    provider: Option<PluginInstanceId>,
1111) -> anyhow::Result<ResolvedApp> {
1112    let selection = DependencyChoice {
1113        consumer,
1114        requirement_id: requirement_id.to_owned(),
1115        provider,
1116    };
1117    set_dependency_selections(root, [selection])
1118}
1119
1120/// Atomically applies one or more App-owned dependency choices as one validated candidate.
1121pub fn set_dependency_selections(
1122    root: &Path,
1123    replacements: impl IntoIterator<Item = DependencyChoice>,
1124) -> anyhow::Result<ResolvedApp> {
1125    let replacements = replacements.into_iter().collect::<Vec<_>>();
1126    if replacements.is_empty() {
1127        bail!("at least one dependency selection is required");
1128    }
1129    let mut replacement_keys = BTreeSet::new();
1130    for selection in &replacements {
1131        validate_existing_plugin_id(selection.consumer.plugin_id())?;
1132        validate_instance_filename(selection.consumer.instance_key())?;
1133        if selection.requirement_id.trim().is_empty() {
1134            bail!("dependency requirement identity must not be empty");
1135        }
1136        if let Some(provider) = &selection.provider {
1137            validate_existing_plugin_id(provider.plugin_id())?;
1138            validate_instance_filename(provider.instance_key())?;
1139        }
1140        if !replacement_keys.insert((&selection.consumer, selection.requirement_id.as_str())) {
1141            bail!(
1142                "duplicate dependency selection for `{}` requirement `{}`",
1143                selection.consumer,
1144                selection.requirement_id
1145            );
1146        }
1147    }
1148    let _lock = lock_plugin_root(root)?;
1149    let host = load_host_catalog(root)?;
1150    let current = snapshot_plugin_root(root, &host)?;
1151    let mut selections = current.dependency_choices().to_vec();
1152    selections.retain(|selection| {
1153        !replacement_keys.contains(&(&selection.consumer, selection.requirement_id.as_str()))
1154    });
1155    drop(replacement_keys);
1156    selections.extend(replacements);
1157    selections.sort_by(|left, right| {
1158        left.consumer
1159            .cmp(&right.consumer)
1160            .then_with(|| left.requirement_id.cmp(&right.requirement_id))
1161    });
1162    let (resolved, selections) = resolve_adopted_dependencies(&host, &current, selections)?;
1163    let document = DependencySelectionsDocument {
1164        schema: DEPENDENCY_SELECTIONS_SCHEMA.to_owned(),
1165        selections,
1166    };
1167    let bytes = serde_json::to_vec_pretty(&document).context("encode dependency selections")?;
1168    atomic_write(&root.join(PLUGIN_ROOT).join(DEPENDENCY_SELECTIONS), &bytes)?;
1169    Ok(resolved)
1170}
1171
1172fn resolve_adopted_dependencies(
1173    host: &HostInput,
1174    current: &PluginRootSnapshot,
1175    mut selections: Vec<DependencyChoice>,
1176) -> anyhow::Result<(ResolvedApp, Vec<DependencyChoice>)> {
1177    selections.sort_by(|left, right| {
1178        left.consumer
1179            .cmp(&right.consumer)
1180            .then_with(|| left.requirement_id.cmp(&right.requirement_id))
1181    });
1182    let candidate = PluginRootSnapshot::new(
1183        current.releases().iter().cloned(),
1184        current.instances().iter().cloned(),
1185        current.disabled().iter().cloned(),
1186    )
1187    .with_dependency_choices(selections);
1188    let proposed = host.propose(&candidate).map_err(anyhow::Error::msg)?;
1189    let selections = proposed.dependency_choices().to_vec();
1190    let materialized = PluginRootSnapshot::new(
1191        current.releases().iter().cloned(),
1192        current.instances().iter().cloned(),
1193        current.disabled().iter().cloned(),
1194    )
1195    .with_dependency_choices(selections.clone());
1196    let resolved = host.resolve(&materialized).map_err(anyhow::Error::msg)?;
1197    Ok((resolved, selections))
1198}
1199
1200fn set_instance_disabled_inner(
1201    root: &Path,
1202    plugin_id: &str,
1203    instance: &str,
1204    disabled_state: bool,
1205    expected_revision: Option<&PluginRootRevision>,
1206) -> anyhow::Result<(PluginRootRevision, PluginRootRevision, ResolvedApp)> {
1207    validate_existing_plugin_id(plugin_id)?;
1208    validate_instance_filename(instance)?;
1209    let _lock = lock_plugin_root(root)?;
1210    let host = load_host_catalog(root)?;
1211    let current = snapshot_plugin_root(root, &host)?;
1212    let base_revision = configuration_authority::revision_for_snapshot(&current)?;
1213    if let Some(expected_revision) = expected_revision {
1214        configuration_authority::ensure_revision(expected_revision, &base_revision)?;
1215    }
1216    let id = PluginInstanceId::new(plugin_id, instance);
1217    let mut disabled = current.disabled().iter().cloned().collect::<BTreeSet<_>>();
1218    if disabled_state {
1219        disabled.insert(id.clone());
1220    } else if !disabled.remove(&id) {
1221        bail!("Plugin Instance `{id}` is not disabled");
1222    }
1223    let candidate = preserve_dependency_selections(
1224        PluginRootSnapshot::new(
1225            current.releases().iter().cloned(),
1226            current.instances().iter().cloned(),
1227            disabled,
1228        ),
1229        &current,
1230    );
1231    let candidate_revision = configuration_authority::revision_for_snapshot(&candidate)?;
1232    let resolved = host.resolve(&candidate).map_err(anyhow::Error::msg)?;
1233    let marker = root
1234        .join(PLUGIN_ROOT)
1235        .join(plugin_id)
1236        .join(format!("{instance}.disabled"));
1237    if disabled_state {
1238        atomic_write(&marker, &[])?;
1239    } else {
1240        fs::remove_file(&marker)
1241            .with_context(|| format!("remove disabled marker {}", marker.display()))?;
1242    }
1243    Ok((base_revision, candidate_revision, resolved))
1244}
1245
1246/// Removes one App-owned Instance difference after validating the remaining App.
1247pub fn remove_instance_difference(
1248    root: &Path,
1249    plugin_id: &str,
1250    instance: &str,
1251) -> anyhow::Result<ResolvedApp> {
1252    validate_existing_plugin_id(plugin_id)?;
1253    validate_instance_filename(instance)?;
1254    let _lock = lock_plugin_root(root)?;
1255    let host = load_host_catalog(root)?;
1256    let current = snapshot_plugin_root(root, &host)?;
1257    let id = PluginInstanceId::new(plugin_id, instance);
1258    let candidate = preserve_dependency_selections(
1259        PluginRootSnapshot::new(
1260            current.releases().iter().cloned(),
1261            current
1262                .instances()
1263                .iter()
1264                .filter(|item| item.id() != &id)
1265                .cloned(),
1266            current
1267                .disabled()
1268                .iter()
1269                .filter(|item| *item != &id)
1270                .cloned(),
1271        ),
1272        &current,
1273    );
1274    let resolved = host.resolve(&candidate).map_err(anyhow::Error::msg)?;
1275    let plugin_directory = root.join(PLUGIN_ROOT).join(plugin_id);
1276    remove_if_exists(&plugin_directory.join(format!("{instance}.toml")))?;
1277    remove_if_exists(&plugin_directory.join(format!("{instance}.disabled")))?;
1278    Ok(resolved)
1279}
1280
1281/// Moves one root-supplied Plugin to recoverable trash after validating the remaining App.
1282pub fn remove_plugin(root: &Path, plugin_id: &str) -> anyhow::Result<(ResolvedApp, PathBuf)> {
1283    validate_existing_plugin_id(plugin_id)?;
1284    let _lock = lock_plugin_root(root)?;
1285    let host = load_host_catalog(root)?;
1286    let current = snapshot_plugin_root(root, &host)?;
1287    let candidate = preserve_dependency_selections(
1288        PluginRootSnapshot::new(
1289            current
1290                .releases()
1291                .iter()
1292                .filter(|release| release.plugin_id() != plugin_id)
1293                .cloned(),
1294            current
1295                .instances()
1296                .iter()
1297                .filter(|instance| instance.id().plugin_id() != plugin_id)
1298                .cloned(),
1299            current
1300                .disabled()
1301                .iter()
1302                .filter(|instance| instance.plugin_id() != plugin_id)
1303                .cloned(),
1304        ),
1305        &current,
1306    );
1307    let resolved = host.resolve(&candidate).map_err(anyhow::Error::msg)?;
1308    let plugin_directory = root.join(PLUGIN_ROOT).join(plugin_id);
1309    if !plugin_directory.exists() {
1310        bail!("Plugin `{plugin_id}` has no Plugin Root directory");
1311    }
1312    let trash = root
1313        .join(".lenso/trash")
1314        .join(format!("{plugin_id}-{}", uuid::Uuid::now_v7()));
1315    fs::create_dir_all(trash.parent().expect("trash has a parent"))?;
1316    fs::rename(&plugin_directory, &trash)?;
1317    Ok((resolved, trash))
1318}
1319
1320fn atomic_write(path: &Path, bytes: &[u8]) -> anyhow::Result<()> {
1321    let parent = path.parent().context("Plugin file has no parent")?;
1322    fs::create_dir_all(parent)?;
1323    let temporary = tempfile::NamedTempFile::new_in(parent)?;
1324    fs::write(temporary.path(), bytes)?;
1325    temporary
1326        .persist(path)
1327        .map_err(|error| error.error)
1328        .with_context(|| format!("commit Plugin file {}", path.display()))?;
1329    Ok(())
1330}
1331
1332fn lock_plugin_root(root: &Path) -> anyhow::Result<fs::File> {
1333    let path = root.join(AUTHORING_LOCK);
1334    let parent = path.parent().context("Plugin Root lock has no parent")?;
1335    fs::create_dir_all(parent)?;
1336    let file = fs::OpenOptions::new()
1337        .create(true)
1338        .read(true)
1339        .write(true)
1340        .truncate(false)
1341        .open(&path)
1342        .with_context(|| format!("open Plugin Root authoring lock {}", path.display()))?;
1343    file.lock()
1344        .with_context(|| format!("lock Plugin Root authoring authority {}", path.display()))?;
1345    Ok(file)
1346}
1347fn copy_directory(source: &Path, destination: &Path) -> anyhow::Result<()> {
1348    for entry in read_entries(source)? {
1349        let file_type = entry.file_type()?;
1350        if file_type.is_dir() {
1351            let child = destination.join(entry.file_name());
1352            fs::create_dir_all(&child)?;
1353            copy_directory(&entry.path(), &child)?;
1354            continue;
1355        }
1356        if !file_type.is_file() {
1357            bail!(
1358                "Plugin Bundle contains a non-file entry: {}",
1359                entry.path().display()
1360            );
1361        }
1362        fs::copy(entry.path(), destination.join(entry.file_name()))?;
1363    }
1364    Ok(())
1365}
1366
1367fn remove_if_exists(path: &Path) -> anyhow::Result<()> {
1368    match fs::remove_file(path) {
1369        Ok(()) => Ok(()),
1370        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1371        Err(error) => Err(error).with_context(|| format!("remove {}", path.display())),
1372    }
1373}
1374
1375#[cfg(test)]
1376mod tests {
1377    use super::*;
1378    use lenso_app_plan::authoring::{
1379        HostBinding, HostCatalog, HostDefaultPlugin, HostPluginRelease, HostSlot,
1380    };
1381    use lenso_app_plan::{CapabilityEndpointPlan, CapabilityRequirementPlan};
1382
1383    fn fixture_root() -> tempfile::TempDir {
1384        let root = tempfile::tempdir().unwrap();
1385        fs::create_dir_all(root.path().join(".lenso")).unwrap();
1386        let host = HostCatalog::new(
1387            [HostSlot::one("agent")],
1388            [HostPluginRelease::new(PluginDescriptor::new(
1389                "example.agent",
1390                "1.0.0",
1391                "agent",
1392            ))],
1393            [HostDefaultPlugin::new("example.agent", "default")],
1394        );
1395        fs::write(
1396            root.path().join(HOST_CATALOG),
1397            serde_json::to_vec(&host).unwrap(),
1398        )
1399        .unwrap();
1400        root
1401    }
1402
1403    #[test]
1404    fn missing_plugin_root_resolves_the_host_default_app() {
1405        let root = fixture_root();
1406        let resolved = load_resolved_app(root.path()).unwrap();
1407
1408        assert_eq!(resolved.instances().len(), 1);
1409        assert_eq!(
1410            resolved.instances()[0].id().to_string(),
1411            "example.agent/default"
1412        );
1413    }
1414
1415    #[test]
1416    fn dependency_choice_is_materialized_and_survives_a_new_compatible_provider() {
1417        let root = tempfile::tempdir().unwrap();
1418        fs::create_dir_all(root.path().join(".lenso")).unwrap();
1419        let consumer = PluginDescriptor::new("example.copy", "1.0.0", "copy")
1420            .with_authoring(2, "lenso.test-authoring@2")
1421            .with_requirement(
1422                CapabilityRequirementPlan::one("example.store@1", "1.0.0")
1423                    .with_requirement_id("source"),
1424            );
1425        let store = |plugin_id: &str| {
1426            PluginDescriptor::new(plugin_id, "1.0.0", "store").with_capability(
1427                CapabilityEndpointPlan::new("example.store@1", "1.0.0", ["get"]),
1428            )
1429        };
1430        let host = HostCatalog::new(
1431            [HostSlot::one("copy"), HostSlot::many("store")],
1432            [
1433                HostPluginRelease::new(consumer.clone()),
1434                HostPluginRelease::new(store("example.store.a")),
1435            ],
1436            [
1437                HostDefaultPlugin::new("example.copy", "default"),
1438                HostDefaultPlugin::new("example.store.a", "default"),
1439            ],
1440        )
1441        .with_bindings([HostBinding::new(
1442            PluginInstanceId::new("example.copy", "default"),
1443            "example.store@1",
1444            "store",
1445        )
1446        .with_requirement_id("source")
1447        .selectable(None)]);
1448        fs::write(
1449            root.path().join(HOST_CATALOG),
1450            serde_json::to_vec(&host).unwrap(),
1451        )
1452        .unwrap();
1453
1454        set_dependency_selection(
1455            root.path(),
1456            PluginInstanceId::new("example.copy", "default"),
1457            "source",
1458            Some(PluginInstanceId::new("example.store.a", "default")),
1459        )
1460        .unwrap();
1461        assert!(root.path().join("plugins/dependencies.json").is_file());
1462
1463        let expanded = HostCatalog::new(
1464            [HostSlot::one("copy"), HostSlot::many("store")],
1465            [
1466                HostPluginRelease::new(consumer),
1467                HostPluginRelease::new(store("example.store.a")),
1468                HostPluginRelease::new(store("example.store.b")),
1469            ],
1470            [
1471                HostDefaultPlugin::new("example.copy", "default"),
1472                HostDefaultPlugin::new("example.store.a", "default"),
1473                HostDefaultPlugin::new("example.store.b", "default"),
1474            ],
1475        )
1476        .with_bindings([HostBinding::new(
1477            PluginInstanceId::new("example.copy", "default"),
1478            "example.store@1",
1479            "store",
1480        )
1481        .with_requirement_id("source")
1482        .selectable(None)]);
1483        fs::write(
1484            root.path().join(HOST_CATALOG),
1485            serde_json::to_vec(&expanded).unwrap(),
1486        )
1487        .unwrap();
1488
1489        let resolved = load_resolved_app(root.path()).unwrap();
1490        assert_eq!(
1491            resolved.plan().capability_bindings()[0].provider_instance(),
1492            "example.store.a/default"
1493        );
1494    }
1495
1496    #[test]
1497    fn first_bind_repairs_the_requested_legacy_ambiguity_and_materializes_unique_choices() {
1498        let root = tempfile::tempdir().unwrap();
1499        fs::create_dir_all(root.path().join(".lenso")).unwrap();
1500        let consumer = PluginDescriptor::new("example.copy", "1.0.0", "copy")
1501            .with_authoring(2, "lenso.test-authoring@2")
1502            .with_requirement(
1503                CapabilityRequirementPlan::one("example.store@1", "1.0.0")
1504                    .with_requirement_id("source"),
1505            )
1506            .with_requirement(
1507                CapabilityRequirementPlan::one("example.audit@1", "1.0.0")
1508                    .with_requirement_id("audit"),
1509            );
1510        let store = |plugin_id: &str| {
1511            PluginDescriptor::new(plugin_id, "1.0.0", "store").with_capability(
1512                CapabilityEndpointPlan::new("example.store@1", "1.0.0", ["get"]),
1513            )
1514        };
1515        let audit = PluginDescriptor::new("example.audit", "1.0.0", "audit").with_capability(
1516            CapabilityEndpointPlan::new("example.audit@1", "1.0.0", ["record"]),
1517        );
1518        let host = HostCatalog::new(
1519            [
1520                HostSlot::one("copy"),
1521                HostSlot::many("store"),
1522                HostSlot::one("audit"),
1523            ],
1524            [
1525                HostPluginRelease::new(consumer),
1526                HostPluginRelease::new(store("example.store.a")),
1527                HostPluginRelease::new(store("example.store.b")),
1528                HostPluginRelease::new(audit),
1529            ],
1530            [
1531                HostDefaultPlugin::new("example.copy", "default"),
1532                HostDefaultPlugin::new("example.store.a", "default"),
1533                HostDefaultPlugin::new("example.store.b", "default"),
1534                HostDefaultPlugin::new("example.audit", "default"),
1535            ],
1536        )
1537        .with_bindings([HostBinding::new(
1538            PluginInstanceId::new("example.copy", "default"),
1539            "example.store@1",
1540            "store",
1541        )
1542        .with_requirement_id("source")
1543        .selectable(None)]);
1544        fs::write(
1545            root.path().join(HOST_CATALOG),
1546            serde_json::to_vec(&host).unwrap(),
1547        )
1548        .unwrap();
1549
1550        let resolved = set_dependency_selection(
1551            root.path(),
1552            PluginInstanceId::new("example.copy", "default"),
1553            "source",
1554            Some(PluginInstanceId::new("example.store.b", "default")),
1555        )
1556        .unwrap();
1557        let bindings = resolved
1558            .plan()
1559            .capability_bindings()
1560            .iter()
1561            .map(|binding| (binding.requirement_id(), binding.provider_instance()))
1562            .collect::<BTreeMap<_, _>>();
1563
1564        assert_eq!(bindings["source"], "example.store.b/default");
1565        assert_eq!(bindings["audit"], "example.audit/default");
1566        let document: DependencySelectionsDocument = serde_json::from_slice(
1567            &fs::read(root.path().join("plugins/dependencies.json")).unwrap(),
1568        )
1569        .unwrap();
1570        assert_eq!(document.selections.len(), 1);
1571    }
1572
1573    #[test]
1574    fn batch_bind_adopts_two_ambiguous_requirements_atomically() {
1575        let root = tempfile::tempdir().unwrap();
1576        fs::create_dir_all(root.path().join(".lenso")).unwrap();
1577        let consumer = PluginDescriptor::new("example.copy", "1.0.0", "copy")
1578            .with_authoring(2, "lenso.test-authoring@2")
1579            .with_requirement(
1580                CapabilityRequirementPlan::one("example.store@1", "1.0.0")
1581                    .with_requirement_id("source"),
1582            )
1583            .with_requirement(
1584                CapabilityRequirementPlan::one("example.store@1", "1.0.0")
1585                    .with_requirement_id("destination"),
1586            );
1587        let store = |plugin_id: &str| {
1588            PluginDescriptor::new(plugin_id, "1.0.0", "store").with_capability(
1589                CapabilityEndpointPlan::new("example.store@1", "1.0.0", ["get"]),
1590            )
1591        };
1592        let host = HostCatalog::new(
1593            [HostSlot::one("copy"), HostSlot::many("store")],
1594            [
1595                HostPluginRelease::new(consumer),
1596                HostPluginRelease::new(store("example.store.a")),
1597                HostPluginRelease::new(store("example.store.b")),
1598            ],
1599            [
1600                HostDefaultPlugin::new("example.copy", "default"),
1601                HostDefaultPlugin::new("example.store.a", "default"),
1602                HostDefaultPlugin::new("example.store.b", "default"),
1603            ],
1604        )
1605        .with_bindings([
1606            HostBinding::new(
1607                PluginInstanceId::new("example.copy", "default"),
1608                "example.store@1",
1609                "store",
1610            )
1611            .with_requirement_id("source")
1612            .selectable(None),
1613            HostBinding::new(
1614                PluginInstanceId::new("example.copy", "default"),
1615                "example.store@1",
1616                "store",
1617            )
1618            .with_requirement_id("destination")
1619            .selectable(None),
1620        ]);
1621        fs::write(
1622            root.path().join(HOST_CATALOG),
1623            serde_json::to_vec(&host).unwrap(),
1624        )
1625        .unwrap();
1626        let consumer = PluginInstanceId::new("example.copy", "default");
1627
1628        let resolved = set_dependency_selections(
1629            root.path(),
1630            [
1631                DependencyChoice {
1632                    consumer: consumer.clone(),
1633                    requirement_id: "source".to_owned(),
1634                    provider: Some(PluginInstanceId::new("example.store.a", "default")),
1635                },
1636                DependencyChoice {
1637                    consumer,
1638                    requirement_id: "destination".to_owned(),
1639                    provider: Some(PluginInstanceId::new("example.store.b", "default")),
1640                },
1641            ],
1642        )
1643        .unwrap();
1644        let bindings = resolved
1645            .plan()
1646            .capability_bindings()
1647            .iter()
1648            .map(|binding| (binding.requirement_id(), binding.provider_instance()))
1649            .collect::<BTreeMap<_, _>>();
1650
1651        assert_eq!(bindings["source"], "example.store.a/default");
1652        assert_eq!(bindings["destination"], "example.store.b/default");
1653    }
1654
1655    #[test]
1656    fn inspection_separates_host_defaults_from_root_differences() {
1657        let root = fixture_root();
1658        let plugin = root.path().join("plugins/example.agent");
1659        fs::create_dir_all(&plugin).unwrap();
1660        fs::write(plugin.join("default.toml"), "").unwrap();
1661
1662        let state = inspect_plugin_root(root.path()).unwrap();
1663        let plugin = state
1664            .plugins()
1665            .iter()
1666            .find(|plugin| plugin.plugin_id() == "example.agent")
1667            .unwrap();
1668        let instance = &plugin.instances()[0];
1669
1670        assert_eq!(plugin.release_version(), "1.0.0");
1671        assert!(!plugin.is_root_supplied());
1672        assert!(instance.is_enabled());
1673        assert!(instance.is_host_default());
1674        assert!(!instance.is_disableable());
1675        assert_eq!(instance.root_configuration_toml(), Some(""));
1676        assert!(instance.source_digest().as_str().starts_with("sha256:"));
1677        assert!(instance.has_root_difference());
1678    }
1679
1680    #[test]
1681    fn inspection_reports_disabled_host_default_without_losing_the_instance() {
1682        let root = tempfile::tempdir().unwrap();
1683        fs::create_dir_all(root.path().join(".lenso")).unwrap();
1684        let host = HostCatalog::new(
1685            [HostSlot::optional("optional")],
1686            [HostPluginRelease::new(PluginDescriptor::new(
1687                "example.optional",
1688                "1.0.0",
1689                "optional",
1690            ))],
1691            [HostDefaultPlugin::new("example.optional", "default").disableable()],
1692        );
1693        fs::write(
1694            root.path().join(HOST_CATALOG),
1695            serde_json::to_vec(&host).unwrap(),
1696        )
1697        .unwrap();
1698        let plugin = root.path().join("plugins/example.optional");
1699        fs::create_dir_all(&plugin).unwrap();
1700        fs::write(plugin.join("default.disabled"), "").unwrap();
1701
1702        let state = inspect_plugin_root(root.path()).unwrap();
1703        let instance = &state.plugins()[0].instances()[0];
1704
1705        assert!(!instance.is_enabled());
1706        assert!(instance.is_host_default());
1707        assert!(instance.is_disableable());
1708        assert!(instance.is_disabled_by_root());
1709    }
1710
1711    #[test]
1712    fn local_selection_authority_disables_and_enables_one_instance() {
1713        let root = tempfile::tempdir().unwrap();
1714        fs::create_dir_all(root.path().join(".lenso")).unwrap();
1715        let host = HostCatalog::new(
1716            [HostSlot::optional("optional")],
1717            [HostPluginRelease::new(PluginDescriptor::new(
1718                "example.optional",
1719                "1.0.0",
1720                "optional",
1721            ))],
1722            [HostDefaultPlugin::new("example.optional", "default").disableable()],
1723        );
1724        fs::write(
1725            root.path().join(HOST_CATALOG),
1726            serde_json::to_vec(&host).unwrap(),
1727        )
1728        .unwrap();
1729        let authority = LocalPluginRootAuthority::new(root.path());
1730        let base = inspect_plugin_root(root.path()).unwrap().revision().clone();
1731
1732        let disabled = authority
1733            .set_enabled(&base, "example.optional", "default", false)
1734            .unwrap();
1735        assert_eq!(disabled.base_revision(), &base);
1736        assert!(!disabled.enabled());
1737        assert_eq!(disabled.plugin_id(), "example.optional");
1738        assert_eq!(disabled.instance(), "default");
1739        assert!(
1740            root.path()
1741                .join("plugins/example.optional/default.disabled")
1742                .is_file()
1743        );
1744
1745        let enabled = authority
1746            .set_enabled(disabled.revision(), "example.optional", "default", true)
1747            .unwrap();
1748        assert!(enabled.enabled());
1749        assert!(
1750            !root
1751                .path()
1752                .join("plugins/example.optional/default.disabled")
1753                .exists()
1754        );
1755    }
1756
1757    #[test]
1758    fn local_selection_authority_rejects_a_stale_revision_without_mutating() {
1759        let root = tempfile::tempdir().unwrap();
1760        fs::create_dir_all(root.path().join(".lenso")).unwrap();
1761        let host = HostCatalog::new(
1762            [HostSlot::optional("optional")],
1763            [HostPluginRelease::new(PluginDescriptor::new(
1764                "example.optional",
1765                "1.0.0",
1766                "optional",
1767            ))],
1768            [HostDefaultPlugin::new("example.optional", "default").disableable()],
1769        );
1770        fs::write(
1771            root.path().join(HOST_CATALOG),
1772            serde_json::to_vec(&host).unwrap(),
1773        )
1774        .unwrap();
1775        let authority = LocalPluginRootAuthority::new(root.path());
1776        let stale = inspect_plugin_root(root.path()).unwrap().revision().clone();
1777        authority
1778            .set_enabled(&stale, "example.optional", "default", false)
1779            .unwrap();
1780
1781        let error = authority
1782            .set_enabled(&stale, "example.optional", "default", true)
1783            .unwrap_err();
1784
1785        assert!(error.downcast_ref::<PluginRootRevisionConflict>().is_some());
1786        assert!(
1787            root.path()
1788                .join("plugins/example.optional/default.disabled")
1789                .is_file()
1790        );
1791    }
1792
1793    #[test]
1794    fn macos_metadata_at_plugin_root_is_ignored() {
1795        let root = fixture_root();
1796        fs::create_dir(root.path().join("plugins")).unwrap();
1797        fs::write(root.path().join("plugins/.DS_Store"), b"Finder metadata").unwrap();
1798
1799        let resolved = load_resolved_app(root.path()).unwrap();
1800
1801        assert_eq!(resolved.instances().len(), 1);
1802    }
1803
1804    #[test]
1805    fn macos_metadata_inside_plugin_directory_is_ignored() {
1806        let root = fixture_root();
1807        let plugin = root.path().join("plugins/example.agent");
1808        fs::create_dir_all(&plugin).unwrap();
1809        fs::write(plugin.join(".DS_Store"), b"Finder metadata").unwrap();
1810
1811        let resolved = load_resolved_app(root.path()).unwrap();
1812
1813        assert_eq!(resolved.instances().len(), 1);
1814    }
1815
1816    #[test]
1817    fn accepts_a_bounded_resource_directory_paired_with_an_instance() {
1818        let root = fixture_root();
1819        let plugin = root.path().join("plugins/example.agent");
1820        fs::create_dir_all(plugin.join("default/prompts")).unwrap();
1821        fs::write(plugin.join("default.toml"), "").unwrap();
1822        fs::write(plugin.join("default/prompts/system.md"), "hello").unwrap();
1823        fs::write(plugin.join("default/prompts/.DS_Store"), "metadata").unwrap();
1824
1825        let resolved = load_resolved_app(root.path()).unwrap();
1826
1827        assert!(
1828            resolved
1829                .instances()
1830                .iter()
1831                .any(|instance| instance.id().to_string() == "example.agent/default")
1832        );
1833    }
1834
1835    #[test]
1836    fn rejects_an_orphan_resource_directory() {
1837        let root = fixture_root();
1838        let resources = root.path().join("plugins/example.agent/custom");
1839        fs::create_dir_all(&resources).unwrap();
1840        fs::write(resources.join("prompt.md"), "orphan").unwrap();
1841
1842        let error = load_resolved_app(root.path()).unwrap_err();
1843
1844        assert!(
1845            error
1846                .to_string()
1847                .contains("orphan Plugin resource directory")
1848        );
1849    }
1850
1851    #[cfg(unix)]
1852    #[test]
1853    fn rejects_a_resource_symlink() {
1854        use std::os::unix::fs::symlink;
1855
1856        let root = fixture_root();
1857        let plugin = root.path().join("plugins/example.agent");
1858        fs::create_dir_all(plugin.join("custom")).unwrap();
1859        fs::write(plugin.join("custom.toml"), "").unwrap();
1860        fs::write(root.path().join("secret"), "not admitted").unwrap();
1861        symlink(root.path().join("secret"), plugin.join("custom/secret")).unwrap();
1862
1863        let error = load_resolved_app(root.path()).unwrap_err();
1864
1865        assert!(error.to_string().contains("cannot contain symlinks"));
1866    }
1867
1868    #[test]
1869    fn failed_configuration_candidate_does_not_write_the_plugin_root() {
1870        let root = fixture_root();
1871
1872        let error = configure_instance(
1873            root.path(),
1874            "example.agent",
1875            "default",
1876            b"unexpected = true\n",
1877        )
1878        .unwrap_err();
1879
1880        assert!(error.to_string().contains("non-empty configuration"));
1881        assert!(
1882            !root
1883                .path()
1884                .join("plugins/example.agent/default.toml")
1885                .exists()
1886        );
1887    }
1888
1889    #[test]
1890    fn required_default_disable_fails_before_writing_a_marker() {
1891        let root = fixture_root();
1892
1893        let error =
1894            set_instance_disabled(root.path(), "example.agent", "default", true).unwrap_err();
1895
1896        assert!(error.to_string().contains("cannot be disabled"));
1897        assert!(
1898            !root
1899                .path()
1900                .join("plugins/example.agent/default.disabled")
1901                .exists()
1902        );
1903    }
1904
1905    #[test]
1906    fn case_colliding_plugin_identities_fail_closed() {
1907        let mut normalized = BTreeMap::new();
1908        reject_case_collision(&mut normalized, "Example.Agent", "Plugin ID").unwrap();
1909        let error =
1910            reject_case_collision(&mut normalized, "example.agent", "Plugin ID").unwrap_err();
1911
1912        assert!(error.to_string().contains("case-colliding Plugin IDs"));
1913    }
1914
1915    #[test]
1916    fn add_replace_and_restore_publish_failures_leave_visible_bytes_unchanged() {
1917        for mutation in [
1918            BundleMutation::Add,
1919            BundleMutation::Replace,
1920            BundleMutation::Restore,
1921        ] {
1922            let root = tempfile::tempdir().unwrap();
1923            let destination = root
1924                .path()
1925                .join("plugins/example.agent/plugin.lenso-plugin");
1926            if mutation == BundleMutation::Add {
1927                fs::create_dir(root.path().join("plugins")).unwrap();
1928            } else {
1929                fs::create_dir_all(&destination).unwrap();
1930                fs::write(destination.join("marker"), "old").unwrap();
1931            }
1932            let staging = tempfile::tempdir_in(root.path()).unwrap();
1933            fs::write(staging.path().join("marker"), "new").unwrap();
1934
1935            let error = commit_staged_bundle_with(
1936                &destination,
1937                mutation,
1938                staging,
1939                |_, _, _| {
1940                    Err(std::io::Error::new(
1941                        std::io::ErrorKind::PermissionDenied,
1942                        "injected publish failure",
1943                    ))
1944                },
1945                |_| panic!("retirement cannot run before publication succeeds"),
1946            )
1947            .unwrap_err();
1948
1949            assert!(error.to_string().contains("Plugin Bundle"));
1950            if mutation == BundleMutation::Add {
1951                assert!(!destination.exists());
1952                assert!(!destination.parent().unwrap().exists());
1953            } else {
1954                assert_eq!(
1955                    fs::read_to_string(destination.join("marker")).unwrap(),
1956                    "old"
1957                );
1958            }
1959        }
1960    }
1961
1962    #[test]
1963    fn portable_bundle_add_publishes_with_one_atomic_rename() {
1964        let root = tempfile::tempdir().unwrap();
1965        let destination = root
1966            .path()
1967            .join("plugins/example.agent/plugin.lenso-plugin");
1968        let staging = tempfile::tempdir_in(root.path()).unwrap();
1969        fs::write(staging.path().join("marker"), "new").unwrap();
1970
1971        commit_staged_bundle(&destination, BundleMutation::Add, staging).unwrap();
1972
1973        assert_eq!(
1974            fs::read_to_string(destination.join("marker")).unwrap(),
1975            "new"
1976        );
1977    }
1978
1979    #[cfg(any(target_os = "linux", target_vendor = "apple", windows))]
1980    #[test]
1981    fn portable_bundle_add_never_replaces_a_concurrent_destination() {
1982        let root = tempfile::tempdir().unwrap();
1983        let destination = root.path().join("destination");
1984        fs::create_dir(&destination).unwrap();
1985        fs::write(destination.join("marker"), "old").unwrap();
1986        let staging = tempfile::tempdir_in(root.path()).unwrap();
1987        fs::write(staging.path().join("marker"), "new").unwrap();
1988
1989        atomic_publish_bundle(staging.path(), &destination, BundleMutation::Add).unwrap_err();
1990
1991        assert_eq!(
1992            fs::read_to_string(destination.join("marker")).unwrap(),
1993            "old"
1994        );
1995        assert_eq!(
1996            fs::read_to_string(staging.path().join("marker")).unwrap(),
1997            "new"
1998        );
1999    }
2000
2001    #[cfg(not(any(target_os = "linux", target_vendor = "apple")))]
2002    #[test]
2003    fn portable_bundle_replace_fails_closed_when_exchange_is_unavailable() {
2004        let root = tempfile::tempdir().unwrap();
2005        let destination = root
2006            .path()
2007            .join("plugins/example.agent/plugin.lenso-plugin");
2008        fs::create_dir_all(&destination).unwrap();
2009        fs::write(destination.join("marker"), "old").unwrap();
2010        let staging = tempfile::tempdir_in(root.path()).unwrap();
2011        fs::write(staging.path().join("marker"), "new").unwrap();
2012
2013        let error =
2014            commit_staged_bundle(&destination, BundleMutation::Replace, staging).unwrap_err();
2015
2016        assert_eq!(
2017            error
2018                .root_cause()
2019                .downcast_ref::<std::io::Error>()
2020                .unwrap()
2021                .kind(),
2022            std::io::ErrorKind::Unsupported
2023        );
2024        assert_eq!(
2025            fs::read_to_string(destination.join("marker")).unwrap(),
2026            "old"
2027        );
2028    }
2029
2030    #[cfg(any(target_os = "linux", target_vendor = "apple"))]
2031    #[test]
2032    fn replace_and_restore_commit_atomically_even_when_retirement_cleanup_fails() {
2033        for mutation in [BundleMutation::Replace, BundleMutation::Restore] {
2034            let root = tempfile::tempdir().unwrap();
2035            let destination = root
2036                .path()
2037                .join("plugins/example.agent/plugin.lenso-plugin");
2038            fs::create_dir_all(&destination).unwrap();
2039            fs::write(destination.join("marker"), "old").unwrap();
2040            let staging = tempfile::tempdir_in(root.path()).unwrap();
2041            fs::write(staging.path().join("marker"), "new").unwrap();
2042            let mut retired = None;
2043
2044            commit_staged_bundle_with(
2045                &destination,
2046                mutation,
2047                staging,
2048                atomic_publish_bundle,
2049                |staging| {
2050                    retired = Some(staging.keep());
2051                    Err(std::io::Error::other("injected cleanup failure"))
2052                },
2053            )
2054            .unwrap();
2055
2056            assert_eq!(
2057                fs::read_to_string(destination.join("marker")).unwrap(),
2058                "new"
2059            );
2060            let retired = retired.unwrap();
2061            assert_eq!(fs::read_to_string(retired.join("marker")).unwrap(), "old");
2062            fs::remove_dir_all(retired).unwrap();
2063        }
2064    }
2065}