Skip to main content

lenso_module_management/
linked.rs

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, &current.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(&current) {
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(&current)?.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(&current)?;
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}