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