Skip to main content

lenso_app_authoring/
lib.rs

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