1use crate::{
2 AdvanceModuleOperation, ApplicationModuleLock, CargoLockResolutionRequest,
3 DesiredModuleComposition, ExpectedLinkedPackage, LINKED_COMPOSITION_SEAM_PROTOCOL,
4 LinkedCompositionSeam, ManagedFileType, ModuleEffectOutcome, ModuleEffectReceipt,
5 ModuleFileChange, ModuleFileOwnership, ModuleManagementEngine, ModuleManagementError,
6 ModuleOperation, ModuleOperationError, ModuleOperationState, ModuleOperationStore,
7 ModuleOperationStoreError, ModulePathPrecondition, ModulePlanEffect, ModuleWorkspaceBackup,
8 PathExistence, application_module_lock_digest, validate_application_module_lock,
9 validate_change_plan, validate_desired_composition,
10};
11use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
12use chrono::{DateTime, Utc};
13use lenso_contracts::{ArtifactReference, ModuleDelivery, digest_json};
14use sha2::{Digest as _, Sha256};
15use std::collections::{BTreeMap, BTreeSet};
16use std::fs::{self, File, OpenOptions};
17use std::io::Write as _;
18use std::path::{Component, Path, PathBuf};
19use thiserror::Error;
20
21const GENERATED_MARKER: &str = "generated by lenso-module-management";
22
23#[derive(Debug, Error)]
24pub enum LinkedWorkspaceError {
25 #[error("Linked composition contract is invalid: {0}")]
26 InvalidContract(String),
27 #[error("workspace plan is stale at `{0}`")]
28 Stale(String),
29 #[error("workspace path is unsafe: `{0}`")]
30 UnsafePath(String),
31 #[error("workspace I/O failed: {0}")]
32 Io(#[from] std::io::Error),
33 #[error("workspace JSON failed: {0}")]
34 Json(#[from] serde_json::Error),
35 #[error(transparent)]
36 Management(#[from] ModuleManagementError),
37 #[error(transparent)]
38 Store(#[from] ModuleOperationStoreError),
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct LinkedWorkspacePlan {
43 pub read_set: Vec<ModulePathPrecondition>,
44 pub effects: Vec<ModulePlanEffect>,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct LinkedCargoPreparation {
49 pub workspace_plan_without_lock: LinkedWorkspacePlan,
50 pub cargo_request: CargoLockResolutionRequest,
51}
52
53#[derive(Debug, Clone)]
54pub struct LinkedWorkspacePlanner {
55 root: PathBuf,
56 seam_path: PathBuf,
57}
58
59impl LinkedWorkspacePlanner {
60 pub fn new(root: impl Into<PathBuf>) -> Self {
61 Self {
62 root: root.into(),
63 seam_path: PathBuf::from(".lenso/linked-composition-seam.json"),
64 }
65 }
66
67 #[must_use]
68 pub fn with_seam_path(mut self, path: impl Into<PathBuf>) -> Self {
69 self.seam_path = path.into();
70 self
71 }
72
73 pub fn plan(
74 &self,
75 desired: &DesiredModuleComposition,
76 module_lock: &ApplicationModuleLock,
77 reviewed_desired_document: &str,
78 candidate_cargo_lock: Option<&str>,
79 ) -> Result<LinkedWorkspacePlan, LinkedWorkspaceError> {
80 validate_desired_composition(desired)?;
81 validate_application_module_lock(module_lock)?;
82 let reviewed: DesiredModuleComposition = serde_json::from_str(reviewed_desired_document)?;
83 if &reviewed != desired {
84 return invalid("reviewed Desired Composition bytes do not match the target contract");
85 }
86 let seam_relative = normalized_relative(&self.seam_path)?;
87 let seam_snapshot = snapshot_path(&self.root, &seam_relative)?;
88 let seam_bytes = seam_snapshot.bytes.as_deref().ok_or_else(|| {
89 LinkedWorkspaceError::InvalidContract("stable Host seam is missing".into())
90 })?;
91 let seam: LinkedCompositionSeam = serde_json::from_slice(seam_bytes)?;
92 validate_seam(&self.root, &seam)?;
93
94 let lock_digest = application_module_lock_digest(module_lock)?;
95 let generated = generate_composition_files(desired, module_lock, &seam, &lock_digest)?;
96 let mut targets = vec![
97 PlannedFile::user("lenso.modules.json", reviewed_desired_document.as_bytes()),
98 PlannedFile::generated_json(
99 "lenso.modules.lock.json",
100 format!("{}\n", serde_json::to_string_pretty(module_lock)?).as_bytes(),
101 ),
102 ];
103 targets.extend(generated);
104 if let Some(candidate) = candidate_cargo_lock {
105 if candidate.trim().is_empty() {
106 return invalid("candidate Cargo.lock must not be empty");
107 }
108 targets.push(PlannedFile::user("Cargo.lock", candidate.as_bytes()));
109 }
110 targets.sort_by(|left, right| left.path.cmp(&right.path));
111 if !targets.windows(2).all(|pair| pair[0].path < pair[1].path) {
112 return invalid("Linked composition target paths collide");
113 }
114
115 let mut read_paths = BTreeSet::from([
116 seam_relative,
117 seam.host_manifest_path.clone(),
118 seam.host_source_path.clone(),
119 ]);
120 read_paths.extend(targets.iter().map(|target| target.path.clone()));
121 let snapshots = read_paths
122 .iter()
123 .map(|path| snapshot_path(&self.root, path).map(|snapshot| (path.clone(), snapshot)))
124 .collect::<Result<BTreeMap<_, _>, _>>()?;
125 let read_set = snapshots
126 .values()
127 .map(|snapshot| snapshot.precondition.clone())
128 .collect::<Vec<_>>();
129 let effects = targets
130 .into_iter()
131 .filter_map(|target| {
132 let before = snapshots
133 .get(&target.path)
134 .expect("target path was added to read set");
135 let after_digest = raw_digest(&target.content);
136 if before.precondition.content_digest.as_deref() == Some(after_digest.as_str()) {
137 return None;
138 }
139 let change = if before.precondition.existence == PathExistence::Absent {
140 ModuleFileChange::Create
141 } else {
142 ModuleFileChange::Modify
143 };
144 let before_display = before.bytes.as_deref().map(String::from_utf8_lossy);
145 Some(ModulePlanEffect::WorkspaceFile {
146 effect_id: format!("10-workspace:{}", target.path),
147 path: target.path.clone(),
148 ownership: target.ownership,
149 change,
150 before_digest: before.precondition.content_digest.clone(),
151 after_digest: Some(after_digest),
152 after_content: Some(
153 String::from_utf8(target.content)
154 .expect("managed Linked composition files are UTF-8"),
155 ),
156 after_mode: Some(0o644),
157 patch: exact_replacement_patch(
158 &target.path,
159 before_display.as_deref(),
160 &target.display,
161 ),
162 reversible_before_migration: true,
163 })
164 })
165 .collect::<Vec<_>>();
166 Ok(LinkedWorkspacePlan { read_set, effects })
167 }
168
169 pub fn prepare_cargo_resolution(
170 &self,
171 desired: &DesiredModuleComposition,
172 current_lock: Option<&ApplicationModuleLock>,
173 target_lock: &ApplicationModuleLock,
174 reviewed_desired_document: &str,
175 offline: bool,
176 ) -> Result<LinkedCargoPreparation, LinkedWorkspaceError> {
177 let workspace_plan_without_lock =
178 self.plan(desired, target_lock, reviewed_desired_document, None)?;
179 let seam_relative = normalized_relative(&self.seam_path)?;
180 let seam: LinkedCompositionSeam = serde_json::from_slice(
181 snapshot_path(&self.root, &seam_relative)?
182 .bytes
183 .as_deref()
184 .ok_or_else(|| {
185 LinkedWorkspaceError::InvalidContract("stable Host seam is missing".into())
186 })?,
187 )?;
188 let root_manifest_path = seam.host_manifest_path.clone();
189 let manifest_parent = Path::new(&root_manifest_path)
190 .parent()
191 .filter(|path| !path.as_os_str().is_empty())
192 .unwrap_or_else(|| Path::new(""));
193 let lock_path = normalized_relative(&manifest_parent.join("Cargo.lock"))?;
194 let mut read_set = BTreeMap::new();
195 for path in [&root_manifest_path, &lock_path] {
196 let snapshot = snapshot_path(&self.root, path)?;
197 let bytes = snapshot.bytes.ok_or_else(|| {
198 LinkedWorkspaceError::InvalidContract(format!(
199 "isolated Cargo input `{path}` is missing"
200 ))
201 })?;
202 read_set.insert(path.clone(), bytes);
203 }
204 let generated_prefix = format!("{}/", seam.generated_crate_path.trim_end_matches('/'));
205 let candidate_files = workspace_plan_without_lock
206 .effects
207 .iter()
208 .filter_map(|effect| match effect {
209 ModulePlanEffect::WorkspaceFile {
210 path,
211 after_content: Some(content),
212 ..
213 } if path.starts_with(&generated_prefix) => {
214 Some((path.clone(), content.as_bytes().to_vec()))
215 }
216 _ => None,
217 })
218 .collect::<BTreeMap<_, _>>();
219 let current_linked_packages = current_lock
220 .map(expected_linked_packages)
221 .unwrap_or_default();
222 let expected_linked_packages = expected_linked_packages(target_lock);
223 let mut allowed_root_packages = current_linked_packages
224 .iter()
225 .chain(&expected_linked_packages)
226 .map(|package| package.package.clone())
227 .collect::<BTreeSet<_>>();
228 allowed_root_packages.insert("lenso-linked-composition".to_owned());
229
230 Ok(LinkedCargoPreparation {
231 workspace_plan_without_lock,
232 cargo_request: CargoLockResolutionRequest {
233 read_set,
234 candidate_files,
235 root_manifest_path,
236 lock_path,
237 allowed_root_packages: allowed_root_packages.into_iter().collect(),
238 current_linked_packages,
239 expected_linked_packages,
240 offline,
241 },
242 })
243 }
244}
245
246fn expected_linked_packages(module_lock: &ApplicationModuleLock) -> Vec<ExpectedLinkedPackage> {
247 let mut packages = module_lock
248 .modules
249 .iter()
250 .filter_map(|module| {
251 let ModuleDelivery::Linked(delivery) = &module.delivery else {
252 return None;
253 };
254 Some(ExpectedLinkedPackage {
255 package: delivery.package.clone(),
256 version: delivery.crate_version.clone(),
257 archive_checksum: module
258 .local_override_digest
259 .is_none()
260 .then(|| delivery.archive_checksum.clone()),
261 default_features: delivery.default_features,
262 features: module.crate_features.clone(),
263 })
264 })
265 .collect::<Vec<_>>();
266 packages.sort_by(|left, right| left.package.cmp(&right.package));
267 packages
268}
269
270#[derive(Debug, Clone)]
271pub struct LinkedWorkspaceTransaction {
272 root: PathBuf,
273}
274
275impl LinkedWorkspaceTransaction {
276 pub fn new(root: impl Into<PathBuf>) -> Self {
277 Self { root: root.into() }
278 }
279
280 #[allow(clippy::too_many_arguments)]
281 pub fn apply<S: ModuleOperationStore>(
282 &self,
283 engine: &ModuleManagementEngine<S>,
284 operation_id: &str,
285 plan: &crate::ModuleChangePlan,
286 fencing_token: u64,
287 actor_id: &str,
288 now: DateTime<Utc>,
289 ) -> Result<ModuleOperation, LinkedWorkspaceError> {
290 validate_change_plan(plan)?;
291 let mut operation = engine.store().load(operation_id)?;
292 if operation.plan_digest != plan.plan_digest {
293 return invalid("operation does not bind the supplied Linked workspace plan");
294 }
295 if operation.fencing_token != fencing_token {
296 return Err(LinkedWorkspaceError::Management(
297 ModuleManagementError::StaleFencingToken,
298 ));
299 }
300 if operation.state == ModuleOperationState::FilesApplied {
301 return Ok(operation);
302 }
303 let effects = workspace_effects(plan);
304 if effects.is_empty() {
305 return invalid("Linked workspace plan contains no file effects");
306 }
307 if operation.state == ModuleOperationState::Ready {
308 verify_read_set(&self.root, plan, &effects, false)?;
309 let backups = collect_backups(&self.root, &effects)?;
310 operation = engine.begin_workspace_application(
311 operation_id,
312 operation.revision,
313 fencing_token,
314 actor_id,
315 plan,
316 backups,
317 Vec::new(),
318 now,
319 )?;
320 } else if operation.state == ModuleOperationState::ApplyingFiles {
321 if operation.workspace_backups.is_empty() {
322 return invalid("crash continuation has no journaled workspace backups");
323 }
324 verify_read_set(&self.root, plan, &effects, true)?;
325 } else {
326 return Err(LinkedWorkspaceError::Management(
327 ModuleManagementError::IllegalTransition {
328 from: operation.state,
329 to: ModuleOperationState::ApplyingFiles,
330 },
331 ));
332 }
333
334 let result = self.apply_effects(engine, operation, effects, actor_id, now);
335 match result {
336 Ok(operation) => Ok(operation),
337 Err(error) => {
338 let current = engine.store().load(operation_id)?;
339 let restore = restore_backups(&self.root, ¤t.workspace_backups);
340 let (state, code, message) = match restore {
341 Ok(()) => (
342 ModuleOperationState::Restored,
343 "workspace_apply_failed_restored",
344 error.to_string(),
345 ),
346 Err(restore_error) => (
347 ModuleOperationState::RepairRequired,
348 "workspace_restore_failed",
349 format!("{error}; restore failed: {restore_error}"),
350 ),
351 };
352 engine.advance(AdvanceModuleOperation {
353 operation_id: current.operation_id,
354 expected_revision: current.revision,
355 fencing_token,
356 next_state: state,
357 actor_id: actor_id.to_owned(),
358 outcome_code: code.to_owned(),
359 evidence_references: Vec::new(),
360 error: Some(ModuleOperationError {
361 code: code.to_owned(),
362 message,
363 evidence_references: Vec::new(),
364 recorded_at: now,
365 }),
366 next_actions: vec![if state == ModuleOperationState::Restored {
367 "replan_from_restored_workspace".to_owned()
368 } else {
369 "create_workspace_repair_plan".to_owned()
370 }],
371 now,
372 })?;
373 Err(error)
374 }
375 }
376 }
377
378 pub fn resume_evidence(
379 &self,
380 operation: &ModuleOperation,
381 plan: &crate::ModuleChangePlan,
382 observed_at: DateTime<Utc>,
383 ) -> Result<crate::ModuleResumeEvidence, LinkedWorkspaceError> {
384 validate_change_plan(plan)?;
385 if operation.state != ModuleOperationState::ApplyingFiles
386 || operation.plan_digest != plan.plan_digest
387 {
388 return invalid("only a bound in-progress workspace transaction can resume");
389 }
390 let completed_effect_ids = operation
391 .effect_receipts
392 .iter()
393 .map(|receipt| receipt.effect_id.clone())
394 .collect::<Vec<_>>();
395 let completed = completed_effect_ids
396 .iter()
397 .map(String::as_str)
398 .collect::<BTreeSet<_>>();
399 let next = workspace_effects(plan)
400 .into_iter()
401 .find(|effect| !completed.contains(effect.effect_id()))
402 .ok_or_else(|| {
403 LinkedWorkspaceError::InvalidContract(
404 "workspace transaction has no incomplete effect".to_owned(),
405 )
406 })?;
407 let (path, _, _, _, _) = workspace_effect_parts(next)?;
408 let snapshot = snapshot_path(&self.root, path)?;
409 Ok(crate::ModuleResumeEvidence {
410 plan_digest: plan.plan_digest.clone(),
411 observed_target_digest: raw_digest(snapshot.bytes.as_deref().unwrap_or_default()),
412 completed_effect_ids,
413 next_effect_id: next.effect_id().to_owned(),
414 next_effect_idempotent: true,
415 observed_at,
416 })
417 }
418
419 fn apply_effects<S: ModuleOperationStore>(
420 &self,
421 engine: &ModuleManagementEngine<S>,
422 mut operation: ModuleOperation,
423 effects: Vec<&ModulePlanEffect>,
424 actor_id: &str,
425 now: DateTime<Utc>,
426 ) -> Result<ModuleOperation, LinkedWorkspaceError> {
427 for effect in effects {
428 let effect_id = effect.effect_id();
429 if operation
430 .effect_receipts
431 .iter()
432 .any(|receipt| receipt.effect_id == effect_id)
433 {
434 continue;
435 }
436 let (path, change, after_digest, after_content, after_mode) =
437 workspace_effect_parts(effect)?;
438 let snapshot = snapshot_path(&self.root, path)?;
439 let outcome = if matches_after(&snapshot.precondition, change, after_digest) {
440 ModuleEffectOutcome::AlreadyApplied
441 } else {
442 apply_file(
443 &self.root,
444 path,
445 change,
446 after_content,
447 after_mode,
448 &operation,
449 effect_id,
450 )?;
451 ModuleEffectOutcome::Applied
452 };
453 let effect_digest = digest_json(effect)?;
454 operation = engine.record_effect_receipt(
455 &operation.operation_id,
456 operation.revision,
457 operation.fencing_token,
458 actor_id,
459 ModuleEffectReceipt {
460 receipt_id: format!("{}:{}", operation.operation_id, effect_id),
461 effect_id: effect_id.to_owned(),
462 effect_digest,
463 operation_id: operation.operation_id.clone(),
464 attempt: operation.attempt,
465 fencing_token: operation.fencing_token,
466 outcome,
467 evidence_references: after_digest
468 .map(|digest| ArtifactReference {
469 locator: path.to_owned(),
470 digest: digest.to_owned(),
471 })
472 .into_iter()
473 .collect(),
474 committed_at: now,
475 },
476 now,
477 )?;
478 }
479 Ok(engine.advance(AdvanceModuleOperation {
480 operation_id: operation.operation_id,
481 expected_revision: operation.revision,
482 fencing_token: operation.fencing_token,
483 next_state: ModuleOperationState::FilesApplied,
484 actor_id: actor_id.to_owned(),
485 outcome_code: "linked_workspace_files_applied".to_owned(),
486 evidence_references: Vec::new(),
487 error: None,
488 next_actions: vec!["run_reviewed_locked_validation".to_owned()],
489 now,
490 })?)
491 }
492}
493
494#[derive(Debug)]
495struct PlannedFile {
496 path: String,
497 ownership: ModuleFileOwnership,
498 content: Vec<u8>,
499 display: String,
500}
501
502impl PlannedFile {
503 fn user(path: &str, content: &[u8]) -> Self {
504 Self::new(path, ModuleFileOwnership::User, content)
505 }
506
507 fn generated_json(path: &str, content: &[u8]) -> Self {
508 Self::new(path, ModuleFileOwnership::Generated, content)
509 }
510
511 fn generated(path: &str, content: &str) -> Self {
512 Self::new(path, ModuleFileOwnership::Generated, content.as_bytes())
513 }
514
515 fn new(path: &str, ownership: ModuleFileOwnership, content: &[u8]) -> Self {
516 Self {
517 path: path.to_owned(),
518 ownership,
519 content: content.to_vec(),
520 display: String::from_utf8_lossy(content).into_owned(),
521 }
522 }
523}
524
525#[derive(Debug)]
526struct PathSnapshot {
527 precondition: ModulePathPrecondition,
528 bytes: Option<Vec<u8>>,
529}
530
531fn generate_composition_files(
532 desired: &DesiredModuleComposition,
533 module_lock: &ApplicationModuleLock,
534 seam: &LinkedCompositionSeam,
535 lock_digest: &str,
536) -> Result<Vec<PlannedFile>, LinkedWorkspaceError> {
537 let overrides = desired
538 .local_overrides
539 .iter()
540 .map(|entry| (entry.module_id.as_str(), entry.path.as_str()))
541 .collect::<BTreeMap<_, _>>();
542 let mut aliases = BTreeSet::new();
543 let mut dependencies = Vec::new();
544 let modules = topological_linked_modules(module_lock)?;
545 let mut bindings = Vec::new();
546 for module in modules {
547 let ModuleDelivery::Linked(delivery) = &module.delivery else {
548 continue;
549 };
550 let alias = dependency_alias(&module.module_id);
551 if !aliases.insert(alias.clone()) {
552 return invalid("deterministic Linked dependency aliases collide");
553 }
554 if !valid_package_name(&delivery.package) || !valid_binding_path(&delivery.binding) {
555 return invalid("Linked package or binding export is not safe to generate");
556 }
557 if !module
558 .crate_features
559 .windows(2)
560 .all(|pair| pair[0] < pair[1])
561 {
562 return invalid("Linked crate features must be sorted and unique");
563 }
564 let features = module
565 .crate_features
566 .iter()
567 .map(|feature| format!("\"{}\"", escape_toml(feature)))
568 .collect::<Vec<_>>()
569 .join(", ");
570 let coordinate = overrides.get(module.module_id.as_str()).map_or_else(
571 || format!("version = \"={}\"", delivery.crate_version),
572 |path| format!("path = \"{}\"", escape_toml(path)),
573 );
574 dependencies.push(format!(
575 "{alias} = {{ package = \"{}\", {coordinate}, default-features = {}, features = [{features}] }}",
576 escape_toml(&delivery.package),
577 delivery.default_features,
578 ));
579 bindings.push(format!(" {alias}::{}(),", delivery.binding));
580 }
581 let cargo = format!(
582 "# {GENERATED_MARKER}; source-lock: {lock_digest}\n[package]\nname = \"lenso-linked-composition\"\nversion = \"0.0.0\"\nedition = \"2024\"\npublish = false\n\n[dependencies]\nlenso = {{ version = \"={}\", features = [\"host\"] }}\n{}\n",
583 seam.lenso_version,
584 dependencies.join("\n")
585 );
586 let source = format!(
587 "// {GENERATED_MARKER}; source-lock: {lock_digest}\n\npub const SOURCE_APPLICATION_LOCK_DIGEST: &str = \"{lock_digest}\";\n\n#[must_use]\npub fn linked_modules() -> Vec<lenso::host::HostLinkedModule> {{\n vec![\n{}\n ]\n}}\n",
588 bindings.join("\n")
589 );
590 let root = seam.generated_crate_path.trim_end_matches('/');
591 Ok(vec![
592 PlannedFile::generated(&format!("{root}/Cargo.toml"), &cargo),
593 PlannedFile::generated(&format!("{root}/src/lib.rs"), &source),
594 ])
595}
596
597fn topological_linked_modules(
598 module_lock: &ApplicationModuleLock,
599) -> Result<Vec<&crate::LockedModule>, LinkedWorkspaceError> {
600 let by_id = module_lock
601 .modules
602 .iter()
603 .map(|module| (module.module_id.as_str(), module))
604 .collect::<BTreeMap<_, _>>();
605 let mut remaining = by_id.keys().copied().collect::<BTreeSet<_>>();
606 let mut emitted = BTreeSet::new();
607 let mut ordered = Vec::new();
608 while !remaining.is_empty() {
609 let ready = remaining
610 .iter()
611 .copied()
612 .filter(|module_id| {
613 by_id[module_id]
614 .dependency_module_ids
615 .iter()
616 .all(|dependency| emitted.contains(dependency.as_str()))
617 })
618 .collect::<Vec<_>>();
619 if ready.is_empty() {
620 return invalid("Application Module Lock contains a dependency cycle");
621 }
622 for module_id in ready {
623 remaining.remove(module_id);
624 emitted.insert(module_id);
625 ordered.push(by_id[module_id]);
626 }
627 }
628 Ok(ordered)
629}
630
631fn validate_seam(root: &Path, seam: &LinkedCompositionSeam) -> Result<(), LinkedWorkspaceError> {
632 if seam.protocol != LINKED_COMPOSITION_SEAM_PROTOCOL
633 || semver::Version::parse(&seam.lenso_version).is_err()
634 || !valid_package_name(&seam.dependency_name)
635 {
636 return invalid("unsupported or malformed Linked composition seam");
637 }
638 for path in [
639 &seam.host_manifest_path,
640 &seam.host_source_path,
641 &seam.generated_crate_path,
642 ] {
643 normalized_relative(Path::new(path))?;
644 }
645 let manifest = read_required_utf8(root, &seam.host_manifest_path)?;
646 let dependency_path = format!("path = \"{}\"", seam.generated_crate_path);
647 if !manifest.contains(&seam.dependency_name) || !manifest.contains(&dependency_path) {
648 return invalid(
649 "Host manifest does not contain the fixed generated composition dependency",
650 );
651 }
652 let source = read_required_utf8(root, &seam.host_source_path)?;
653 let rust_name = seam.dependency_name.replace('-', "_");
654 let call = format!(".linked_modules({rust_name}::linked_modules())");
655 if !source.contains(&call) {
656 return invalid("Host source does not call the fixed generated composition seam");
657 }
658 Ok(())
659}
660
661fn verify_read_set(
662 root: &Path,
663 plan: &crate::ModuleChangePlan,
664 effects: &[&ModulePlanEffect],
665 allow_applied_targets: bool,
666) -> Result<(), LinkedWorkspaceError> {
667 for expected in &plan.read_set {
668 let snapshot = snapshot_path(root, &expected.path)?;
669 let observed = &snapshot.precondition;
670 if observed == expected {
671 if let Some(effect) = effects.iter().find(|effect| {
672 matches!(effect, ModulePlanEffect::WorkspaceFile { path, .. } if path == &expected.path)
673 }) {
674 verify_generated_ownership(plan, effect, snapshot.bytes.as_deref())?;
675 }
676 continue;
677 }
678 let applied = allow_applied_targets
679 && effects.iter().any(|effect| {
680 workspace_effect_parts(effect).is_ok_and(|(path, change, after, _, _)| {
681 path == expected.path && matches_after(observed, change, after)
682 })
683 });
684 if !applied {
685 return Err(LinkedWorkspaceError::Stale(expected.path.clone()));
686 }
687 }
688 Ok(())
689}
690
691fn verify_generated_ownership(
692 plan: &crate::ModuleChangePlan,
693 effect: &ModulePlanEffect,
694 current_bytes: Option<&[u8]>,
695) -> Result<(), LinkedWorkspaceError> {
696 let ModulePlanEffect::WorkspaceFile {
697 path,
698 ownership,
699 change,
700 ..
701 } = effect
702 else {
703 return Ok(());
704 };
705 if *ownership != ModuleFileOwnership::Generated || *change == ModuleFileChange::Create {
706 return Ok(());
707 }
708 let bytes = current_bytes.ok_or_else(|| LinkedWorkspaceError::Stale(path.clone()))?;
709 let recognized = if path == "lenso.modules.lock.json" {
710 serde_json::from_slice::<ApplicationModuleLock>(bytes)
711 .ok()
712 .and_then(|module_lock| application_module_lock_digest(&module_lock).ok())
713 .as_deref()
714 == plan.current_lock_digest.as_deref()
715 } else {
716 let content = String::from_utf8_lossy(bytes);
717 content.contains(GENERATED_MARKER)
718 && plan
719 .current_lock_digest
720 .as_deref()
721 .is_some_and(|digest| content.contains(digest))
722 };
723 if recognized {
724 Ok(())
725 } else {
726 Err(LinkedWorkspaceError::InvalidContract(format!(
727 "generated file `{path}` has no matching ownership marker and source lock"
728 )))
729 }
730}
731
732fn collect_backups(
733 root: &Path,
734 effects: &[&ModulePlanEffect],
735) -> Result<Vec<ModuleWorkspaceBackup>, LinkedWorkspaceError> {
736 let mut paths = BTreeSet::new();
737 for effect in effects {
738 let (path, _, _, _, _) = workspace_effect_parts(effect)?;
739 let mut current = Path::new(path);
740 loop {
741 paths.insert(normalized_relative(current)?);
742 let Some(parent) = current.parent() else {
743 break;
744 };
745 if parent.as_os_str().is_empty() {
746 break;
747 }
748 current = parent;
749 }
750 }
751 paths
752 .into_iter()
753 .map(|path| {
754 let snapshot = snapshot_path(root, &path)?;
755 Ok(ModuleWorkspaceBackup {
756 path,
757 existence: snapshot.precondition.existence,
758 file_type: snapshot.precondition.file_type,
759 content_base64: snapshot.bytes.as_ref().map(|bytes| BASE64.encode(bytes)),
760 content_digest: snapshot.precondition.content_digest,
761 mode: snapshot.precondition.mode,
762 })
763 })
764 .collect()
765}
766
767fn restore_backups(
768 root: &Path,
769 backups: &[ModuleWorkspaceBackup],
770) -> Result<(), LinkedWorkspaceError> {
771 for backup in backups.iter().rev() {
772 let path = guarded_path(root, &backup.path)?;
773 match (backup.existence, backup.file_type) {
774 (PathExistence::Present, ManagedFileType::Regular) => {
775 let bytes = BASE64
776 .decode(backup.content_base64.as_deref().ok_or_else(|| {
777 LinkedWorkspaceError::InvalidContract("file backup has no bytes".into())
778 })?)
779 .map_err(|error| LinkedWorkspaceError::InvalidContract(error.to_string()))?;
780 atomic_replace(&path, &bytes, backup.mode, "restore")?;
781 }
782 (PathExistence::Present, ManagedFileType::Directory) => {
783 fs::create_dir_all(&path)?;
784 set_mode(&path, backup.mode)?;
785 }
786 (PathExistence::Absent, _) => {
787 if path.is_file() {
788 fs::remove_file(&path)?;
789 } else if path.is_dir() {
790 fs::remove_dir(&path)?;
791 }
792 }
793 _ => return invalid("workspace backup has an incoherent shape"),
794 }
795 }
796 Ok(())
797}
798
799fn apply_file(
800 root: &Path,
801 path: &str,
802 change: ModuleFileChange,
803 after_content: Option<&str>,
804 after_mode: Option<u32>,
805 operation: &ModuleOperation,
806 effect_id: &str,
807) -> Result<(), LinkedWorkspaceError> {
808 let target = guarded_path(root, path)?;
809 match change {
810 ModuleFileChange::Create | ModuleFileChange::Modify => {
811 let content = after_content.ok_or_else(|| {
812 LinkedWorkspaceError::InvalidContract("write effect has no reviewed bytes".into())
813 })?;
814 if let Some(parent) = target.parent() {
815 create_guarded_directories(root, parent)?;
816 }
817 atomic_replace(
818 &target,
819 content.as_bytes(),
820 after_mode,
821 &raw_digest(format!("{}:{effect_id}", operation.operation_id).as_bytes()),
822 )?;
823 }
824 ModuleFileChange::Delete => fs::remove_file(target)?,
825 }
826 Ok(())
827}
828
829fn workspace_effects(plan: &crate::ModuleChangePlan) -> Vec<&ModulePlanEffect> {
830 plan.effects
831 .iter()
832 .filter(|effect| matches!(effect, ModulePlanEffect::WorkspaceFile { .. }))
833 .collect()
834}
835
836#[allow(clippy::type_complexity)]
837fn workspace_effect_parts(
838 effect: &ModulePlanEffect,
839) -> Result<
840 (
841 &str,
842 ModuleFileChange,
843 Option<&str>,
844 Option<&str>,
845 Option<u32>,
846 ),
847 LinkedWorkspaceError,
848> {
849 if let ModulePlanEffect::WorkspaceFile {
850 path,
851 change,
852 after_digest,
853 after_content,
854 after_mode,
855 ..
856 } = effect
857 {
858 Ok((
859 path,
860 *change,
861 after_digest.as_deref(),
862 after_content.as_deref(),
863 *after_mode,
864 ))
865 } else {
866 invalid("non-workspace effect reached Linked workspace transaction")
867 }
868}
869
870fn matches_after(
871 observed: &ModulePathPrecondition,
872 change: ModuleFileChange,
873 after_digest: Option<&str>,
874) -> bool {
875 match change {
876 ModuleFileChange::Create | ModuleFileChange::Modify => {
877 observed.existence == PathExistence::Present
878 && observed.file_type == ManagedFileType::Regular
879 && observed.content_digest.as_deref() == after_digest
880 }
881 ModuleFileChange::Delete => observed.existence == PathExistence::Absent,
882 }
883}
884
885fn snapshot_path(root: &Path, relative: &str) -> Result<PathSnapshot, LinkedWorkspaceError> {
886 let path = guarded_path(root, relative)?;
887 let metadata = match fs::symlink_metadata(&path) {
888 Ok(metadata) => metadata,
889 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
890 return Ok(PathSnapshot {
891 precondition: ModulePathPrecondition {
892 path: relative.to_owned(),
893 existence: PathExistence::Absent,
894 content_digest: None,
895 file_type: ManagedFileType::Absent,
896 mode: None,
897 },
898 bytes: None,
899 });
900 }
901 Err(error) => return Err(error.into()),
902 };
903 if metadata.file_type().is_symlink() {
904 return Err(LinkedWorkspaceError::UnsafePath(relative.to_owned()));
905 }
906 let (file_type, bytes, digest) = if metadata.is_file() {
907 let bytes = fs::read(&path)?;
908 let digest = raw_digest(&bytes);
909 (ManagedFileType::Regular, Some(bytes), Some(digest))
910 } else if metadata.is_dir() {
911 (ManagedFileType::Directory, None, None)
912 } else {
913 return Err(LinkedWorkspaceError::UnsafePath(relative.to_owned()));
914 };
915 Ok(PathSnapshot {
916 precondition: ModulePathPrecondition {
917 path: relative.to_owned(),
918 existence: PathExistence::Present,
919 content_digest: digest,
920 file_type,
921 mode: mode(&metadata),
922 },
923 bytes,
924 })
925}
926
927fn guarded_path(root: &Path, relative: &str) -> Result<PathBuf, LinkedWorkspaceError> {
928 let relative = normalized_relative(Path::new(relative))?;
929 let mut current = root.to_path_buf();
930 for component in Path::new(&relative).components() {
931 let Component::Normal(segment) = component else {
932 return Err(LinkedWorkspaceError::UnsafePath(relative));
933 };
934 current.push(segment);
935 match fs::symlink_metadata(¤t) {
936 Ok(metadata) if metadata.file_type().is_symlink() => {
937 return Err(LinkedWorkspaceError::UnsafePath(relative));
938 }
939 Ok(_) => {}
940 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
941 Err(error) => return Err(error.into()),
942 }
943 }
944 Ok(current)
945}
946
947fn normalized_relative(path: &Path) -> Result<String, LinkedWorkspaceError> {
948 if path.as_os_str().is_empty() || path.is_absolute() {
949 return Err(LinkedWorkspaceError::UnsafePath(path.display().to_string()));
950 }
951 let mut parts = Vec::new();
952 for component in path.components() {
953 match component {
954 Component::Normal(part) => parts.push(part.to_string_lossy().into_owned()),
955 _ => return Err(LinkedWorkspaceError::UnsafePath(path.display().to_string())),
956 }
957 }
958 let value = parts.join("/");
959 if value == ".env" || value.ends_with("/.env") {
960 return Err(LinkedWorkspaceError::UnsafePath(value));
961 }
962 Ok(value)
963}
964
965fn create_guarded_directories(root: &Path, parent: &Path) -> Result<(), LinkedWorkspaceError> {
966 let relative = parent
967 .strip_prefix(root)
968 .map_err(|_| LinkedWorkspaceError::UnsafePath(parent.display().to_string()))?;
969 let mut current = root.to_path_buf();
970 for component in relative.components() {
971 let Component::Normal(segment) = component else {
972 return Err(LinkedWorkspaceError::UnsafePath(
973 parent.display().to_string(),
974 ));
975 };
976 current.push(segment);
977 if current.exists() {
978 if fs::symlink_metadata(¤t)?.file_type().is_symlink() || !current.is_dir() {
979 return Err(LinkedWorkspaceError::UnsafePath(
980 current.display().to_string(),
981 ));
982 }
983 } else {
984 fs::create_dir(¤t)?;
985 }
986 }
987 Ok(())
988}
989
990fn atomic_replace(
991 path: &Path,
992 bytes: &[u8],
993 mode: Option<u32>,
994 nonce: &str,
995) -> Result<(), LinkedWorkspaceError> {
996 let parent = path
997 .parent()
998 .ok_or_else(|| LinkedWorkspaceError::UnsafePath(path.display().to_string()))?;
999 let suffix = raw_digest(nonce.as_bytes()).replace("sha256:", "");
1000 let temporary = parent.join(format!(".lenso-next-{}", &suffix[..16]));
1001 let created = match OpenOptions::new()
1002 .write(true)
1003 .create_new(true)
1004 .open(&temporary)
1005 {
1006 Ok(mut file) => {
1007 let write_result = file
1008 .write_all(bytes)
1009 .and_then(|()| file.sync_all())
1010 .and_then(|()| set_mode(&temporary, mode));
1011 if let Err(error) = write_result {
1012 let _ = fs::remove_file(&temporary);
1013 return Err(error.into());
1014 }
1015 true
1016 }
1017 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
1018 let metadata = fs::symlink_metadata(&temporary)?;
1019 if metadata.file_type().is_symlink()
1020 || !metadata.is_file()
1021 || fs::read(&temporary)? != bytes
1022 {
1023 return Err(LinkedWorkspaceError::Stale(temporary.display().to_string()));
1024 }
1025 false
1026 }
1027 Err(error) => return Err(error.into()),
1028 };
1029 if let Err(error) = fs::rename(&temporary, path) {
1030 if created {
1031 let _ = fs::remove_file(&temporary);
1032 }
1033 return Err(error.into());
1034 }
1035 File::open(parent)?.sync_all()?;
1036 Ok(())
1037}
1038
1039fn read_required_utf8(root: &Path, path: &str) -> Result<String, LinkedWorkspaceError> {
1040 let snapshot = snapshot_path(root, path)?;
1041 let bytes = snapshot.bytes.ok_or_else(|| {
1042 LinkedWorkspaceError::InvalidContract(format!("required seam file `{path}` is absent"))
1043 })?;
1044 String::from_utf8(bytes).map_err(|_| {
1045 LinkedWorkspaceError::InvalidContract(format!("required seam file `{path}` is not UTF-8"))
1046 })
1047}
1048
1049fn dependency_alias(module_id: &str) -> String {
1050 format!(
1051 "lenso_module_{}",
1052 module_id
1053 .chars()
1054 .map(|character| if character.is_ascii_alphanumeric() {
1055 character
1056 } else {
1057 '_'
1058 })
1059 .collect::<String>()
1060 )
1061}
1062
1063fn valid_package_name(value: &str) -> bool {
1064 !value.is_empty()
1065 && value
1066 .bytes()
1067 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
1068}
1069
1070fn valid_binding_path(value: &str) -> bool {
1071 !value.is_empty()
1072 && value.split("::").all(|segment| {
1073 !segment.is_empty()
1074 && segment
1075 .bytes()
1076 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
1077 && segment
1078 .bytes()
1079 .next()
1080 .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
1081 })
1082}
1083
1084fn escape_toml(value: &str) -> String {
1085 value.replace('\\', "\\\\").replace('"', "\\\"")
1086}
1087
1088fn exact_replacement_patch(path: &str, before: Option<&str>, after: &str) -> String {
1089 let mut rendered_patch = format!("--- a/{path}\n+++ b/{path}\n");
1090 if let Some(before) = before {
1091 for line in before.lines() {
1092 rendered_patch.push('-');
1093 rendered_patch.push_str(line);
1094 rendered_patch.push('\n');
1095 }
1096 }
1097 for line in after.lines() {
1098 rendered_patch.push('+');
1099 rendered_patch.push_str(line);
1100 rendered_patch.push('\n');
1101 }
1102 rendered_patch
1103}
1104
1105fn raw_digest(bytes: &[u8]) -> String {
1106 const HEX: &[u8; 16] = b"0123456789abcdef";
1107 let digest = Sha256::digest(bytes);
1108 let mut hex = String::with_capacity(digest.len() * 2);
1109 for byte in digest {
1110 hex.push(char::from(HEX[usize::from(byte >> 4)]));
1111 hex.push(char::from(HEX[usize::from(byte & 0x0f)]));
1112 }
1113 format!("sha256:{hex}")
1114}
1115
1116#[cfg(unix)]
1117#[allow(clippy::unnecessary_wraps)]
1118fn mode(metadata: &fs::Metadata) -> Option<u32> {
1119 use std::os::unix::fs::PermissionsExt as _;
1120 Some(metadata.permissions().mode() & 0o777)
1121}
1122
1123#[cfg(not(unix))]
1124fn mode(_: &fs::Metadata) -> Option<u32> {
1125 None
1126}
1127
1128#[cfg(unix)]
1129fn set_mode(path: &Path, mode: Option<u32>) -> Result<(), std::io::Error> {
1130 use std::os::unix::fs::PermissionsExt as _;
1131 if let Some(mode) = mode {
1132 fs::set_permissions(path, fs::Permissions::from_mode(mode))?;
1133 }
1134 Ok(())
1135}
1136
1137#[cfg(not(unix))]
1138fn set_mode(_: &Path, _: Option<u32>) -> Result<(), std::io::Error> {
1139 Ok(())
1140}
1141
1142fn invalid<T>(message: impl Into<String>) -> Result<T, LinkedWorkspaceError> {
1143 Err(LinkedWorkspaceError::InvalidContract(message.into()))
1144}