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::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 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 runtimes: [
709 ("lenso.quickjs@1", "lenso.quickjs@1"),
710 ("lenso.process@1", "lenso.process-stdio@2"),
711 ("lenso.process@1", "lenso.process@1"),
712 ("lenso.wasm-component@1", "lenso.wasm-component@1"),
713 ("lenso.bun-process@1", PLUGIN_AUTHORING_V2_RUNTIME_PROFILE),
714 ("lenso.bun-process@1", "lenso.bun-process@1"),
715 ]
716 .into_iter()
717 .map(|(execution_class, runtime_profile)| RuntimeAdmission {
718 execution_class: ExecutionClassId::new(execution_class),
719 runtime_profile: runtime_profile.to_owned(),
720 })
721 .collect(),
722 },
723 )?
724 .descriptor;
725 if descriptor.plugin_id() != plugin_id
726 || descriptor.release_version() != verified.release_version
727 {
728 bail!("Plugin Descriptor identity does not match the verified Bundle");
729 }
730 Ok(descriptor)
731}
732
733fn read_configuration(path: &Path) -> anyhow::Result<serde_json::Value> {
734 let metadata = fs::metadata(path)?;
735 if metadata.len() > MAX_CONFIGURATION_BYTES {
736 bail!("Plugin configuration exceeds 256 KiB: {}", path.display());
737 }
738 let text = fs::read_to_string(path)
739 .with_context(|| format!("read Plugin configuration {}", path.display()))?;
740 let table: toml::Table = toml::from_str(&text)
741 .with_context(|| format!("parse Plugin configuration {}", path.display()))?;
742 serde_json::to_value(table).context("convert Plugin configuration to portable values")
743}
744
745fn read_entries(path: &Path) -> anyhow::Result<Vec<fs::DirEntry>> {
746 fs::read_dir(path)
747 .with_context(|| format!("read directory {}", path.display()))?
748 .collect::<Result<Vec<_>, _>>()
749 .with_context(|| format!("read directory entries {}", path.display()))
750}
751
752fn utf8_name(path: &Path, name: &std::ffi::OsStr) -> anyhow::Result<String> {
753 name.to_str()
754 .map(str::to_owned)
755 .with_context(|| format!("Plugin path is not UTF-8: {}", path.display()))
756}
757
758fn validate_instance_filename(instance: &str) -> anyhow::Result<()> {
759 validate_path_identity(instance, "Instance key")?;
760 if instance.starts_with('.') || instance == "plugin" {
761 bail!("reserved Plugin Instance key `{instance}`");
762 }
763 Ok(())
764}
765
766fn validate_existing_plugin_id(plugin_id: &str) -> anyhow::Result<()> {
767 validate_path_identity(plugin_id, "Plugin ID")?;
768 classify_existing_plugin_id(plugin_id).map(|_| ())
769}
770
771fn validate_path_identity(value: &str, label: &str) -> anyhow::Result<()> {
772 if value.trim() != value
773 || value.is_empty()
774 || value == "."
775 || value == ".."
776 || value.contains(['/', '\0', '\\'])
777 {
778 bail!("invalid {label} `{value}`");
779 }
780 Ok(())
781}
782
783fn reject_case_collision(
784 normalized: &mut BTreeMap<String, String>,
785 value: &str,
786 label: &str,
787) -> anyhow::Result<()> {
788 let key = value.to_lowercase();
789 if let Some(previous) = normalized.insert(key, value.to_owned())
790 && previous != value
791 {
792 bail!("case-colliding {label}s `{previous}` and `{value}`");
793 }
794 Ok(())
795}
796
797pub fn add_bundle(root: &Path, bundle: &Path) -> anyhow::Result<(String, String, ResolvedApp)> {
799 prepare_bundle_mutation(root, bundle, BundleMutation::Add)?.commit()
800}
801
802#[derive(Clone, Copy, Debug, Eq, PartialEq)]
804pub enum BundleMutation {
805 Add,
806 Replace,
807 Restore,
809}
810
811#[derive(Debug)]
818pub struct PreparedBundleMutation {
819 authority: fs::File,
820 destination: PathBuf,
821 mutation: BundleMutation,
822 resolved: ResolvedApp,
823 staging: tempfile::TempDir,
824 verified: VerifiedBundle,
825}
826
827impl PreparedBundleMutation {
828 pub const fn verified(&self) -> &VerifiedBundle {
829 &self.verified
830 }
831
832 pub const fn resolved(&self) -> &ResolvedApp {
833 &self.resolved
834 }
835
836 pub fn destination(&self) -> &Path {
837 &self.destination
838 }
839
840 pub fn commit(self) -> anyhow::Result<(String, String, ResolvedApp)> {
842 let Self {
843 authority,
844 destination,
845 mutation,
846 resolved,
847 staging,
848 verified,
849 } = self;
850 let commit = commit_staged_bundle(&destination, mutation, staging);
851 drop(authority);
852 commit?;
853 Ok((verified.plugin_id, verified.release_version, resolved))
854 }
855}
856
857fn commit_staged_bundle(
858 destination: &Path,
859 mutation: BundleMutation,
860 staging: tempfile::TempDir,
861) -> anyhow::Result<()> {
862 commit_staged_bundle_with(
863 destination,
864 mutation,
865 staging,
866 atomic_publish_bundle,
867 tempfile::TempDir::close,
868 )
869}
870
871fn commit_staged_bundle_with<Publish, Retire>(
872 destination: &Path,
873 mutation: BundleMutation,
874 staging: tempfile::TempDir,
875 publish: Publish,
876 retire: Retire,
877) -> anyhow::Result<()>
878where
879 Publish: FnOnce(&Path, &Path, BundleMutation) -> std::io::Result<()>,
880 Retire: FnOnce(tempfile::TempDir) -> std::io::Result<()>,
881{
882 let parent = destination
883 .parent()
884 .context("Bundle destination has no parent")?;
885 if mutation == BundleMutation::Add && destination.exists() {
886 bail!("Plugin Bundle already exists: {}", destination.display());
887 }
888 let created_parent = mutation == BundleMutation::Add && !parent.exists();
889 if mutation == BundleMutation::Add {
890 fs::create_dir_all(parent)?;
891 }
892 let publication =
893 publish(staging.path(), destination, mutation).with_context(|| match mutation {
894 BundleMutation::Add => format!("commit Plugin Bundle {}", destination.display()),
895 BundleMutation::Replace | BundleMutation::Restore => {
896 format!("atomically replace Plugin Bundle {}", destination.display())
897 }
898 });
899 if let Err(error) = publication {
900 if created_parent
901 && let Err(cleanup_error) = fs::remove_dir(parent)
902 && cleanup_error.kind() != std::io::ErrorKind::NotFound
903 && cleanup_error.kind() != std::io::ErrorKind::DirectoryNotEmpty
904 {
905 return Err(error.context(format!(
906 "also failed to remove empty Plugin directory {}: {cleanup_error}",
907 parent.display()
908 )));
909 }
910 return Err(error);
911 }
912
913 if mutation != BundleMutation::Add
914 && let Err(error) = retire(staging)
915 {
916 eprintln!("warning: Plugin Bundle committed, but retired Bundle cleanup failed: {error}");
920 }
921 Ok(())
922}
923
924#[cfg(any(target_os = "linux", target_vendor = "apple"))]
925fn atomic_publish_bundle(
926 staging: &Path,
927 destination: &Path,
928 mutation: BundleMutation,
929) -> std::io::Result<()> {
930 use rustix::fs::{CWD, RenameFlags, renameat_with};
931
932 let flags = match mutation {
933 BundleMutation::Add => RenameFlags::NOREPLACE,
934 BundleMutation::Replace | BundleMutation::Restore => RenameFlags::EXCHANGE,
935 };
936 renameat_with(CWD, staging, CWD, destination, flags).map_err(std::io::Error::from)
937}
938
939#[cfg(windows)]
940fn atomic_publish_bundle(
941 staging: &Path,
942 destination: &Path,
943 mutation: BundleMutation,
944) -> std::io::Result<()> {
945 match mutation {
946 BundleMutation::Add => winsafe::MoveFile(
949 staging.to_str().ok_or_else(|| {
950 std::io::Error::new(
951 std::io::ErrorKind::InvalidInput,
952 "Plugin Bundle staging path is not Unicode",
953 )
954 })?,
955 destination.to_str().ok_or_else(|| {
956 std::io::Error::new(
957 std::io::ErrorKind::InvalidInput,
958 "Plugin Bundle destination path is not Unicode",
959 )
960 })?,
961 )
962 .map_err(|error| std::io::Error::from_raw_os_error(error.raw() as i32)),
963 BundleMutation::Replace | BundleMutation::Restore => Err(std::io::Error::new(
964 std::io::ErrorKind::Unsupported,
965 "atomic Plugin Bundle replacement is unavailable on this platform",
966 )),
967 }
968}
969
970#[cfg(not(any(target_os = "linux", target_vendor = "apple", windows)))]
971fn atomic_publish_bundle(
972 _staging: &Path,
973 _destination: &Path,
974 _mutation: BundleMutation,
975) -> std::io::Result<()> {
976 Err(std::io::Error::new(
977 std::io::ErrorKind::Unsupported,
978 "atomic Plugin Bundle publication is unavailable on this platform",
979 ))
980}
981
982pub fn prepare_bundle_mutation(
985 root: &Path,
986 bundle: &Path,
987 mutation: BundleMutation,
988) -> anyhow::Result<PreparedBundleMutation> {
989 let staging = tempfile::Builder::new()
990 .prefix(".plugin-bundle-")
991 .tempdir_in(root)?;
992 copy_directory(bundle, staging.path())?;
993 let authority = lock_plugin_root(root)?;
994 let host = load_host_catalog(root)?;
995 let (verified, descriptor) = verify_bundle_mutation(staging.path(), mutation, &host)?;
996 let resolved = resolve_bundle_mutation(root, mutation, &verified, descriptor, &host)?;
997 let destination = root
998 .join(PLUGIN_ROOT)
999 .join(&verified.plugin_id)
1000 .join(BUNDLE_NAME);
1001 Ok(PreparedBundleMutation {
1002 authority,
1003 destination,
1004 mutation,
1005 resolved,
1006 staging,
1007 verified,
1008 })
1009}
1010
1011pub fn validate_bundle_mutation(
1016 root: &Path,
1017 bundle: &Path,
1018 mutation: BundleMutation,
1019) -> anyhow::Result<(lenso_plugin_bundle::VerifiedBundle, ResolvedApp)> {
1020 let _lock = lock_plugin_root(root)?;
1021 let host = load_host_catalog(root)?;
1022 let (verified, descriptor) = verify_bundle_mutation(bundle, mutation, &host)?;
1023 let resolved = resolve_bundle_mutation(root, mutation, &verified, descriptor, &host)?;
1024 Ok((verified, resolved))
1025}
1026
1027fn verify_bundle_mutation(
1028 bundle: &Path,
1029 mutation: BundleMutation,
1030 host: &HostInput,
1031) -> anyhow::Result<(VerifiedBundle, PluginDescriptor)> {
1032 let verified = verify_bundle_directory(bundle)
1033 .with_context(|| format!("verify Plugin Bundle {}", bundle.display()))?;
1034 match mutation {
1035 BundleMutation::Add | BundleMutation::Replace => {
1036 validate_plugin_id_v1(&verified.plugin_id)?;
1037 }
1038 BundleMutation::Restore => {
1039 classify_existing_plugin_id(&verified.plugin_id)?;
1040 }
1041 }
1042 validate_release_version(&verified.release_version)?;
1043 let descriptor = host.select_bundle(bundle, &verified)?;
1044 Ok((verified, descriptor))
1045}
1046
1047fn resolve_bundle_mutation(
1048 root: &Path,
1049 mutation: BundleMutation,
1050 verified: &VerifiedBundle,
1051 descriptor: PluginDescriptor,
1052 host: &HostInput,
1053) -> anyhow::Result<ResolvedApp> {
1054 let current = snapshot_plugin_root(root, host)?;
1055 let has_current = current
1056 .releases()
1057 .iter()
1058 .any(|release| release.plugin_id() == verified.plugin_id);
1059 match (mutation, has_current) {
1060 (BundleMutation::Add, true) => {
1061 bail!("Plugin `{}` already has a root Bundle", verified.plugin_id)
1062 }
1063 (BundleMutation::Replace | BundleMutation::Restore, false) => {
1064 bail!(
1065 "Plugin `{}` has no root Bundle to update",
1066 verified.plugin_id
1067 )
1068 }
1069 _ => {}
1070 }
1071 let candidate = preserve_dependency_selections(
1072 PluginRootSnapshot::new(
1073 current
1074 .releases()
1075 .iter()
1076 .filter(|release| release.plugin_id() != verified.plugin_id)
1077 .cloned()
1078 .chain([descriptor]),
1079 current.instances().iter().cloned(),
1080 current.disabled().iter().cloned(),
1081 ),
1082 ¤t,
1083 );
1084 let resolved = host.resolve(&candidate).map_err(anyhow::Error::msg)?;
1085 Ok(resolved)
1086}
1087
1088pub fn configure_instance(
1090 root: &Path,
1091 plugin_id: &str,
1092 instance: &str,
1093 bytes: &[u8],
1094) -> anyhow::Result<ResolvedApp> {
1095 let base_revision = inspect_plugin_root(root)?.revision().clone();
1096 let proposal =
1097 propose_instance_configuration(root, &base_revision, plugin_id, instance, bytes)?;
1098 let publication = publish_instance_configuration(root, &proposal)?;
1099 Ok(publication.into_resolved())
1100}
1101
1102pub fn set_instance_disabled(
1104 root: &Path,
1105 plugin_id: &str,
1106 instance: &str,
1107 disabled_state: bool,
1108) -> anyhow::Result<ResolvedApp> {
1109 set_instance_disabled_inner(root, plugin_id, instance, disabled_state, None)
1110 .map(|(_, _, resolved)| resolved)
1111}
1112
1113pub fn set_dependency_selection(
1115 root: &Path,
1116 consumer: PluginInstanceId,
1117 requirement_id: &str,
1118 provider: Option<PluginInstanceId>,
1119) -> anyhow::Result<ResolvedApp> {
1120 let selection = DependencyChoice {
1121 consumer,
1122 requirement_id: requirement_id.to_owned(),
1123 provider,
1124 };
1125 set_dependency_selections(root, [selection])
1126}
1127
1128pub fn set_dependency_selections(
1130 root: &Path,
1131 replacements: impl IntoIterator<Item = DependencyChoice>,
1132) -> anyhow::Result<ResolvedApp> {
1133 let replacements = replacements.into_iter().collect::<Vec<_>>();
1134 if replacements.is_empty() {
1135 bail!("at least one dependency selection is required");
1136 }
1137 let mut replacement_keys = BTreeSet::new();
1138 for selection in &replacements {
1139 validate_existing_plugin_id(selection.consumer.plugin_id())?;
1140 validate_instance_filename(selection.consumer.instance_key())?;
1141 if selection.requirement_id.trim().is_empty() {
1142 bail!("dependency requirement identity must not be empty");
1143 }
1144 if let Some(provider) = &selection.provider {
1145 validate_existing_plugin_id(provider.plugin_id())?;
1146 validate_instance_filename(provider.instance_key())?;
1147 }
1148 if !replacement_keys.insert((&selection.consumer, selection.requirement_id.as_str())) {
1149 bail!(
1150 "duplicate dependency selection for `{}` requirement `{}`",
1151 selection.consumer,
1152 selection.requirement_id
1153 );
1154 }
1155 }
1156 let _lock = lock_plugin_root(root)?;
1157 let host = load_host_catalog(root)?;
1158 let current = snapshot_plugin_root(root, &host)?;
1159 let mut selections = current.dependency_choices().to_vec();
1160 selections.retain(|selection| {
1161 !replacement_keys.contains(&(&selection.consumer, selection.requirement_id.as_str()))
1162 });
1163 drop(replacement_keys);
1164 selections.extend(replacements);
1165 selections.sort_by(|left, right| {
1166 left.consumer
1167 .cmp(&right.consumer)
1168 .then_with(|| left.requirement_id.cmp(&right.requirement_id))
1169 });
1170 let (resolved, selections) = resolve_adopted_dependencies(&host, ¤t, selections)?;
1171 let document = DependencySelectionsDocument {
1172 schema: DEPENDENCY_SELECTIONS_SCHEMA.to_owned(),
1173 selections,
1174 };
1175 let bytes = serde_json::to_vec_pretty(&document).context("encode dependency selections")?;
1176 atomic_write(&root.join(PLUGIN_ROOT).join(DEPENDENCY_SELECTIONS), &bytes)?;
1177 Ok(resolved)
1178}
1179
1180fn resolve_adopted_dependencies(
1181 host: &HostInput,
1182 current: &PluginRootSnapshot,
1183 mut selections: Vec<DependencyChoice>,
1184) -> anyhow::Result<(ResolvedApp, Vec<DependencyChoice>)> {
1185 selections.sort_by(|left, right| {
1186 left.consumer
1187 .cmp(&right.consumer)
1188 .then_with(|| left.requirement_id.cmp(&right.requirement_id))
1189 });
1190 let candidate = PluginRootSnapshot::new(
1191 current.releases().iter().cloned(),
1192 current.instances().iter().cloned(),
1193 current.disabled().iter().cloned(),
1194 )
1195 .with_dependency_choices(selections);
1196 let proposed = host.propose(&candidate).map_err(anyhow::Error::msg)?;
1197 let selections = proposed.dependency_choices().to_vec();
1198 let materialized = PluginRootSnapshot::new(
1199 current.releases().iter().cloned(),
1200 current.instances().iter().cloned(),
1201 current.disabled().iter().cloned(),
1202 )
1203 .with_dependency_choices(selections.clone());
1204 let resolved = host.resolve(&materialized).map_err(anyhow::Error::msg)?;
1205 Ok((resolved, selections))
1206}
1207
1208fn set_instance_disabled_inner(
1209 root: &Path,
1210 plugin_id: &str,
1211 instance: &str,
1212 disabled_state: bool,
1213 expected_revision: Option<&PluginRootRevision>,
1214) -> anyhow::Result<(PluginRootRevision, PluginRootRevision, ResolvedApp)> {
1215 validate_existing_plugin_id(plugin_id)?;
1216 validate_instance_filename(instance)?;
1217 let _lock = lock_plugin_root(root)?;
1218 let host = load_host_catalog(root)?;
1219 let current = snapshot_plugin_root(root, &host)?;
1220 let base_revision = configuration_authority::revision_for_snapshot(¤t)?;
1221 if let Some(expected_revision) = expected_revision {
1222 configuration_authority::ensure_revision(expected_revision, &base_revision)?;
1223 }
1224 let id = PluginInstanceId::new(plugin_id, instance);
1225 let mut disabled = current.disabled().iter().cloned().collect::<BTreeSet<_>>();
1226 if disabled_state {
1227 disabled.insert(id.clone());
1228 } else if !disabled.remove(&id) {
1229 bail!("Plugin Instance `{id}` is not disabled");
1230 }
1231 let candidate = preserve_dependency_selections(
1232 PluginRootSnapshot::new(
1233 current.releases().iter().cloned(),
1234 current.instances().iter().cloned(),
1235 disabled,
1236 ),
1237 ¤t,
1238 );
1239 let candidate_revision = configuration_authority::revision_for_snapshot(&candidate)?;
1240 let resolved = host.resolve(&candidate).map_err(anyhow::Error::msg)?;
1241 let marker = root
1242 .join(PLUGIN_ROOT)
1243 .join(plugin_id)
1244 .join(format!("{instance}.disabled"));
1245 if disabled_state {
1246 atomic_write(&marker, &[])?;
1247 } else {
1248 fs::remove_file(&marker)
1249 .with_context(|| format!("remove disabled marker {}", marker.display()))?;
1250 }
1251 Ok((base_revision, candidate_revision, resolved))
1252}
1253
1254pub fn remove_instance_difference(
1256 root: &Path,
1257 plugin_id: &str,
1258 instance: &str,
1259) -> anyhow::Result<ResolvedApp> {
1260 validate_existing_plugin_id(plugin_id)?;
1261 validate_instance_filename(instance)?;
1262 let _lock = lock_plugin_root(root)?;
1263 let host = load_host_catalog(root)?;
1264 let current = snapshot_plugin_root(root, &host)?;
1265 let id = PluginInstanceId::new(plugin_id, instance);
1266 let candidate = preserve_dependency_selections(
1267 PluginRootSnapshot::new(
1268 current.releases().iter().cloned(),
1269 current
1270 .instances()
1271 .iter()
1272 .filter(|item| item.id() != &id)
1273 .cloned(),
1274 current
1275 .disabled()
1276 .iter()
1277 .filter(|item| *item != &id)
1278 .cloned(),
1279 ),
1280 ¤t,
1281 );
1282 let resolved = host.resolve(&candidate).map_err(anyhow::Error::msg)?;
1283 let plugin_directory = root.join(PLUGIN_ROOT).join(plugin_id);
1284 remove_if_exists(&plugin_directory.join(format!("{instance}.toml")))?;
1285 remove_if_exists(&plugin_directory.join(format!("{instance}.disabled")))?;
1286 Ok(resolved)
1287}
1288
1289pub fn remove_plugin(root: &Path, plugin_id: &str) -> anyhow::Result<(ResolvedApp, PathBuf)> {
1291 validate_existing_plugin_id(plugin_id)?;
1292 let _lock = lock_plugin_root(root)?;
1293 let host = load_host_catalog(root)?;
1294 let current = snapshot_plugin_root(root, &host)?;
1295 let candidate = preserve_dependency_selections(
1296 PluginRootSnapshot::new(
1297 current
1298 .releases()
1299 .iter()
1300 .filter(|release| release.plugin_id() != plugin_id)
1301 .cloned(),
1302 current
1303 .instances()
1304 .iter()
1305 .filter(|instance| instance.id().plugin_id() != plugin_id)
1306 .cloned(),
1307 current
1308 .disabled()
1309 .iter()
1310 .filter(|instance| instance.plugin_id() != plugin_id)
1311 .cloned(),
1312 ),
1313 ¤t,
1314 );
1315 let resolved = host.resolve(&candidate).map_err(anyhow::Error::msg)?;
1316 let plugin_directory = root.join(PLUGIN_ROOT).join(plugin_id);
1317 if !plugin_directory.exists() {
1318 bail!("Plugin `{plugin_id}` has no Plugin Root directory");
1319 }
1320 let trash = root
1321 .join(".lenso/trash")
1322 .join(format!("{plugin_id}-{}", uuid::Uuid::now_v7()));
1323 fs::create_dir_all(trash.parent().expect("trash has a parent"))?;
1324 fs::rename(&plugin_directory, &trash)?;
1325 Ok((resolved, trash))
1326}
1327
1328fn atomic_write(path: &Path, bytes: &[u8]) -> anyhow::Result<()> {
1329 let parent = path.parent().context("Plugin file has no parent")?;
1330 fs::create_dir_all(parent)?;
1331 let temporary = tempfile::NamedTempFile::new_in(parent)?;
1332 fs::write(temporary.path(), bytes)?;
1333 temporary
1334 .persist(path)
1335 .map_err(|error| error.error)
1336 .with_context(|| format!("commit Plugin file {}", path.display()))?;
1337 Ok(())
1338}
1339
1340fn lock_plugin_root(root: &Path) -> anyhow::Result<fs::File> {
1341 let path = root.join(AUTHORING_LOCK);
1342 let parent = path.parent().context("Plugin Root lock has no parent")?;
1343 fs::create_dir_all(parent)?;
1344 let file = fs::OpenOptions::new()
1345 .create(true)
1346 .read(true)
1347 .write(true)
1348 .truncate(false)
1349 .open(&path)
1350 .with_context(|| format!("open Plugin Root authoring lock {}", path.display()))?;
1351 file.lock()
1352 .with_context(|| format!("lock Plugin Root authoring authority {}", path.display()))?;
1353 Ok(file)
1354}
1355fn copy_directory(source: &Path, destination: &Path) -> anyhow::Result<()> {
1356 for entry in read_entries(source)? {
1357 let file_type = entry.file_type()?;
1358 if file_type.is_dir() {
1359 let child = destination.join(entry.file_name());
1360 fs::create_dir_all(&child)?;
1361 copy_directory(&entry.path(), &child)?;
1362 continue;
1363 }
1364 if !file_type.is_file() {
1365 bail!(
1366 "Plugin Bundle contains a non-file entry: {}",
1367 entry.path().display()
1368 );
1369 }
1370 fs::copy(entry.path(), destination.join(entry.file_name()))?;
1371 }
1372 Ok(())
1373}
1374
1375fn remove_if_exists(path: &Path) -> anyhow::Result<()> {
1376 match fs::remove_file(path) {
1377 Ok(()) => Ok(()),
1378 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1379 Err(error) => Err(error).with_context(|| format!("remove {}", path.display())),
1380 }
1381}
1382
1383#[cfg(test)]
1384mod tests {
1385 use super::*;
1386 use lenso_app_plan::authoring::{
1387 HostBinding, HostCatalog, HostDefaultPlugin, HostPluginRelease, HostSlot,
1388 };
1389 use lenso_app_plan::{CapabilityEndpointPlan, CapabilityRequirementPlan};
1390
1391 fn fixture_root() -> tempfile::TempDir {
1392 let root = tempfile::tempdir().unwrap();
1393 fs::create_dir_all(root.path().join(".lenso")).unwrap();
1394 let host = HostCatalog::new(
1395 [HostSlot::one("agent")],
1396 [HostPluginRelease::new(PluginDescriptor::new(
1397 "example.agent",
1398 "1.0.0",
1399 "agent",
1400 ))],
1401 [HostDefaultPlugin::new("example.agent", "default")],
1402 );
1403 fs::write(
1404 root.path().join(HOST_CATALOG),
1405 serde_json::to_vec(&host).unwrap(),
1406 )
1407 .unwrap();
1408 root
1409 }
1410
1411 #[test]
1412 fn missing_plugin_root_resolves_the_host_default_app() {
1413 let root = fixture_root();
1414 let resolved = load_resolved_app(root.path()).unwrap();
1415
1416 assert_eq!(resolved.instances().len(), 1);
1417 assert_eq!(
1418 resolved.instances()[0].id().to_string(),
1419 "example.agent/default"
1420 );
1421 }
1422
1423 #[test]
1424 fn dependency_choice_is_materialized_and_survives_a_new_compatible_provider() {
1425 let root = tempfile::tempdir().unwrap();
1426 fs::create_dir_all(root.path().join(".lenso")).unwrap();
1427 let consumer = PluginDescriptor::new("example.copy", "1.0.0", "copy")
1428 .with_authoring(2, "lenso.test-authoring@2")
1429 .with_requirement(
1430 CapabilityRequirementPlan::one("example.store@1", "1.0.0")
1431 .with_requirement_id("source"),
1432 );
1433 let store = |plugin_id: &str| {
1434 PluginDescriptor::new(plugin_id, "1.0.0", "store").with_capability(
1435 CapabilityEndpointPlan::new("example.store@1", "1.0.0", ["get"]),
1436 )
1437 };
1438 let host = HostCatalog::new(
1439 [HostSlot::one("copy"), HostSlot::many("store")],
1440 [
1441 HostPluginRelease::new(consumer.clone()),
1442 HostPluginRelease::new(store("example.store.a")),
1443 ],
1444 [
1445 HostDefaultPlugin::new("example.copy", "default"),
1446 HostDefaultPlugin::new("example.store.a", "default"),
1447 ],
1448 )
1449 .with_bindings([HostBinding::new(
1450 PluginInstanceId::new("example.copy", "default"),
1451 "example.store@1",
1452 "store",
1453 )
1454 .with_requirement_id("source")
1455 .selectable(None)]);
1456 fs::write(
1457 root.path().join(HOST_CATALOG),
1458 serde_json::to_vec(&host).unwrap(),
1459 )
1460 .unwrap();
1461
1462 set_dependency_selection(
1463 root.path(),
1464 PluginInstanceId::new("example.copy", "default"),
1465 "source",
1466 Some(PluginInstanceId::new("example.store.a", "default")),
1467 )
1468 .unwrap();
1469 assert!(root.path().join("plugins/dependencies.json").is_file());
1470
1471 let expanded = HostCatalog::new(
1472 [HostSlot::one("copy"), HostSlot::many("store")],
1473 [
1474 HostPluginRelease::new(consumer),
1475 HostPluginRelease::new(store("example.store.a")),
1476 HostPluginRelease::new(store("example.store.b")),
1477 ],
1478 [
1479 HostDefaultPlugin::new("example.copy", "default"),
1480 HostDefaultPlugin::new("example.store.a", "default"),
1481 HostDefaultPlugin::new("example.store.b", "default"),
1482 ],
1483 )
1484 .with_bindings([HostBinding::new(
1485 PluginInstanceId::new("example.copy", "default"),
1486 "example.store@1",
1487 "store",
1488 )
1489 .with_requirement_id("source")
1490 .selectable(None)]);
1491 fs::write(
1492 root.path().join(HOST_CATALOG),
1493 serde_json::to_vec(&expanded).unwrap(),
1494 )
1495 .unwrap();
1496
1497 let resolved = load_resolved_app(root.path()).unwrap();
1498 assert_eq!(
1499 resolved.plan().capability_bindings()[0].provider_instance(),
1500 "example.store.a/default"
1501 );
1502 }
1503
1504 #[test]
1505 fn first_bind_repairs_the_requested_legacy_ambiguity_and_materializes_unique_choices() {
1506 let root = tempfile::tempdir().unwrap();
1507 fs::create_dir_all(root.path().join(".lenso")).unwrap();
1508 let consumer = PluginDescriptor::new("example.copy", "1.0.0", "copy")
1509 .with_authoring(2, "lenso.test-authoring@2")
1510 .with_requirement(
1511 CapabilityRequirementPlan::one("example.store@1", "1.0.0")
1512 .with_requirement_id("source"),
1513 )
1514 .with_requirement(
1515 CapabilityRequirementPlan::one("example.audit@1", "1.0.0")
1516 .with_requirement_id("audit"),
1517 );
1518 let store = |plugin_id: &str| {
1519 PluginDescriptor::new(plugin_id, "1.0.0", "store").with_capability(
1520 CapabilityEndpointPlan::new("example.store@1", "1.0.0", ["get"]),
1521 )
1522 };
1523 let audit = PluginDescriptor::new("example.audit", "1.0.0", "audit").with_capability(
1524 CapabilityEndpointPlan::new("example.audit@1", "1.0.0", ["record"]),
1525 );
1526 let host = HostCatalog::new(
1527 [
1528 HostSlot::one("copy"),
1529 HostSlot::many("store"),
1530 HostSlot::one("audit"),
1531 ],
1532 [
1533 HostPluginRelease::new(consumer),
1534 HostPluginRelease::new(store("example.store.a")),
1535 HostPluginRelease::new(store("example.store.b")),
1536 HostPluginRelease::new(audit),
1537 ],
1538 [
1539 HostDefaultPlugin::new("example.copy", "default"),
1540 HostDefaultPlugin::new("example.store.a", "default"),
1541 HostDefaultPlugin::new("example.store.b", "default"),
1542 HostDefaultPlugin::new("example.audit", "default"),
1543 ],
1544 )
1545 .with_bindings([HostBinding::new(
1546 PluginInstanceId::new("example.copy", "default"),
1547 "example.store@1",
1548 "store",
1549 )
1550 .with_requirement_id("source")
1551 .selectable(None)]);
1552 fs::write(
1553 root.path().join(HOST_CATALOG),
1554 serde_json::to_vec(&host).unwrap(),
1555 )
1556 .unwrap();
1557
1558 let resolved = set_dependency_selection(
1559 root.path(),
1560 PluginInstanceId::new("example.copy", "default"),
1561 "source",
1562 Some(PluginInstanceId::new("example.store.b", "default")),
1563 )
1564 .unwrap();
1565 let bindings = resolved
1566 .plan()
1567 .capability_bindings()
1568 .iter()
1569 .map(|binding| (binding.requirement_id(), binding.provider_instance()))
1570 .collect::<BTreeMap<_, _>>();
1571
1572 assert_eq!(bindings["source"], "example.store.b/default");
1573 assert_eq!(bindings["audit"], "example.audit/default");
1574 let document: DependencySelectionsDocument = serde_json::from_slice(
1575 &fs::read(root.path().join("plugins/dependencies.json")).unwrap(),
1576 )
1577 .unwrap();
1578 assert_eq!(document.selections.len(), 1);
1579 }
1580
1581 #[test]
1582 fn batch_bind_adopts_two_ambiguous_requirements_atomically() {
1583 let root = tempfile::tempdir().unwrap();
1584 fs::create_dir_all(root.path().join(".lenso")).unwrap();
1585 let consumer = PluginDescriptor::new("example.copy", "1.0.0", "copy")
1586 .with_authoring(2, "lenso.test-authoring@2")
1587 .with_requirement(
1588 CapabilityRequirementPlan::one("example.store@1", "1.0.0")
1589 .with_requirement_id("source"),
1590 )
1591 .with_requirement(
1592 CapabilityRequirementPlan::one("example.store@1", "1.0.0")
1593 .with_requirement_id("destination"),
1594 );
1595 let store = |plugin_id: &str| {
1596 PluginDescriptor::new(plugin_id, "1.0.0", "store").with_capability(
1597 CapabilityEndpointPlan::new("example.store@1", "1.0.0", ["get"]),
1598 )
1599 };
1600 let host = HostCatalog::new(
1601 [HostSlot::one("copy"), HostSlot::many("store")],
1602 [
1603 HostPluginRelease::new(consumer),
1604 HostPluginRelease::new(store("example.store.a")),
1605 HostPluginRelease::new(store("example.store.b")),
1606 ],
1607 [
1608 HostDefaultPlugin::new("example.copy", "default"),
1609 HostDefaultPlugin::new("example.store.a", "default"),
1610 HostDefaultPlugin::new("example.store.b", "default"),
1611 ],
1612 )
1613 .with_bindings([
1614 HostBinding::new(
1615 PluginInstanceId::new("example.copy", "default"),
1616 "example.store@1",
1617 "store",
1618 )
1619 .with_requirement_id("source")
1620 .selectable(None),
1621 HostBinding::new(
1622 PluginInstanceId::new("example.copy", "default"),
1623 "example.store@1",
1624 "store",
1625 )
1626 .with_requirement_id("destination")
1627 .selectable(None),
1628 ]);
1629 fs::write(
1630 root.path().join(HOST_CATALOG),
1631 serde_json::to_vec(&host).unwrap(),
1632 )
1633 .unwrap();
1634 let consumer = PluginInstanceId::new("example.copy", "default");
1635
1636 let resolved = set_dependency_selections(
1637 root.path(),
1638 [
1639 DependencyChoice {
1640 consumer: consumer.clone(),
1641 requirement_id: "source".to_owned(),
1642 provider: Some(PluginInstanceId::new("example.store.a", "default")),
1643 },
1644 DependencyChoice {
1645 consumer,
1646 requirement_id: "destination".to_owned(),
1647 provider: Some(PluginInstanceId::new("example.store.b", "default")),
1648 },
1649 ],
1650 )
1651 .unwrap();
1652 let bindings = resolved
1653 .plan()
1654 .capability_bindings()
1655 .iter()
1656 .map(|binding| (binding.requirement_id(), binding.provider_instance()))
1657 .collect::<BTreeMap<_, _>>();
1658
1659 assert_eq!(bindings["source"], "example.store.a/default");
1660 assert_eq!(bindings["destination"], "example.store.b/default");
1661 }
1662
1663 #[test]
1664 fn inspection_separates_host_defaults_from_root_differences() {
1665 let root = fixture_root();
1666 let plugin = root.path().join("plugins/example.agent");
1667 fs::create_dir_all(&plugin).unwrap();
1668 fs::write(plugin.join("default.toml"), "").unwrap();
1669
1670 let state = inspect_plugin_root(root.path()).unwrap();
1671 let plugin = state
1672 .plugins()
1673 .iter()
1674 .find(|plugin| plugin.plugin_id() == "example.agent")
1675 .unwrap();
1676 let instance = &plugin.instances()[0];
1677
1678 assert_eq!(plugin.release_version(), "1.0.0");
1679 assert!(!plugin.is_root_supplied());
1680 assert!(instance.is_enabled());
1681 assert!(instance.is_host_default());
1682 assert!(!instance.is_disableable());
1683 assert_eq!(instance.root_configuration_toml(), Some(""));
1684 assert!(instance.source_digest().as_str().starts_with("sha256:"));
1685 assert!(instance.has_root_difference());
1686 }
1687
1688 #[test]
1689 fn inspection_reports_disabled_host_default_without_losing_the_instance() {
1690 let root = tempfile::tempdir().unwrap();
1691 fs::create_dir_all(root.path().join(".lenso")).unwrap();
1692 let host = HostCatalog::new(
1693 [HostSlot::optional("optional")],
1694 [HostPluginRelease::new(PluginDescriptor::new(
1695 "example.optional",
1696 "1.0.0",
1697 "optional",
1698 ))],
1699 [HostDefaultPlugin::new("example.optional", "default").disableable()],
1700 );
1701 fs::write(
1702 root.path().join(HOST_CATALOG),
1703 serde_json::to_vec(&host).unwrap(),
1704 )
1705 .unwrap();
1706 let plugin = root.path().join("plugins/example.optional");
1707 fs::create_dir_all(&plugin).unwrap();
1708 fs::write(plugin.join("default.disabled"), "").unwrap();
1709
1710 let state = inspect_plugin_root(root.path()).unwrap();
1711 let instance = &state.plugins()[0].instances()[0];
1712
1713 assert!(!instance.is_enabled());
1714 assert!(instance.is_host_default());
1715 assert!(instance.is_disableable());
1716 assert!(instance.is_disabled_by_root());
1717 }
1718
1719 #[test]
1720 fn local_selection_authority_disables_and_enables_one_instance() {
1721 let root = tempfile::tempdir().unwrap();
1722 fs::create_dir_all(root.path().join(".lenso")).unwrap();
1723 let host = HostCatalog::new(
1724 [HostSlot::optional("optional")],
1725 [HostPluginRelease::new(PluginDescriptor::new(
1726 "example.optional",
1727 "1.0.0",
1728 "optional",
1729 ))],
1730 [HostDefaultPlugin::new("example.optional", "default").disableable()],
1731 );
1732 fs::write(
1733 root.path().join(HOST_CATALOG),
1734 serde_json::to_vec(&host).unwrap(),
1735 )
1736 .unwrap();
1737 let authority = LocalPluginRootAuthority::new(root.path());
1738 let base = inspect_plugin_root(root.path()).unwrap().revision().clone();
1739
1740 let disabled = authority
1741 .set_enabled(&base, "example.optional", "default", false)
1742 .unwrap();
1743 assert_eq!(disabled.base_revision(), &base);
1744 assert!(!disabled.enabled());
1745 assert_eq!(disabled.plugin_id(), "example.optional");
1746 assert_eq!(disabled.instance(), "default");
1747 assert!(
1748 root.path()
1749 .join("plugins/example.optional/default.disabled")
1750 .is_file()
1751 );
1752
1753 let enabled = authority
1754 .set_enabled(disabled.revision(), "example.optional", "default", true)
1755 .unwrap();
1756 assert!(enabled.enabled());
1757 assert!(
1758 !root
1759 .path()
1760 .join("plugins/example.optional/default.disabled")
1761 .exists()
1762 );
1763 }
1764
1765 #[test]
1766 fn local_selection_authority_rejects_a_stale_revision_without_mutating() {
1767 let root = tempfile::tempdir().unwrap();
1768 fs::create_dir_all(root.path().join(".lenso")).unwrap();
1769 let host = HostCatalog::new(
1770 [HostSlot::optional("optional")],
1771 [HostPluginRelease::new(PluginDescriptor::new(
1772 "example.optional",
1773 "1.0.0",
1774 "optional",
1775 ))],
1776 [HostDefaultPlugin::new("example.optional", "default").disableable()],
1777 );
1778 fs::write(
1779 root.path().join(HOST_CATALOG),
1780 serde_json::to_vec(&host).unwrap(),
1781 )
1782 .unwrap();
1783 let authority = LocalPluginRootAuthority::new(root.path());
1784 let stale = inspect_plugin_root(root.path()).unwrap().revision().clone();
1785 authority
1786 .set_enabled(&stale, "example.optional", "default", false)
1787 .unwrap();
1788
1789 let error = authority
1790 .set_enabled(&stale, "example.optional", "default", true)
1791 .unwrap_err();
1792
1793 assert!(error.downcast_ref::<PluginRootRevisionConflict>().is_some());
1794 assert!(
1795 root.path()
1796 .join("plugins/example.optional/default.disabled")
1797 .is_file()
1798 );
1799 }
1800
1801 #[test]
1802 fn macos_metadata_at_plugin_root_is_ignored() {
1803 let root = fixture_root();
1804 fs::create_dir(root.path().join("plugins")).unwrap();
1805 fs::write(root.path().join("plugins/.DS_Store"), b"Finder metadata").unwrap();
1806
1807 let resolved = load_resolved_app(root.path()).unwrap();
1808
1809 assert_eq!(resolved.instances().len(), 1);
1810 }
1811
1812 #[test]
1813 fn macos_metadata_inside_plugin_directory_is_ignored() {
1814 let root = fixture_root();
1815 let plugin = root.path().join("plugins/example.agent");
1816 fs::create_dir_all(&plugin).unwrap();
1817 fs::write(plugin.join(".DS_Store"), b"Finder metadata").unwrap();
1818
1819 let resolved = load_resolved_app(root.path()).unwrap();
1820
1821 assert_eq!(resolved.instances().len(), 1);
1822 }
1823
1824 #[test]
1825 fn accepts_a_bounded_resource_directory_paired_with_an_instance() {
1826 let root = fixture_root();
1827 let plugin = root.path().join("plugins/example.agent");
1828 fs::create_dir_all(plugin.join("default/prompts")).unwrap();
1829 fs::write(plugin.join("default.toml"), "").unwrap();
1830 fs::write(plugin.join("default/prompts/system.md"), "hello").unwrap();
1831 fs::write(plugin.join("default/prompts/.DS_Store"), "metadata").unwrap();
1832
1833 let resolved = load_resolved_app(root.path()).unwrap();
1834
1835 assert!(
1836 resolved
1837 .instances()
1838 .iter()
1839 .any(|instance| instance.id().to_string() == "example.agent/default")
1840 );
1841 }
1842
1843 #[test]
1844 fn rejects_an_orphan_resource_directory() {
1845 let root = fixture_root();
1846 let resources = root.path().join("plugins/example.agent/custom");
1847 fs::create_dir_all(&resources).unwrap();
1848 fs::write(resources.join("prompt.md"), "orphan").unwrap();
1849
1850 let error = load_resolved_app(root.path()).unwrap_err();
1851
1852 assert!(
1853 error
1854 .to_string()
1855 .contains("orphan Plugin resource directory")
1856 );
1857 }
1858
1859 #[cfg(unix)]
1860 #[test]
1861 fn rejects_a_resource_symlink() {
1862 use std::os::unix::fs::symlink;
1863
1864 let root = fixture_root();
1865 let plugin = root.path().join("plugins/example.agent");
1866 fs::create_dir_all(plugin.join("custom")).unwrap();
1867 fs::write(plugin.join("custom.toml"), "").unwrap();
1868 fs::write(root.path().join("secret"), "not admitted").unwrap();
1869 symlink(root.path().join("secret"), plugin.join("custom/secret")).unwrap();
1870
1871 let error = load_resolved_app(root.path()).unwrap_err();
1872
1873 assert!(error.to_string().contains("cannot contain symlinks"));
1874 }
1875
1876 #[test]
1877 fn failed_configuration_candidate_does_not_write_the_plugin_root() {
1878 let root = fixture_root();
1879
1880 let error = configure_instance(
1881 root.path(),
1882 "example.agent",
1883 "default",
1884 b"unexpected = true\n",
1885 )
1886 .unwrap_err();
1887
1888 assert!(error.to_string().contains("non-empty configuration"));
1889 assert!(
1890 !root
1891 .path()
1892 .join("plugins/example.agent/default.toml")
1893 .exists()
1894 );
1895 }
1896
1897 #[test]
1898 fn required_default_disable_fails_before_writing_a_marker() {
1899 let root = fixture_root();
1900
1901 let error =
1902 set_instance_disabled(root.path(), "example.agent", "default", true).unwrap_err();
1903
1904 assert!(error.to_string().contains("cannot be disabled"));
1905 assert!(
1906 !root
1907 .path()
1908 .join("plugins/example.agent/default.disabled")
1909 .exists()
1910 );
1911 }
1912
1913 #[test]
1914 fn case_colliding_plugin_identities_fail_closed() {
1915 let mut normalized = BTreeMap::new();
1916 reject_case_collision(&mut normalized, "Example.Agent", "Plugin ID").unwrap();
1917 let error =
1918 reject_case_collision(&mut normalized, "example.agent", "Plugin ID").unwrap_err();
1919
1920 assert!(error.to_string().contains("case-colliding Plugin IDs"));
1921 }
1922
1923 #[test]
1924 fn add_replace_and_restore_publish_failures_leave_visible_bytes_unchanged() {
1925 for mutation in [
1926 BundleMutation::Add,
1927 BundleMutation::Replace,
1928 BundleMutation::Restore,
1929 ] {
1930 let root = tempfile::tempdir().unwrap();
1931 let destination = root
1932 .path()
1933 .join("plugins/example.agent/plugin.lenso-plugin");
1934 if mutation == BundleMutation::Add {
1935 fs::create_dir(root.path().join("plugins")).unwrap();
1936 } else {
1937 fs::create_dir_all(&destination).unwrap();
1938 fs::write(destination.join("marker"), "old").unwrap();
1939 }
1940 let staging = tempfile::tempdir_in(root.path()).unwrap();
1941 fs::write(staging.path().join("marker"), "new").unwrap();
1942
1943 let error = commit_staged_bundle_with(
1944 &destination,
1945 mutation,
1946 staging,
1947 |_, _, _| {
1948 Err(std::io::Error::new(
1949 std::io::ErrorKind::PermissionDenied,
1950 "injected publish failure",
1951 ))
1952 },
1953 |_| panic!("retirement cannot run before publication succeeds"),
1954 )
1955 .unwrap_err();
1956
1957 assert!(error.to_string().contains("Plugin Bundle"));
1958 if mutation == BundleMutation::Add {
1959 assert!(!destination.exists());
1960 assert!(!destination.parent().unwrap().exists());
1961 } else {
1962 assert_eq!(
1963 fs::read_to_string(destination.join("marker")).unwrap(),
1964 "old"
1965 );
1966 }
1967 }
1968 }
1969
1970 #[test]
1971 fn portable_bundle_add_publishes_with_one_atomic_rename() {
1972 let root = tempfile::tempdir().unwrap();
1973 let destination = root
1974 .path()
1975 .join("plugins/example.agent/plugin.lenso-plugin");
1976 let staging = tempfile::tempdir_in(root.path()).unwrap();
1977 fs::write(staging.path().join("marker"), "new").unwrap();
1978
1979 commit_staged_bundle(&destination, BundleMutation::Add, staging).unwrap();
1980
1981 assert_eq!(
1982 fs::read_to_string(destination.join("marker")).unwrap(),
1983 "new"
1984 );
1985 }
1986
1987 #[cfg(any(target_os = "linux", target_vendor = "apple", windows))]
1988 #[test]
1989 fn portable_bundle_add_never_replaces_a_concurrent_destination() {
1990 let root = tempfile::tempdir().unwrap();
1991 let destination = root.path().join("destination");
1992 fs::create_dir(&destination).unwrap();
1993 fs::write(destination.join("marker"), "old").unwrap();
1994 let staging = tempfile::tempdir_in(root.path()).unwrap();
1995 fs::write(staging.path().join("marker"), "new").unwrap();
1996
1997 atomic_publish_bundle(staging.path(), &destination, BundleMutation::Add).unwrap_err();
1998
1999 assert_eq!(
2000 fs::read_to_string(destination.join("marker")).unwrap(),
2001 "old"
2002 );
2003 assert_eq!(
2004 fs::read_to_string(staging.path().join("marker")).unwrap(),
2005 "new"
2006 );
2007 }
2008
2009 #[cfg(not(any(target_os = "linux", target_vendor = "apple")))]
2010 #[test]
2011 fn portable_bundle_replace_fails_closed_when_exchange_is_unavailable() {
2012 let root = tempfile::tempdir().unwrap();
2013 let destination = root
2014 .path()
2015 .join("plugins/example.agent/plugin.lenso-plugin");
2016 fs::create_dir_all(&destination).unwrap();
2017 fs::write(destination.join("marker"), "old").unwrap();
2018 let staging = tempfile::tempdir_in(root.path()).unwrap();
2019 fs::write(staging.path().join("marker"), "new").unwrap();
2020
2021 let error =
2022 commit_staged_bundle(&destination, BundleMutation::Replace, staging).unwrap_err();
2023
2024 assert_eq!(
2025 error
2026 .root_cause()
2027 .downcast_ref::<std::io::Error>()
2028 .unwrap()
2029 .kind(),
2030 std::io::ErrorKind::Unsupported
2031 );
2032 assert_eq!(
2033 fs::read_to_string(destination.join("marker")).unwrap(),
2034 "old"
2035 );
2036 }
2037
2038 #[cfg(any(target_os = "linux", target_vendor = "apple"))]
2039 #[test]
2040 fn replace_and_restore_commit_atomically_even_when_retirement_cleanup_fails() {
2041 for mutation in [BundleMutation::Replace, BundleMutation::Restore] {
2042 let root = tempfile::tempdir().unwrap();
2043 let destination = root
2044 .path()
2045 .join("plugins/example.agent/plugin.lenso-plugin");
2046 fs::create_dir_all(&destination).unwrap();
2047 fs::write(destination.join("marker"), "old").unwrap();
2048 let staging = tempfile::tempdir_in(root.path()).unwrap();
2049 fs::write(staging.path().join("marker"), "new").unwrap();
2050 let mut retired = None;
2051
2052 commit_staged_bundle_with(
2053 &destination,
2054 mutation,
2055 staging,
2056 atomic_publish_bundle,
2057 |staging| {
2058 retired = Some(staging.keep());
2059 Err(std::io::Error::other("injected cleanup failure"))
2060 },
2061 )
2062 .unwrap();
2063
2064 assert_eq!(
2065 fs::read_to_string(destination.join("marker")).unwrap(),
2066 "new"
2067 );
2068 let retired = retired.unwrap();
2069 assert_eq!(fs::read_to_string(retired.join("marker")).unwrap(), "old");
2070 fs::remove_dir_all(retired).unwrap();
2071 }
2072 }
2073}