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 identity;
8
9use std::{
10    collections::{BTreeMap, BTreeSet},
11    env, fs,
12    path::{Path, PathBuf},
13};
14
15use anyhow::{Context, bail};
16use lenso_app_plan::ExecutionClassId;
17use lenso_app_plan::authoring::{
18    HostCatalog, PluginDescriptor, PluginInstanceId, PluginRootInstance, PluginRootSnapshot,
19    ResolvedApp, resolve_plugin_root,
20};
21use lenso_plugin_bundle::{
22    ImplementationPolicy, VerifiedBundle, read_bundle_manifest, resolve_implementation,
23    verify_bundle_directory,
24};
25use serde_json::Value;
26
27use crate::identity::{
28    classify_existing_plugin_id, validate_plugin_id_v1, validate_release_version,
29};
30
31mod configuration_authority;
32mod selection_authority;
33
34pub use configuration_authority::{
35    LocalPluginRootAuthority, PluginConfigurationApplication, PluginConfigurationAuthority,
36    PluginConfigurationAuthoritySource, PluginConfigurationDiagnostic, PluginConfigurationProposal,
37    PluginConfigurationProposalStatus, PluginConfigurationPublication,
38    PluginConfigurationSourceConflict, PluginConfigurationSourceDigest, PluginRootRevision,
39    PluginRootRevisionConflict, PluginRootRevisionParseError, propose_instance_configuration,
40    publish_instance_configuration,
41};
42pub use selection_authority::{
43    PluginSelectionAuthority, PluginSelectionPublication, set_instance_enabled_fenced,
44};
45
46const PLUGIN_ROOT: &str = "plugins";
47const HOST_CATALOG: &str = ".lenso/host-catalog.json";
48const BUNDLE_NAME: &str = "plugin.lenso-plugin";
49const AUTHORING_LOCK: &str = ".lenso/plugin-root-authoring.lock";
50const MAX_CONFIGURATION_BYTES: u64 = 256 * 1024;
51const MAX_RESOURCE_FILES: usize = 4_096;
52const MAX_RESOURCE_FILE_BYTES: u64 = 1024 * 1024;
53const MAX_RESOURCE_TOTAL_BYTES: u64 = 16 * 1024 * 1024;
54const MAX_RESOURCE_DEPTH: usize = 32;
55
56/// Resolves the App selected by one project root's Host Catalog and Plugin Root.
57pub fn load_resolved_app(root: &Path) -> anyhow::Result<ResolvedApp> {
58    let host = load_host_catalog(root)?;
59    let snapshot = snapshot_plugin_root(root)?;
60    resolve_plugin_root(&host, &snapshot).map_err(anyhow::Error::msg)
61}
62
63/// Read-only authoring state for one Plugin Instance.
64///
65/// This describes only the App-owned difference and the Host policy needed to
66/// present it safely. The resolved Plan remains Host-owned runtime input.
67#[derive(Clone, Debug, Eq, PartialEq)]
68pub struct PluginInstanceAuthoringState {
69    id: PluginInstanceId,
70    origin: PluginInstanceOrigin,
71    selection: PluginInstanceSelection,
72    root_configuration_toml: Option<String>,
73    source_digest: PluginConfigurationSourceDigest,
74}
75
76/// Authority that introduced one visible Plugin Instance.
77#[derive(Clone, Copy, Debug, Eq, PartialEq)]
78pub enum PluginInstanceOrigin {
79    HostDefault { disableable: bool },
80    PluginRoot,
81}
82
83/// Current desired selection derived from the Plugin Root.
84#[derive(Clone, Copy, Debug, Eq, PartialEq)]
85pub enum PluginInstanceSelection {
86    Enabled,
87    DisabledByRoot,
88}
89
90impl PluginInstanceAuthoringState {
91    pub const fn id(&self) -> &PluginInstanceId {
92        &self.id
93    }
94
95    pub const fn is_enabled(&self) -> bool {
96        matches!(self.selection, PluginInstanceSelection::Enabled)
97    }
98
99    pub const fn is_host_default(&self) -> bool {
100        matches!(self.origin, PluginInstanceOrigin::HostDefault { .. })
101    }
102
103    pub const fn is_disableable(&self) -> bool {
104        match self.origin {
105            PluginInstanceOrigin::HostDefault { disableable } => disableable,
106            PluginInstanceOrigin::PluginRoot => true,
107        }
108    }
109
110    pub fn root_configuration_toml(&self) -> Option<&str> {
111        self.root_configuration_toml.as_deref()
112    }
113
114    pub const fn source_digest(&self) -> &PluginConfigurationSourceDigest {
115        &self.source_digest
116    }
117
118    pub const fn is_disabled_by_root(&self) -> bool {
119        matches!(self.selection, PluginInstanceSelection::DisabledByRoot)
120    }
121
122    pub const fn has_root_difference(&self) -> bool {
123        self.root_configuration_toml.is_some() || self.is_disabled_by_root()
124    }
125}
126
127/// Read-only authoring state for one Plugin Release visible to the App owner.
128#[derive(Clone, Debug, Eq, PartialEq)]
129pub struct PluginAuthoringState {
130    configuration_defaults: Value,
131    configuration_schema: Option<Value>,
132    plugin_id: String,
133    release_version: String,
134    root_supplied: bool,
135    instances: Vec<PluginInstanceAuthoringState>,
136}
137
138impl PluginAuthoringState {
139    pub const fn configuration_schema(&self) -> Option<&Value> {
140        self.configuration_schema.as_ref()
141    }
142
143    pub const fn configuration_defaults(&self) -> &Value {
144        &self.configuration_defaults
145    }
146
147    pub fn plugin_id(&self) -> &str {
148        &self.plugin_id
149    }
150
151    pub fn release_version(&self) -> &str {
152        &self.release_version
153    }
154
155    pub const fn is_root_supplied(&self) -> bool {
156        self.root_supplied
157    }
158
159    pub fn instances(&self) -> &[PluginInstanceAuthoringState] {
160        &self.instances
161    }
162}
163
164/// Complete read-only management projection for the current Plugin Root.
165#[derive(Clone, Debug, Eq, PartialEq)]
166pub struct PluginRootAuthoringState {
167    revision: PluginRootRevision,
168    resolved: ResolvedApp,
169    plugins: Vec<PluginAuthoringState>,
170}
171
172impl PluginRootAuthoringState {
173    pub const fn revision(&self) -> &PluginRootRevision {
174        &self.revision
175    }
176
177    pub const fn resolved(&self) -> &ResolvedApp {
178        &self.resolved
179    }
180
181    pub fn plugins(&self) -> &[PluginAuthoringState] {
182        &self.plugins
183    }
184}
185
186/// Inspects the current Host Catalog and Plugin Root without changing either.
187pub fn inspect_plugin_root(root: &Path) -> anyhow::Result<PluginRootAuthoringState> {
188    let host = load_host_catalog(root)?;
189    let snapshot = snapshot_plugin_root(root)?;
190    let revision = configuration_authority::revision_for_snapshot(&snapshot)?;
191    let resolved = resolve_plugin_root(&host, &snapshot).map_err(anyhow::Error::msg)?;
192    let enabled = resolved
193        .instances()
194        .iter()
195        .map(|instance| instance.id().clone())
196        .collect::<BTreeSet<_>>();
197    let disabled = snapshot.disabled().iter().cloned().collect::<BTreeSet<_>>();
198    let root_instances = snapshot
199        .instances()
200        .iter()
201        .map(|instance| instance.id().clone())
202        .collect::<BTreeSet<_>>();
203    let host_defaults = host
204        .defaults()
205        .iter()
206        .map(|instance| (instance.id().clone(), instance.is_disableable()))
207        .collect::<BTreeMap<_, _>>();
208
209    let ids = root_instances
210        .iter()
211        .chain(disabled.iter())
212        .chain(host_defaults.keys())
213        .cloned()
214        .collect::<BTreeSet<_>>();
215    let root_releases = snapshot
216        .releases()
217        .iter()
218        .map(|release| release.plugin_id().to_owned())
219        .collect::<BTreeSet<_>>();
220    let mut releases = host
221        .plugins()
222        .iter()
223        .map(|release| {
224            let descriptor = release.descriptor();
225            (
226                descriptor.plugin_id().to_owned(),
227                (
228                    descriptor.release_version().to_owned(),
229                    descriptor.configuration_schema().cloned(),
230                    descriptor.configuration_defaults().clone(),
231                ),
232            )
233        })
234        .chain(snapshot.releases().iter().map(|release| {
235            (
236                release.plugin_id().to_owned(),
237                (
238                    release.release_version().to_owned(),
239                    release.configuration_schema().cloned(),
240                    release.configuration_defaults().clone(),
241                ),
242            )
243        }))
244        .collect::<BTreeMap<_, _>>();
245    for id in &ids {
246        releases
247            .entry(id.plugin_id().to_owned())
248            .or_insert_with(|| (String::new(), None, Value::Object(Default::default())));
249    }
250
251    let mut plugins = Vec::with_capacity(releases.len());
252    for (plugin_id, (release_version, configuration_schema, configuration_defaults)) in releases {
253        let plugin_ids = ids
254            .iter()
255            .filter(|id| id.plugin_id() == plugin_id)
256            .cloned()
257            .collect::<Vec<_>>();
258        let mut instances = Vec::with_capacity(plugin_ids.len());
259        for id in plugin_ids {
260            let configuration_path = root
261                .join(PLUGIN_ROOT)
262                .join(id.plugin_id())
263                .join(format!("{}.toml", id.instance_key()));
264            let root_configuration_toml = if root_instances.contains(&id) {
265                Some(fs::read_to_string(&configuration_path).with_context(|| {
266                    format!(
267                        "read Plugin configuration source {}",
268                        configuration_path.display()
269                    )
270                })?)
271            } else {
272                None
273            };
274            let source_digest = instance_source_digest(&id, root_configuration_toml.as_deref());
275            let host_disableable = host_defaults.get(&id).copied();
276            instances.push(PluginInstanceAuthoringState {
277                origin: host_disableable.map_or(PluginInstanceOrigin::PluginRoot, |disableable| {
278                    PluginInstanceOrigin::HostDefault { disableable }
279                }),
280                selection: if enabled.contains(&id) {
281                    PluginInstanceSelection::Enabled
282                } else {
283                    PluginInstanceSelection::DisabledByRoot
284                },
285                root_configuration_toml,
286                source_digest,
287                id,
288            });
289        }
290        plugins.push(PluginAuthoringState {
291            configuration_defaults,
292            configuration_schema,
293            root_supplied: root_releases.contains(&plugin_id),
294            plugin_id,
295            release_version,
296            instances,
297        });
298    }
299    Ok(authoring_state(revision, resolved, plugins))
300}
301
302fn instance_source_digest(
303    id: &PluginInstanceId,
304    source: Option<&str>,
305) -> PluginConfigurationSourceDigest {
306    configuration_authority::source_digest_for_bytes(
307        id.plugin_id(),
308        id.instance_key(),
309        source.map(str::as_bytes),
310    )
311}
312
313fn authoring_state(
314    revision: PluginRootRevision,
315    resolved: ResolvedApp,
316    plugins: Vec<PluginAuthoringState>,
317) -> PluginRootAuthoringState {
318    PluginRootAuthoringState {
319        revision,
320        resolved,
321        plugins,
322    }
323}
324
325fn load_host_catalog(root: &Path) -> anyhow::Result<HostCatalog> {
326    let path = root.join(HOST_CATALOG);
327    let metadata = fs::symlink_metadata(&path).with_context(|| {
328        format!(
329            "Host Catalog is unavailable at {}; build or install the current Host first",
330            path.display()
331        )
332    })?;
333    if !metadata.file_type().is_file() {
334        bail!("Host Catalog must be a regular file: {}", path.display());
335    }
336    let bytes = fs::read(&path).with_context(|| format!("read Host Catalog {}", path.display()))?;
337    serde_json::from_slice(&bytes)
338        .with_context(|| format!("Host Catalog is invalid: {}", path.display()))
339}
340
341fn snapshot_plugin_root(root: &Path) -> anyhow::Result<PluginRootSnapshot> {
342    let plugin_root = root.join(PLUGIN_ROOT);
343    match fs::symlink_metadata(&plugin_root) {
344        Ok(metadata) if metadata.file_type().is_dir() => {}
345        Ok(_) => bail!(
346            "Plugin Root must be a regular directory: {}",
347            plugin_root.display()
348        ),
349        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
350            return Ok(PluginRootSnapshot::default());
351        }
352        Err(error) => {
353            return Err(error).with_context(|| format!("inspect {}", plugin_root.display()));
354        }
355    }
356
357    let mut releases = Vec::new();
358    let mut instances = Vec::new();
359    let mut disabled = Vec::new();
360    let mut plugin_names = BTreeMap::<String, String>::new();
361    let mut directories = read_entries(&plugin_root)?;
362    directories.sort_by_key(fs::DirEntry::file_name);
363    for entry in directories {
364        let name = utf8_name(&entry.path(), &entry.file_name())?;
365        if is_ignored_os_metadata(&name) {
366            continue;
367        }
368        let file_type = entry.file_type()?;
369        if !file_type.is_dir() {
370            bail!("unknown Plugin Root entry: {}", entry.path().display());
371        }
372        let plugin_id = name;
373        validate_existing_plugin_id(&plugin_id)?;
374        reject_case_collision(&mut plugin_names, &plugin_id, "Plugin ID")?;
375        scan_plugin_directory(
376            &entry.path(),
377            &plugin_id,
378            &mut releases,
379            &mut instances,
380            &mut disabled,
381        )?;
382    }
383    Ok(PluginRootSnapshot::new(releases, instances, disabled))
384}
385
386fn scan_plugin_directory(
387    directory: &Path,
388    plugin_id: &str,
389    releases: &mut Vec<PluginDescriptor>,
390    instances: &mut Vec<PluginRootInstance>,
391    disabled: &mut Vec<PluginInstanceId>,
392) -> anyhow::Result<()> {
393    let mut normalized = BTreeMap::<String, String>::new();
394    let mut configured_instances = BTreeSet::new();
395    let mut resource_directories = BTreeMap::<String, PathBuf>::new();
396    let mut entries = read_entries(directory)?;
397    entries.sort_by_key(fs::DirEntry::file_name);
398    for entry in entries {
399        let name = utf8_name(&entry.path(), &entry.file_name())?;
400        if is_ignored_os_metadata(&name) {
401            continue;
402        }
403        reject_case_collision(&mut normalized, &name, "Plugin filename")?;
404        let file_type = entry.file_type()?;
405        if name == BUNDLE_NAME {
406            if !file_type.is_dir() {
407                bail!(
408                    "Plugin Bundle must be a regular directory: {}",
409                    entry.path().display()
410                );
411            }
412            releases.push(read_bundle_descriptor(&entry.path(), plugin_id)?);
413            continue;
414        }
415        if file_type.is_dir() {
416            validate_instance_filename(&name)?;
417            resource_directories.insert(name, entry.path());
418            continue;
419        }
420        if !file_type.is_file() {
421            bail!(
422                "Plugin entries cannot be symlinks or special files: {}",
423                entry.path().display()
424            );
425        }
426        if let Some(instance) = name.strip_suffix(".toml") {
427            validate_instance_filename(instance)?;
428            configured_instances.insert(instance.to_owned());
429            instances.push(
430                PluginRootInstance::new(plugin_id, instance)
431                    .with_configuration(read_configuration(&entry.path())?),
432            );
433        } else if let Some(instance) = name.strip_suffix(".disabled") {
434            validate_instance_filename(instance)?;
435            if fs::metadata(entry.path())?.len() != 0 {
436                bail!("disabled marker must be empty: {}", entry.path().display());
437            }
438            disabled.push(PluginInstanceId::new(plugin_id, instance));
439        } else {
440            bail!("unknown Plugin file: {}", entry.path().display());
441        }
442    }
443    for (instance, resource_directory) in resource_directories {
444        if !configured_instances.contains(&instance) {
445            bail!(
446                "orphan Plugin resource directory without `{instance}.toml`: {}",
447                resource_directory.display()
448            );
449        }
450        validate_resource_directory(&resource_directory)?;
451    }
452    Ok(())
453}
454
455fn validate_resource_directory(path: &Path) -> anyhow::Result<()> {
456    let mut file_count = 0_usize;
457    let mut total_size = 0_u64;
458    let mut pending = vec![(path.to_path_buf(), 0_usize)];
459    while let Some((directory, depth)) = pending.pop() {
460        if depth > MAX_RESOURCE_DEPTH {
461            bail!(
462                "Plugin resource directory exceeds {MAX_RESOURCE_DEPTH} levels: {}",
463                directory.display()
464            );
465        }
466        let mut entries = read_entries(&directory)?;
467        entries.sort_by_key(fs::DirEntry::file_name);
468        for entry in entries {
469            let entry_path = entry.path();
470            let name = utf8_name(&entry_path, &entry.file_name())?;
471            if is_ignored_os_metadata(&name) {
472                continue;
473            }
474            let file_type = entry.file_type()?;
475            if file_type.is_dir() {
476                pending.push((entry_path, depth + 1));
477                continue;
478            }
479            if !file_type.is_file() {
480                bail!(
481                    "Plugin resources cannot contain symlinks or special files: {}",
482                    entry_path.display()
483                );
484            }
485            if file_count == MAX_RESOURCE_FILES {
486                bail!(
487                    "Plugin resources exceed {MAX_RESOURCE_FILES} files: {}",
488                    path.display()
489                );
490            }
491            let metadata = fs::symlink_metadata(&entry_path)?;
492            if metadata.file_type().is_symlink() || !metadata.file_type().is_file() {
493                bail!(
494                    "Plugin resources must be regular files: {}",
495                    entry_path.display()
496                );
497            }
498            if metadata.len() > MAX_RESOURCE_FILE_BYTES {
499                bail!("Plugin resource exceeds 1 MiB: {}", entry_path.display());
500            }
501            let bytes = fs::read(&entry_path)?;
502            let byte_count = u64::try_from(bytes.len()).with_context(|| {
503                format!("Plugin resource is too large: {}", entry_path.display())
504            })?;
505            if byte_count > MAX_RESOURCE_FILE_BYTES {
506                bail!("Plugin resource exceeds 1 MiB: {}", entry_path.display());
507            }
508            total_size = total_size
509                .checked_add(byte_count)
510                .with_context(|| format!("Plugin resource size overflow: {}", path.display()))?;
511            if total_size > MAX_RESOURCE_TOTAL_BYTES {
512                bail!("Plugin resources exceed 16 MiB: {}", path.display());
513            }
514            file_count += 1;
515        }
516    }
517    Ok(())
518}
519
520fn is_ignored_os_metadata(name: &str) -> bool {
521    name == ".DS_Store"
522}
523
524fn read_bundle_descriptor(path: &Path, plugin_id: &str) -> anyhow::Result<PluginDescriptor> {
525    validate_existing_plugin_id(plugin_id)?;
526    let verified = verify_bundle_directory(path)
527        .with_context(|| format!("verify Plugin Bundle {}", path.display()))?;
528    read_verified_bundle_descriptor(path, plugin_id, &verified)
529}
530
531fn read_verified_bundle_descriptor(
532    path: &Path,
533    plugin_id: &str,
534    verified: &VerifiedBundle,
535) -> anyhow::Result<PluginDescriptor> {
536    if verified.plugin_id != plugin_id {
537        bail!(
538            "Plugin Bundle ID `{}` does not match directory `{plugin_id}`",
539            verified.plugin_id
540        );
541    }
542    let manifest = read_bundle_manifest(path)
543        .with_context(|| format!("read Plugin Manifest {}", path.display()))?;
544    let descriptor = resolve_implementation(
545        &manifest,
546        &ImplementationPolicy {
547            host_target: format!("{}-unknown-{}", env::consts::ARCH, env::consts::OS),
548            execution_classes: vec![
549                ExecutionClassId::new("lenso.quickjs@1"),
550                ExecutionClassId::new("lenso.process@1"),
551                ExecutionClassId::new("lenso.wasm-component@1"),
552                ExecutionClassId::new("lenso.bun-process@1"),
553            ],
554        },
555    )?
556    .descriptor;
557    if descriptor.plugin_id() != plugin_id
558        || descriptor.release_version() != verified.release_version
559    {
560        bail!("Plugin Descriptor identity does not match the verified Bundle");
561    }
562    Ok(descriptor)
563}
564
565fn read_configuration(path: &Path) -> anyhow::Result<serde_json::Value> {
566    let metadata = fs::metadata(path)?;
567    if metadata.len() > MAX_CONFIGURATION_BYTES {
568        bail!("Plugin configuration exceeds 256 KiB: {}", path.display());
569    }
570    let text = fs::read_to_string(path)
571        .with_context(|| format!("read Plugin configuration {}", path.display()))?;
572    let table: toml::Table = toml::from_str(&text)
573        .with_context(|| format!("parse Plugin configuration {}", path.display()))?;
574    serde_json::to_value(table).context("convert Plugin configuration to portable values")
575}
576
577fn read_entries(path: &Path) -> anyhow::Result<Vec<fs::DirEntry>> {
578    fs::read_dir(path)
579        .with_context(|| format!("read directory {}", path.display()))?
580        .collect::<Result<Vec<_>, _>>()
581        .with_context(|| format!("read directory entries {}", path.display()))
582}
583
584fn utf8_name(path: &Path, name: &std::ffi::OsStr) -> anyhow::Result<String> {
585    name.to_str()
586        .map(str::to_owned)
587        .with_context(|| format!("Plugin path is not UTF-8: {}", path.display()))
588}
589
590fn validate_instance_filename(instance: &str) -> anyhow::Result<()> {
591    validate_path_identity(instance, "Instance key")?;
592    if instance.starts_with('.') || instance == "plugin" {
593        bail!("reserved Plugin Instance key `{instance}`");
594    }
595    Ok(())
596}
597
598fn validate_existing_plugin_id(plugin_id: &str) -> anyhow::Result<()> {
599    validate_path_identity(plugin_id, "Plugin ID")?;
600    classify_existing_plugin_id(plugin_id).map(|_| ())
601}
602
603fn validate_path_identity(value: &str, label: &str) -> anyhow::Result<()> {
604    if value.trim() != value
605        || value.is_empty()
606        || value == "."
607        || value == ".."
608        || value.contains(['/', '\0', '\\'])
609    {
610        bail!("invalid {label} `{value}`");
611    }
612    Ok(())
613}
614
615fn reject_case_collision(
616    normalized: &mut BTreeMap<String, String>,
617    value: &str,
618    label: &str,
619) -> anyhow::Result<()> {
620    let key = value.to_lowercase();
621    if let Some(previous) = normalized.insert(key, value.to_owned())
622        && previous != value
623    {
624        bail!("case-colliding {label}s `{previous}` and `{value}`");
625    }
626    Ok(())
627}
628
629/// Adds one verified external Plugin Bundle after resolving the complete candidate App.
630pub fn add_bundle(root: &Path, bundle: &Path) -> anyhow::Result<(String, String, ResolvedApp)> {
631    prepare_bundle_mutation(root, bundle, BundleMutation::Add)?.commit()
632}
633
634/// Desired root-Bundle mutation validated before visible bytes change.
635#[derive(Clone, Copy, Debug, Eq, PartialEq)]
636pub enum BundleMutation {
637    Add,
638    Replace,
639    /// Restore bytes already retained for a legacy or v1 root Plugin.
640    Restore,
641}
642
643/// Stable staged bytes and candidate resolution for one pending Bundle mutation.
644///
645/// Callers may inspect the verified identity before committing, which lets a
646/// catalog compare its signed metadata without re-reading or re-hashing the
647/// Bundle. The staged directory is removed automatically unless `commit` is
648/// called.
649#[derive(Debug)]
650pub struct PreparedBundleMutation {
651    authority: fs::File,
652    destination: PathBuf,
653    mutation: BundleMutation,
654    resolved: ResolvedApp,
655    staging: tempfile::TempDir,
656    verified: VerifiedBundle,
657}
658
659impl PreparedBundleMutation {
660    pub const fn verified(&self) -> &VerifiedBundle {
661        &self.verified
662    }
663
664    pub const fn resolved(&self) -> &ResolvedApp {
665        &self.resolved
666    }
667
668    pub fn destination(&self) -> &Path {
669        &self.destination
670    }
671
672    /// Atomically makes the already-validated staged Bundle visible.
673    pub fn commit(self) -> anyhow::Result<(String, String, ResolvedApp)> {
674        let Self {
675            authority,
676            destination,
677            mutation,
678            resolved,
679            staging,
680            verified,
681        } = self;
682        let commit = commit_staged_bundle(&destination, mutation, staging);
683        drop(authority);
684        commit?;
685        Ok((verified.plugin_id, verified.release_version, resolved))
686    }
687}
688
689fn commit_staged_bundle(
690    destination: &Path,
691    mutation: BundleMutation,
692    staging: tempfile::TempDir,
693) -> anyhow::Result<()> {
694    commit_staged_bundle_with(
695        destination,
696        mutation,
697        staging,
698        atomic_publish_bundle,
699        tempfile::TempDir::close,
700    )
701}
702
703fn commit_staged_bundle_with<Publish, Retire>(
704    destination: &Path,
705    mutation: BundleMutation,
706    staging: tempfile::TempDir,
707    publish: Publish,
708    retire: Retire,
709) -> anyhow::Result<()>
710where
711    Publish: FnOnce(&Path, &Path, BundleMutation) -> std::io::Result<()>,
712    Retire: FnOnce(tempfile::TempDir) -> std::io::Result<()>,
713{
714    let parent = destination
715        .parent()
716        .context("Bundle destination has no parent")?;
717    if mutation == BundleMutation::Add && destination.exists() {
718        bail!("Plugin Bundle already exists: {}", destination.display());
719    }
720    let created_parent = mutation == BundleMutation::Add && !parent.exists();
721    if mutation == BundleMutation::Add {
722        fs::create_dir_all(parent)?;
723    }
724    let publication =
725        publish(staging.path(), destination, mutation).with_context(|| match mutation {
726            BundleMutation::Add => format!("commit Plugin Bundle {}", destination.display()),
727            BundleMutation::Replace | BundleMutation::Restore => {
728                format!("atomically replace Plugin Bundle {}", destination.display())
729            }
730        });
731    if let Err(error) = publication {
732        if created_parent
733            && let Err(cleanup_error) = fs::remove_dir(parent)
734            && cleanup_error.kind() != std::io::ErrorKind::NotFound
735            && cleanup_error.kind() != std::io::ErrorKind::DirectoryNotEmpty
736        {
737            return Err(error.context(format!(
738                "also failed to remove empty Plugin directory {}: {cleanup_error}",
739                parent.display()
740            )));
741        }
742        return Err(error);
743    }
744
745    if mutation != BundleMutation::Add
746        && let Err(error) = retire(staging)
747    {
748        // EXCHANGE is the commit point: the new Bundle is already visible and
749        // the old one is isolated at the hidden staging path. Cleanup failure
750        // must not misreport a successfully committed mutation as rejected.
751        eprintln!("warning: Plugin Bundle committed, but retired Bundle cleanup failed: {error}");
752    }
753    Ok(())
754}
755
756#[cfg(any(target_os = "linux", target_vendor = "apple"))]
757fn atomic_publish_bundle(
758    staging: &Path,
759    destination: &Path,
760    mutation: BundleMutation,
761) -> std::io::Result<()> {
762    use rustix::fs::{CWD, RenameFlags, renameat_with};
763
764    let flags = match mutation {
765        BundleMutation::Add => RenameFlags::NOREPLACE,
766        BundleMutation::Replace | BundleMutation::Restore => RenameFlags::EXCHANGE,
767    };
768    renameat_with(CWD, staging, CWD, destination, flags).map_err(std::io::Error::from)
769}
770
771#[cfg(windows)]
772fn atomic_publish_bundle(
773    staging: &Path,
774    destination: &Path,
775    mutation: BundleMutation,
776) -> std::io::Result<()> {
777    match mutation {
778        // MoveFileW is intentionally used without a replacement flag: it is
779        // one atomic rename and fails if a concurrent writer won the target.
780        BundleMutation::Add => winsafe::MoveFile(
781            staging.to_str().ok_or_else(|| {
782                std::io::Error::new(
783                    std::io::ErrorKind::InvalidInput,
784                    "Plugin Bundle staging path is not Unicode",
785                )
786            })?,
787            destination.to_str().ok_or_else(|| {
788                std::io::Error::new(
789                    std::io::ErrorKind::InvalidInput,
790                    "Plugin Bundle destination path is not Unicode",
791                )
792            })?,
793        )
794        .map_err(|error| std::io::Error::from_raw_os_error(error.raw() as i32)),
795        BundleMutation::Replace | BundleMutation::Restore => Err(std::io::Error::new(
796            std::io::ErrorKind::Unsupported,
797            "atomic Plugin Bundle replacement is unavailable on this platform",
798        )),
799    }
800}
801
802#[cfg(not(any(target_os = "linux", target_vendor = "apple", windows)))]
803fn atomic_publish_bundle(
804    _staging: &Path,
805    _destination: &Path,
806    _mutation: BundleMutation,
807) -> std::io::Result<()> {
808    Err(std::io::Error::new(
809        std::io::ErrorKind::Unsupported,
810        "atomic Plugin Bundle publication is unavailable on this platform",
811    ))
812}
813
814/// Copies one candidate into stable staging, validates it once, and resolves
815/// the complete candidate App before any Plugin Root bytes change.
816pub fn prepare_bundle_mutation(
817    root: &Path,
818    bundle: &Path,
819    mutation: BundleMutation,
820) -> anyhow::Result<PreparedBundleMutation> {
821    let staging = tempfile::Builder::new()
822        .prefix(".plugin-bundle-")
823        .tempdir_in(root)?;
824    copy_directory(bundle, staging.path())?;
825    let (verified, descriptor) = verify_bundle_mutation(staging.path(), mutation)?;
826    let authority = lock_plugin_root(root)?;
827    let resolved = resolve_bundle_mutation(root, mutation, &verified, descriptor)?;
828    let destination = root
829        .join(PLUGIN_ROOT)
830        .join(&verified.plugin_id)
831        .join(BUNDLE_NAME);
832    Ok(PreparedBundleMutation {
833        authority,
834        destination,
835        mutation,
836        resolved,
837        staging,
838        verified,
839    })
840}
841
842/// Verifies one Bundle and resolves the complete candidate App for an add or replacement.
843///
844/// `prepare_bundle_mutation` is the preferred mutation boundary because it
845/// also owns stable staged bytes and the atomic commit.
846pub fn validate_bundle_mutation(
847    root: &Path,
848    bundle: &Path,
849    mutation: BundleMutation,
850) -> anyhow::Result<(lenso_plugin_bundle::VerifiedBundle, ResolvedApp)> {
851    let (verified, descriptor) = verify_bundle_mutation(bundle, mutation)?;
852    let _lock = lock_plugin_root(root)?;
853    let resolved = resolve_bundle_mutation(root, mutation, &verified, descriptor)?;
854    Ok((verified, resolved))
855}
856
857fn verify_bundle_mutation(
858    bundle: &Path,
859    mutation: BundleMutation,
860) -> anyhow::Result<(VerifiedBundle, PluginDescriptor)> {
861    let verified = verify_bundle_directory(bundle)
862        .with_context(|| format!("verify Plugin Bundle {}", bundle.display()))?;
863    match mutation {
864        BundleMutation::Add | BundleMutation::Replace => {
865            validate_plugin_id_v1(&verified.plugin_id)?;
866        }
867        BundleMutation::Restore => {
868            classify_existing_plugin_id(&verified.plugin_id)?;
869        }
870    }
871    validate_release_version(&verified.release_version)?;
872    let descriptor = read_verified_bundle_descriptor(bundle, &verified.plugin_id, &verified)?;
873    Ok((verified, descriptor))
874}
875
876fn resolve_bundle_mutation(
877    root: &Path,
878    mutation: BundleMutation,
879    verified: &VerifiedBundle,
880    descriptor: PluginDescriptor,
881) -> anyhow::Result<ResolvedApp> {
882    let host = load_host_catalog(root)?;
883    let current = snapshot_plugin_root(root)?;
884    let has_current = current
885        .releases()
886        .iter()
887        .any(|release| release.plugin_id() == verified.plugin_id);
888    match (mutation, has_current) {
889        (BundleMutation::Add, true) => {
890            bail!("Plugin `{}` already has a root Bundle", verified.plugin_id)
891        }
892        (BundleMutation::Replace | BundleMutation::Restore, false) => {
893            bail!(
894                "Plugin `{}` has no root Bundle to update",
895                verified.plugin_id
896            )
897        }
898        _ => {}
899    }
900    let candidate = PluginRootSnapshot::new(
901        current
902            .releases()
903            .iter()
904            .filter(|release| release.plugin_id() != verified.plugin_id)
905            .cloned()
906            .chain([descriptor]),
907        current.instances().iter().cloned(),
908        current.disabled().iter().cloned(),
909    );
910    let resolved = resolve_plugin_root(&host, &candidate).map_err(anyhow::Error::msg)?;
911    Ok(resolved)
912}
913
914/// Atomically writes one typed Instance patch after resolving the complete candidate App.
915pub fn configure_instance(
916    root: &Path,
917    plugin_id: &str,
918    instance: &str,
919    bytes: &[u8],
920) -> anyhow::Result<ResolvedApp> {
921    let base_revision = inspect_plugin_root(root)?.revision().clone();
922    let proposal =
923        propose_instance_configuration(root, &base_revision, plugin_id, instance, bytes)?;
924    let publication = publish_instance_configuration(root, &proposal)?;
925    Ok(publication.into_resolved())
926}
927
928/// Atomically changes one Instance selection marker after candidate resolution.
929pub fn set_instance_disabled(
930    root: &Path,
931    plugin_id: &str,
932    instance: &str,
933    disabled_state: bool,
934) -> anyhow::Result<ResolvedApp> {
935    set_instance_disabled_inner(root, plugin_id, instance, disabled_state, None)
936        .map(|(_, _, resolved)| resolved)
937}
938
939fn set_instance_disabled_inner(
940    root: &Path,
941    plugin_id: &str,
942    instance: &str,
943    disabled_state: bool,
944    expected_revision: Option<&PluginRootRevision>,
945) -> anyhow::Result<(PluginRootRevision, PluginRootRevision, ResolvedApp)> {
946    validate_existing_plugin_id(plugin_id)?;
947    validate_instance_filename(instance)?;
948    let _lock = lock_plugin_root(root)?;
949    let host = load_host_catalog(root)?;
950    let current = snapshot_plugin_root(root)?;
951    let base_revision = configuration_authority::revision_for_snapshot(&current)?;
952    if let Some(expected_revision) = expected_revision {
953        configuration_authority::ensure_revision(expected_revision, &base_revision)?;
954    }
955    let id = PluginInstanceId::new(plugin_id, instance);
956    let mut disabled = current.disabled().iter().cloned().collect::<BTreeSet<_>>();
957    if disabled_state {
958        disabled.insert(id.clone());
959    } else if !disabled.remove(&id) {
960        bail!("Plugin Instance `{id}` is not disabled");
961    }
962    let candidate = PluginRootSnapshot::new(
963        current.releases().iter().cloned(),
964        current.instances().iter().cloned(),
965        disabled,
966    );
967    let candidate_revision = configuration_authority::revision_for_snapshot(&candidate)?;
968    let resolved = resolve_plugin_root(&host, &candidate).map_err(anyhow::Error::msg)?;
969    let marker = root
970        .join(PLUGIN_ROOT)
971        .join(plugin_id)
972        .join(format!("{instance}.disabled"));
973    if disabled_state {
974        atomic_write(&marker, &[])?;
975    } else {
976        fs::remove_file(&marker)
977            .with_context(|| format!("remove disabled marker {}", marker.display()))?;
978    }
979    Ok((base_revision, candidate_revision, resolved))
980}
981
982/// Removes one App-owned Instance difference after validating the remaining App.
983pub fn remove_instance_difference(
984    root: &Path,
985    plugin_id: &str,
986    instance: &str,
987) -> anyhow::Result<ResolvedApp> {
988    validate_existing_plugin_id(plugin_id)?;
989    validate_instance_filename(instance)?;
990    let _lock = lock_plugin_root(root)?;
991    let host = load_host_catalog(root)?;
992    let current = snapshot_plugin_root(root)?;
993    let id = PluginInstanceId::new(plugin_id, instance);
994    let candidate = PluginRootSnapshot::new(
995        current.releases().iter().cloned(),
996        current
997            .instances()
998            .iter()
999            .filter(|item| item.id() != &id)
1000            .cloned(),
1001        current
1002            .disabled()
1003            .iter()
1004            .filter(|item| *item != &id)
1005            .cloned(),
1006    );
1007    let resolved = resolve_plugin_root(&host, &candidate).map_err(anyhow::Error::msg)?;
1008    let plugin_directory = root.join(PLUGIN_ROOT).join(plugin_id);
1009    remove_if_exists(&plugin_directory.join(format!("{instance}.toml")))?;
1010    remove_if_exists(&plugin_directory.join(format!("{instance}.disabled")))?;
1011    Ok(resolved)
1012}
1013
1014/// Moves one root-supplied Plugin to recoverable trash after validating the remaining App.
1015pub fn remove_plugin(root: &Path, plugin_id: &str) -> anyhow::Result<(ResolvedApp, PathBuf)> {
1016    validate_existing_plugin_id(plugin_id)?;
1017    let _lock = lock_plugin_root(root)?;
1018    let host = load_host_catalog(root)?;
1019    let current = snapshot_plugin_root(root)?;
1020    let candidate = PluginRootSnapshot::new(
1021        current
1022            .releases()
1023            .iter()
1024            .filter(|release| release.plugin_id() != plugin_id)
1025            .cloned(),
1026        current
1027            .instances()
1028            .iter()
1029            .filter(|instance| instance.id().plugin_id() != plugin_id)
1030            .cloned(),
1031        current
1032            .disabled()
1033            .iter()
1034            .filter(|instance| instance.plugin_id() != plugin_id)
1035            .cloned(),
1036    );
1037    let resolved = resolve_plugin_root(&host, &candidate).map_err(anyhow::Error::msg)?;
1038    let plugin_directory = root.join(PLUGIN_ROOT).join(plugin_id);
1039    if !plugin_directory.exists() {
1040        bail!("Plugin `{plugin_id}` has no Plugin Root directory");
1041    }
1042    let trash = root
1043        .join(".lenso/trash")
1044        .join(format!("{plugin_id}-{}", uuid::Uuid::now_v7()));
1045    fs::create_dir_all(trash.parent().expect("trash has a parent"))?;
1046    fs::rename(&plugin_directory, &trash)?;
1047    Ok((resolved, trash))
1048}
1049
1050fn atomic_write(path: &Path, bytes: &[u8]) -> anyhow::Result<()> {
1051    let parent = path.parent().context("Plugin file has no parent")?;
1052    fs::create_dir_all(parent)?;
1053    let temporary = tempfile::NamedTempFile::new_in(parent)?;
1054    fs::write(temporary.path(), bytes)?;
1055    temporary
1056        .persist(path)
1057        .map_err(|error| error.error)
1058        .with_context(|| format!("commit Plugin file {}", path.display()))?;
1059    Ok(())
1060}
1061
1062fn lock_plugin_root(root: &Path) -> anyhow::Result<fs::File> {
1063    let path = root.join(AUTHORING_LOCK);
1064    let parent = path.parent().context("Plugin Root lock has no parent")?;
1065    fs::create_dir_all(parent)?;
1066    let file = fs::OpenOptions::new()
1067        .create(true)
1068        .read(true)
1069        .write(true)
1070        .truncate(false)
1071        .open(&path)
1072        .with_context(|| format!("open Plugin Root authoring lock {}", path.display()))?;
1073    file.lock()
1074        .with_context(|| format!("lock Plugin Root authoring authority {}", path.display()))?;
1075    Ok(file)
1076}
1077fn copy_directory(source: &Path, destination: &Path) -> anyhow::Result<()> {
1078    for entry in read_entries(source)? {
1079        let file_type = entry.file_type()?;
1080        if file_type.is_dir() {
1081            let child = destination.join(entry.file_name());
1082            fs::create_dir_all(&child)?;
1083            copy_directory(&entry.path(), &child)?;
1084            continue;
1085        }
1086        if !file_type.is_file() {
1087            bail!(
1088                "Plugin Bundle contains a non-file entry: {}",
1089                entry.path().display()
1090            );
1091        }
1092        fs::copy(entry.path(), destination.join(entry.file_name()))?;
1093    }
1094    Ok(())
1095}
1096
1097fn remove_if_exists(path: &Path) -> anyhow::Result<()> {
1098    match fs::remove_file(path) {
1099        Ok(()) => Ok(()),
1100        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1101        Err(error) => Err(error).with_context(|| format!("remove {}", path.display())),
1102    }
1103}
1104
1105#[cfg(test)]
1106mod tests {
1107    use super::*;
1108    use lenso_app_plan::authoring::{HostDefaultPlugin, HostPluginRelease, HostSlot};
1109
1110    fn fixture_root() -> tempfile::TempDir {
1111        let root = tempfile::tempdir().unwrap();
1112        fs::create_dir_all(root.path().join(".lenso")).unwrap();
1113        let host = HostCatalog::new(
1114            [HostSlot::one("agent")],
1115            [HostPluginRelease::new(PluginDescriptor::new(
1116                "example.agent",
1117                "1.0.0",
1118                "agent",
1119            ))],
1120            [HostDefaultPlugin::new("example.agent", "default")],
1121        );
1122        fs::write(
1123            root.path().join(HOST_CATALOG),
1124            serde_json::to_vec(&host).unwrap(),
1125        )
1126        .unwrap();
1127        root
1128    }
1129
1130    #[test]
1131    fn missing_plugin_root_resolves_the_host_default_app() {
1132        let root = fixture_root();
1133        let resolved = load_resolved_app(root.path()).unwrap();
1134
1135        assert_eq!(resolved.instances().len(), 1);
1136        assert_eq!(
1137            resolved.instances()[0].id().to_string(),
1138            "example.agent/default"
1139        );
1140    }
1141
1142    #[test]
1143    fn inspection_separates_host_defaults_from_root_differences() {
1144        let root = fixture_root();
1145        let plugin = root.path().join("plugins/example.agent");
1146        fs::create_dir_all(&plugin).unwrap();
1147        fs::write(plugin.join("default.toml"), "").unwrap();
1148
1149        let state = inspect_plugin_root(root.path()).unwrap();
1150        let plugin = state
1151            .plugins()
1152            .iter()
1153            .find(|plugin| plugin.plugin_id() == "example.agent")
1154            .unwrap();
1155        let instance = &plugin.instances()[0];
1156
1157        assert_eq!(plugin.release_version(), "1.0.0");
1158        assert!(!plugin.is_root_supplied());
1159        assert!(instance.is_enabled());
1160        assert!(instance.is_host_default());
1161        assert!(!instance.is_disableable());
1162        assert_eq!(instance.root_configuration_toml(), Some(""));
1163        assert!(instance.source_digest().as_str().starts_with("sha256:"));
1164        assert!(instance.has_root_difference());
1165    }
1166
1167    #[test]
1168    fn inspection_reports_disabled_host_default_without_losing_the_instance() {
1169        let root = tempfile::tempdir().unwrap();
1170        fs::create_dir_all(root.path().join(".lenso")).unwrap();
1171        let host = HostCatalog::new(
1172            [HostSlot::optional("optional")],
1173            [HostPluginRelease::new(PluginDescriptor::new(
1174                "example.optional",
1175                "1.0.0",
1176                "optional",
1177            ))],
1178            [HostDefaultPlugin::new("example.optional", "default").disableable()],
1179        );
1180        fs::write(
1181            root.path().join(HOST_CATALOG),
1182            serde_json::to_vec(&host).unwrap(),
1183        )
1184        .unwrap();
1185        let plugin = root.path().join("plugins/example.optional");
1186        fs::create_dir_all(&plugin).unwrap();
1187        fs::write(plugin.join("default.disabled"), "").unwrap();
1188
1189        let state = inspect_plugin_root(root.path()).unwrap();
1190        let instance = &state.plugins()[0].instances()[0];
1191
1192        assert!(!instance.is_enabled());
1193        assert!(instance.is_host_default());
1194        assert!(instance.is_disableable());
1195        assert!(instance.is_disabled_by_root());
1196    }
1197
1198    #[test]
1199    fn local_selection_authority_disables_and_enables_one_instance() {
1200        let root = tempfile::tempdir().unwrap();
1201        fs::create_dir_all(root.path().join(".lenso")).unwrap();
1202        let host = HostCatalog::new(
1203            [HostSlot::optional("optional")],
1204            [HostPluginRelease::new(PluginDescriptor::new(
1205                "example.optional",
1206                "1.0.0",
1207                "optional",
1208            ))],
1209            [HostDefaultPlugin::new("example.optional", "default").disableable()],
1210        );
1211        fs::write(
1212            root.path().join(HOST_CATALOG),
1213            serde_json::to_vec(&host).unwrap(),
1214        )
1215        .unwrap();
1216        let authority = LocalPluginRootAuthority::new(root.path());
1217        let base = inspect_plugin_root(root.path()).unwrap().revision().clone();
1218
1219        let disabled = authority
1220            .set_enabled(&base, "example.optional", "default", false)
1221            .unwrap();
1222        assert_eq!(disabled.base_revision(), &base);
1223        assert!(!disabled.enabled());
1224        assert_eq!(disabled.plugin_id(), "example.optional");
1225        assert_eq!(disabled.instance(), "default");
1226        assert!(
1227            root.path()
1228                .join("plugins/example.optional/default.disabled")
1229                .is_file()
1230        );
1231
1232        let enabled = authority
1233            .set_enabled(disabled.revision(), "example.optional", "default", true)
1234            .unwrap();
1235        assert!(enabled.enabled());
1236        assert!(
1237            !root
1238                .path()
1239                .join("plugins/example.optional/default.disabled")
1240                .exists()
1241        );
1242    }
1243
1244    #[test]
1245    fn local_selection_authority_rejects_a_stale_revision_without_mutating() {
1246        let root = tempfile::tempdir().unwrap();
1247        fs::create_dir_all(root.path().join(".lenso")).unwrap();
1248        let host = HostCatalog::new(
1249            [HostSlot::optional("optional")],
1250            [HostPluginRelease::new(PluginDescriptor::new(
1251                "example.optional",
1252                "1.0.0",
1253                "optional",
1254            ))],
1255            [HostDefaultPlugin::new("example.optional", "default").disableable()],
1256        );
1257        fs::write(
1258            root.path().join(HOST_CATALOG),
1259            serde_json::to_vec(&host).unwrap(),
1260        )
1261        .unwrap();
1262        let authority = LocalPluginRootAuthority::new(root.path());
1263        let stale = inspect_plugin_root(root.path()).unwrap().revision().clone();
1264        authority
1265            .set_enabled(&stale, "example.optional", "default", false)
1266            .unwrap();
1267
1268        let error = authority
1269            .set_enabled(&stale, "example.optional", "default", true)
1270            .unwrap_err();
1271
1272        assert!(error.downcast_ref::<PluginRootRevisionConflict>().is_some());
1273        assert!(
1274            root.path()
1275                .join("plugins/example.optional/default.disabled")
1276                .is_file()
1277        );
1278    }
1279
1280    #[test]
1281    fn macos_metadata_at_plugin_root_is_ignored() {
1282        let root = fixture_root();
1283        fs::create_dir(root.path().join("plugins")).unwrap();
1284        fs::write(root.path().join("plugins/.DS_Store"), b"Finder metadata").unwrap();
1285
1286        let resolved = load_resolved_app(root.path()).unwrap();
1287
1288        assert_eq!(resolved.instances().len(), 1);
1289    }
1290
1291    #[test]
1292    fn macos_metadata_inside_plugin_directory_is_ignored() {
1293        let root = fixture_root();
1294        let plugin = root.path().join("plugins/example.agent");
1295        fs::create_dir_all(&plugin).unwrap();
1296        fs::write(plugin.join(".DS_Store"), b"Finder metadata").unwrap();
1297
1298        let resolved = load_resolved_app(root.path()).unwrap();
1299
1300        assert_eq!(resolved.instances().len(), 1);
1301    }
1302
1303    #[test]
1304    fn accepts_a_bounded_resource_directory_paired_with_an_instance() {
1305        let root = fixture_root();
1306        let plugin = root.path().join("plugins/example.agent");
1307        fs::create_dir_all(plugin.join("default/prompts")).unwrap();
1308        fs::write(plugin.join("default.toml"), "").unwrap();
1309        fs::write(plugin.join("default/prompts/system.md"), "hello").unwrap();
1310        fs::write(plugin.join("default/prompts/.DS_Store"), "metadata").unwrap();
1311
1312        let resolved = load_resolved_app(root.path()).unwrap();
1313
1314        assert!(
1315            resolved
1316                .instances()
1317                .iter()
1318                .any(|instance| instance.id().to_string() == "example.agent/default")
1319        );
1320    }
1321
1322    #[test]
1323    fn rejects_an_orphan_resource_directory() {
1324        let root = fixture_root();
1325        let resources = root.path().join("plugins/example.agent/custom");
1326        fs::create_dir_all(&resources).unwrap();
1327        fs::write(resources.join("prompt.md"), "orphan").unwrap();
1328
1329        let error = load_resolved_app(root.path()).unwrap_err();
1330
1331        assert!(
1332            error
1333                .to_string()
1334                .contains("orphan Plugin resource directory")
1335        );
1336    }
1337
1338    #[cfg(unix)]
1339    #[test]
1340    fn rejects_a_resource_symlink() {
1341        use std::os::unix::fs::symlink;
1342
1343        let root = fixture_root();
1344        let plugin = root.path().join("plugins/example.agent");
1345        fs::create_dir_all(plugin.join("custom")).unwrap();
1346        fs::write(plugin.join("custom.toml"), "").unwrap();
1347        fs::write(root.path().join("secret"), "not admitted").unwrap();
1348        symlink(root.path().join("secret"), plugin.join("custom/secret")).unwrap();
1349
1350        let error = load_resolved_app(root.path()).unwrap_err();
1351
1352        assert!(error.to_string().contains("cannot contain symlinks"));
1353    }
1354
1355    #[test]
1356    fn failed_configuration_candidate_does_not_write_the_plugin_root() {
1357        let root = fixture_root();
1358
1359        let error = configure_instance(
1360            root.path(),
1361            "example.agent",
1362            "default",
1363            b"unexpected = true\n",
1364        )
1365        .unwrap_err();
1366
1367        assert!(error.to_string().contains("non-empty configuration"));
1368        assert!(
1369            !root
1370                .path()
1371                .join("plugins/example.agent/default.toml")
1372                .exists()
1373        );
1374    }
1375
1376    #[test]
1377    fn required_default_disable_fails_before_writing_a_marker() {
1378        let root = fixture_root();
1379
1380        let error =
1381            set_instance_disabled(root.path(), "example.agent", "default", true).unwrap_err();
1382
1383        assert!(error.to_string().contains("cannot be disabled"));
1384        assert!(
1385            !root
1386                .path()
1387                .join("plugins/example.agent/default.disabled")
1388                .exists()
1389        );
1390    }
1391
1392    #[test]
1393    fn case_colliding_plugin_identities_fail_closed() {
1394        let mut normalized = BTreeMap::new();
1395        reject_case_collision(&mut normalized, "Example.Agent", "Plugin ID").unwrap();
1396        let error =
1397            reject_case_collision(&mut normalized, "example.agent", "Plugin ID").unwrap_err();
1398
1399        assert!(error.to_string().contains("case-colliding Plugin IDs"));
1400    }
1401
1402    #[test]
1403    fn add_replace_and_restore_publish_failures_leave_visible_bytes_unchanged() {
1404        for mutation in [
1405            BundleMutation::Add,
1406            BundleMutation::Replace,
1407            BundleMutation::Restore,
1408        ] {
1409            let root = tempfile::tempdir().unwrap();
1410            let destination = root
1411                .path()
1412                .join("plugins/example.agent/plugin.lenso-plugin");
1413            if mutation == BundleMutation::Add {
1414                fs::create_dir(root.path().join("plugins")).unwrap();
1415            } else {
1416                fs::create_dir_all(&destination).unwrap();
1417                fs::write(destination.join("marker"), "old").unwrap();
1418            }
1419            let staging = tempfile::tempdir_in(root.path()).unwrap();
1420            fs::write(staging.path().join("marker"), "new").unwrap();
1421
1422            let error = commit_staged_bundle_with(
1423                &destination,
1424                mutation,
1425                staging,
1426                |_, _, _| {
1427                    Err(std::io::Error::new(
1428                        std::io::ErrorKind::PermissionDenied,
1429                        "injected publish failure",
1430                    ))
1431                },
1432                |_| panic!("retirement cannot run before publication succeeds"),
1433            )
1434            .unwrap_err();
1435
1436            assert!(error.to_string().contains("Plugin Bundle"));
1437            if mutation == BundleMutation::Add {
1438                assert!(!destination.exists());
1439                assert!(!destination.parent().unwrap().exists());
1440            } else {
1441                assert_eq!(
1442                    fs::read_to_string(destination.join("marker")).unwrap(),
1443                    "old"
1444                );
1445            }
1446        }
1447    }
1448
1449    #[test]
1450    fn portable_bundle_add_publishes_with_one_atomic_rename() {
1451        let root = tempfile::tempdir().unwrap();
1452        let destination = root
1453            .path()
1454            .join("plugins/example.agent/plugin.lenso-plugin");
1455        let staging = tempfile::tempdir_in(root.path()).unwrap();
1456        fs::write(staging.path().join("marker"), "new").unwrap();
1457
1458        commit_staged_bundle(&destination, BundleMutation::Add, staging).unwrap();
1459
1460        assert_eq!(
1461            fs::read_to_string(destination.join("marker")).unwrap(),
1462            "new"
1463        );
1464    }
1465
1466    #[cfg(any(target_os = "linux", target_vendor = "apple", windows))]
1467    #[test]
1468    fn portable_bundle_add_never_replaces_a_concurrent_destination() {
1469        let root = tempfile::tempdir().unwrap();
1470        let destination = root.path().join("destination");
1471        fs::create_dir(&destination).unwrap();
1472        fs::write(destination.join("marker"), "old").unwrap();
1473        let staging = tempfile::tempdir_in(root.path()).unwrap();
1474        fs::write(staging.path().join("marker"), "new").unwrap();
1475
1476        atomic_publish_bundle(staging.path(), &destination, BundleMutation::Add).unwrap_err();
1477
1478        assert_eq!(
1479            fs::read_to_string(destination.join("marker")).unwrap(),
1480            "old"
1481        );
1482        assert_eq!(
1483            fs::read_to_string(staging.path().join("marker")).unwrap(),
1484            "new"
1485        );
1486    }
1487
1488    #[cfg(not(any(target_os = "linux", target_vendor = "apple")))]
1489    #[test]
1490    fn portable_bundle_replace_fails_closed_when_exchange_is_unavailable() {
1491        let root = tempfile::tempdir().unwrap();
1492        let destination = root
1493            .path()
1494            .join("plugins/example.agent/plugin.lenso-plugin");
1495        fs::create_dir_all(&destination).unwrap();
1496        fs::write(destination.join("marker"), "old").unwrap();
1497        let staging = tempfile::tempdir_in(root.path()).unwrap();
1498        fs::write(staging.path().join("marker"), "new").unwrap();
1499
1500        let error =
1501            commit_staged_bundle(&destination, BundleMutation::Replace, staging).unwrap_err();
1502
1503        assert_eq!(
1504            error
1505                .root_cause()
1506                .downcast_ref::<std::io::Error>()
1507                .unwrap()
1508                .kind(),
1509            std::io::ErrorKind::Unsupported
1510        );
1511        assert_eq!(
1512            fs::read_to_string(destination.join("marker")).unwrap(),
1513            "old"
1514        );
1515    }
1516
1517    #[cfg(any(target_os = "linux", target_vendor = "apple"))]
1518    #[test]
1519    fn replace_and_restore_commit_atomically_even_when_retirement_cleanup_fails() {
1520        for mutation in [BundleMutation::Replace, BundleMutation::Restore] {
1521            let root = tempfile::tempdir().unwrap();
1522            let destination = root
1523                .path()
1524                .join("plugins/example.agent/plugin.lenso-plugin");
1525            fs::create_dir_all(&destination).unwrap();
1526            fs::write(destination.join("marker"), "old").unwrap();
1527            let staging = tempfile::tempdir_in(root.path()).unwrap();
1528            fs::write(staging.path().join("marker"), "new").unwrap();
1529            let mut retired = None;
1530
1531            commit_staged_bundle_with(
1532                &destination,
1533                mutation,
1534                staging,
1535                atomic_publish_bundle,
1536                |staging| {
1537                    retired = Some(staging.keep());
1538                    Err(std::io::Error::other("injected cleanup failure"))
1539                },
1540            )
1541            .unwrap();
1542
1543            assert_eq!(
1544                fs::read_to_string(destination.join("marker")).unwrap(),
1545                "new"
1546            );
1547            let retired = retired.unwrap();
1548            assert_eq!(fs::read_to_string(retired.join("marker")).unwrap(), "old");
1549            fs::remove_dir_all(retired).unwrap();
1550        }
1551    }
1552}