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