Skip to main content

lenso_app_authoring/
lib.rs

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