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