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