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