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