Skip to main content

soma_application/
graduation.rs

1//! Honest Python-to-Rust/component graduation workflow.
2//!
3//! The workflow scaffolds adapters and verifies recorded behavior. It never
4//! claims to translate arbitrary Python business logic. Candidate publication,
5//! attestation, activation, and rollback are serialized and digest-bound.
6
7use std::{
8    fs::{self, File, OpenOptions, TryLockError},
9    io::Write,
10    path::{Path, PathBuf},
11    process::Command,
12    time::{Duration, Instant, SystemTime, UNIX_EPOCH},
13};
14
15use atomicwrites::{AllowOverwrite, AtomicFile};
16use serde::{Deserialize, Serialize};
17use serde_json::{Value, json};
18use sha2::{Digest, Sha256};
19use soma_provider_core::{ProviderCatalog, ProviderKind};
20
21const STATE_SCHEMA_VERSION: u32 = 3;
22const TRANSACTION_DIR: &str = ".graduation-transaction";
23const MAX_SOURCE_BYTES: usize = 1024 * 1024;
24const MAX_FIXTURE_BYTES: usize = 4 * 1024 * 1024;
25const MAX_COMPONENT_BYTES: usize = 64 * 1024 * 1024;
26const MAX_RECOVERY_DEPTH: usize = 8;
27const MAX_RECOVERY_DIRECTORIES: usize = 4_096;
28const MAX_RECOVERY_ENTRIES: usize = 4_096;
29
30mod build;
31mod comparison;
32mod recovery;
33mod state;
34mod transaction;
35use build::run_isolated_component_build;
36pub use comparison::{ComparisonRequest, GraduationFixture, compare};
37pub(crate) use comparison::{read_fixture_snapshot, read_fixtures};
38pub use recovery::{recover, recover_all};
39pub(crate) use state::{read_state, validate_state_paths};
40use state::{write_state, write_state_at};
41use transaction::{
42    AmbiguousCommitError, begin_transaction, finish_transaction, recover_transaction,
43    remove_committed_tombstone,
44};
45
46/// Immutable component identity retained in the graduation workspace.
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
48#[serde(deny_unknown_fields)]
49pub struct GraduationArtifact {
50    /// Immutable artifact path in the graduation workspace.
51    pub path: PathBuf,
52    /// Lowercase SHA-256 digest of the artifact bytes.
53    pub sha256: String,
54}
55
56/// Successful, digest-bound conformance evidence required by activation.
57#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
58#[serde(deny_unknown_fields)]
59pub struct ConformanceAttestation {
60    /// Digest of the candidate artifact that was exercised.
61    pub artifact_sha256: String,
62    /// Digest of the exact fixture corpus used for comparison.
63    pub fixtures_sha256: String,
64    /// Number of successfully matched fixtures.
65    pub fixture_count: usize,
66    /// Digest of the exact live Python source exercised during dual-run.
67    pub source_sha256: String,
68    /// Canonical provider contract digest exercised by both runtimes.
69    pub catalog_sha256: String,
70    /// Unix timestamp in milliseconds when comparison completed.
71    pub verified_unix_ms: u64,
72}
73
74/// Durable state for one graduation workspace.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76#[serde(deny_unknown_fields)]
77pub struct GraduationState {
78    /// Version of this persisted state schema.
79    pub schema_version: u32,
80    /// Canonical path of the original Python provider.
81    pub source: PathBuf,
82    /// Digest of the Python source at scaffold time.
83    pub source_sha256: String,
84    /// Canonical digest of tools, schemas, annotations, and capabilities.
85    pub catalog_sha256: String,
86    /// Captured provider catalog used to preserve the public contract.
87    pub catalog: ProviderCatalog,
88    /// Built and verified component awaiting conformance and activation.
89    pub candidate: Option<GraduationArtifact>,
90    /// Component currently published in the live provider directory.
91    pub active: Option<GraduationArtifact>,
92    /// Previously active component retained for rollback.
93    pub previous: Option<GraduationArtifact>,
94    /// Backup path holding the original Python provider while Wasm is active.
95    pub python_backup: Option<PathBuf>,
96    /// Digest-bound proof that the candidate matches the fixture corpus.
97    pub attestation: Option<ConformanceAttestation>,
98}
99
100pub(crate) struct WorkspaceLock(File);
101
102impl WorkspaceLock {
103    pub(crate) fn acquire(workspace: &Path) -> anyhow::Result<Self> {
104        Self::acquire_before(workspace, Instant::now() + Duration::from_secs(30))
105    }
106
107    fn acquire_before(workspace: &Path, deadline: Instant) -> anyhow::Result<Self> {
108        if !workspace.is_dir() {
109            anyhow::bail!(
110                "graduation workspace does not exist: {}",
111                workspace.display()
112            );
113        }
114        let file = OpenOptions::new()
115            .create(true)
116            .truncate(false)
117            .read(true)
118            .write(true)
119            .open(workspace.join(".graduation.lock"))?;
120        loop {
121            match file.try_lock() {
122                Ok(()) => return Ok(Self(file)),
123                Err(TryLockError::WouldBlock) => {
124                    if Instant::now() >= deadline {
125                        anyhow::bail!("graduation workspace lock deadline expired");
126                    }
127                    std::thread::sleep(Duration::from_millis(10));
128                }
129                Err(TryLockError::Error(error)) => return Err(error.into()),
130            }
131        }
132    }
133}
134
135impl Drop for WorkspaceLock {
136    fn drop(&mut self) {
137        let _ = self.0.unlock();
138    }
139}
140
141/// Scaffold a reusable Rust core plus thin PyO3 and WIT adapters.
142pub fn graduate(
143    source: &Path,
144    workspace: &Path,
145    fixtures: Option<&Path>,
146    mut catalog: ProviderCatalog,
147    provider_root: &Path,
148) -> anyhow::Result<Value> {
149    if source.extension().and_then(|value| value.to_str()) != Some("py") || !source.is_file() {
150        anyhow::bail!("graduation source must be an existing .py provider");
151    }
152    if workspace.exists() {
153        anyhow::bail!(
154            "graduation workspace already exists: {}",
155            workspace.display()
156        );
157    }
158    let source = source.canonicalize()?;
159    let provider_root = provider_root.canonicalize()?;
160    if !source.starts_with(&provider_root) {
161        anyhow::bail!("graduation source is outside the managed provider root");
162    }
163    let source_bytes = read_bounded(&source, MAX_SOURCE_BYTES, "Python source")?;
164    let source_sha256 = digest_bytes(&source_bytes);
165    if !matches!(
166        catalog.provider.kind,
167        ProviderKind::Python | ProviderKind::Langchain | ProviderKind::Llamaindex
168    ) {
169        anyhow::bail!("graduation source is not an active Python provider");
170    }
171    catalog.provider.source = Some(source.display().to_string());
172    let catalog_sha256 = catalog_contract_digest(&catalog)?;
173
174    let parent = workspace
175        .parent()
176        .ok_or_else(|| anyhow::anyhow!("graduation workspace requires a parent"))?;
177    fs::create_dir_all(parent)?;
178    let staging = tempfile::Builder::new()
179        .prefix(".soma-graduate-")
180        .tempdir_in(parent)?;
181    let staging_path = staging.path();
182    fs::create_dir_all(staging_path.join("src"))?;
183    fs::create_dir_all(staging_path.join("fixtures"))?;
184    fs::create_dir_all(staging_path.join("artifacts"))?;
185    fs::write(staging_path.join("source.py"), &source_bytes)?;
186    let fixture_destination = staging_path.join("fixtures/conformance-v1.json");
187    if let Some(fixtures) = fixtures {
188        let corpus = read_fixtures(fixtures)?;
189        fs::write(&fixture_destination, serde_json::to_vec_pretty(&corpus)?)?;
190    } else {
191        fs::write(&fixture_destination, b"[]\n")?;
192    }
193    fs::write(
194        staging_path.join("fixtures/README.md"),
195        "Record provider/action/arguments selectors and expected JSON results in \
196         `conformance-v1.json`. Soma supplies the host-owned execution envelope \
197         before comparing or activating a component.\n",
198    )?;
199    fs::create_dir_all(staging_path.join("wit"))?;
200    fs::write(
201        staging_path.join("wit/world.wit"),
202        include_str!("../../../../wit/soma-provider/world.wit"),
203    )?;
204    fs::write(
205        staging_path.join("Cargo.toml"),
206        include_str!("../templates/graduation/Cargo.toml"),
207    )?;
208    fs::write(
209        staging_path.join("src/core.rs"),
210        include_str!("../templates/graduation/core.rs"),
211    )?;
212    fs::write(
213        staging_path.join("src/lib.rs"),
214        include_str!("../templates/graduation/lib.rs"),
215    )?;
216    fs::write(
217        staging_path.join("src/component.rs"),
218        include_str!("../templates/graduation/component.rs"),
219    )?;
220    fs::write(
221        staging_path.join("src/python.rs"),
222        include_str!("../templates/graduation/python.rs"),
223    )?;
224    write_state_at(
225        staging_path,
226        &GraduationState {
227            schema_version: STATE_SCHEMA_VERSION,
228            source: source.clone(),
229            source_sha256,
230            catalog_sha256,
231            catalog,
232            candidate: None,
233            active: None,
234            previous: None,
235            python_backup: None,
236            attestation: None,
237        },
238    )?;
239    let lock_status = Command::new("cargo")
240        .args([
241            "generate-lockfile",
242            "--manifest-path",
243            &staging_path.join("Cargo.toml").to_string_lossy(),
244        ])
245        .status()?;
246    if !lock_status.success() {
247        anyhow::bail!("graduation lockfile generation failed with status {lock_status}");
248    }
249    let fetch_status = Command::new("cargo")
250        .args([
251            "fetch",
252            "--locked",
253            "--manifest-path",
254            &staging_path.join("Cargo.toml").to_string_lossy(),
255            "--target",
256            "wasm32-wasip2",
257        ])
258        .status()?;
259    if !fetch_status.success() {
260        anyhow::bail!("graduation dependency fetch failed with status {fetch_status}");
261    }
262    let staging = staging.keep();
263    fs::rename(&staging, workspace)?;
264    sync_parent(workspace)?;
265    Ok(json!({
266        "ok": true,
267        "workspace": workspace,
268        "source": source,
269        "manual_rewrite_required": true,
270        "translated_business_logic": false,
271        "fixtures_imported": fixtures.is_some(),
272    }))
273}
274
275/// Build (or import), verify, and publish an immutable candidate artifact.
276pub fn build_component(
277    workspace: &Path,
278    component: Option<&Path>,
279    provider_root: &Path,
280) -> anyhow::Result<Value> {
281    let _lock = WorkspaceLock::acquire(workspace)?;
282    build_component_locked(workspace, component, provider_root)
283}
284
285pub(crate) fn build_component_locked(
286    workspace: &Path,
287    component: Option<&Path>,
288    provider_root: &Path,
289) -> anyhow::Result<Value> {
290    ensure_no_transaction(workspace)?;
291    let initial_state = fs::read(workspace.join("graduation.json"))?;
292    validate_state_paths(workspace, provider_root, &read_state(workspace)?)?;
293    let built_component;
294    let component = if let Some(component) = component {
295        component
296    } else {
297        let status = run_isolated_component_build(workspace)?;
298        if !status.success() {
299            anyhow::bail!("graduated component build failed with status {status}");
300        }
301        built_component = workspace.join("target/wasm32-wasip2/debug/graduated_soma_provider.wasm");
302        &built_component
303    };
304    if fs::read(workspace.join("graduation.json"))? != initial_state {
305        anyhow::bail!("graduation control state changed during component build");
306    }
307    ensure_no_transaction(workspace)?;
308    soma_provider_adapters::wasm::verify_component_artifact(component)
309        .map_err(anyhow::Error::msg)?;
310    let bytes = read_bounded(component, MAX_COMPONENT_BYTES, "component artifact")?;
311    let digest = digest_bytes(&bytes);
312    let destination = workspace
313        .join("artifacts")
314        .join(format!("candidate-{digest}.wasm"));
315    if let Ok(existing) = fs::read(&destination)
316        && existing != bytes
317    {
318        anyhow::bail!("candidate digest path already contains different bytes");
319    }
320    if !destination.exists() {
321        atomic_write(&destination, &bytes)?;
322        set_read_only(&destination)?;
323    }
324    verify_artifact(&GraduationArtifact {
325        path: destination.clone(),
326        sha256: digest.clone(),
327    })?;
328    let mut state = read_state(workspace)?;
329    state.candidate = Some(GraduationArtifact {
330        path: destination.clone(),
331        sha256: digest.clone(),
332    });
333    state.attestation = None;
334    write_state(workspace, &state)?;
335    Ok(json!({"ok": true, "candidate": destination, "sha256": digest}))
336}
337
338/// Validate a component artifact against the versioned WIT runtime.
339pub fn verify_component(component: &Path) -> anyhow::Result<Value> {
340    soma_provider_adapters::wasm::verify_component_artifact(component)
341        .map_err(anyhow::Error::msg)?;
342    Ok(json!({
343        "ok": true,
344        "component": component,
345        "sha256": digest_file(component)?,
346        "wit": "soma:provider@1.0.0"
347    }))
348}
349
350/// Publish the attested candidate into the live provider directory.
351pub fn activate(workspace: &Path, provider_root: &Path) -> anyhow::Result<Value> {
352    let _lock = WorkspaceLock::acquire(workspace)?;
353    ensure_no_transaction(workspace)?;
354    let mut state = read_state(workspace)?;
355    validate_state_paths(workspace, provider_root, &state)?;
356    let candidate = state
357        .candidate
358        .clone()
359        .ok_or_else(|| anyhow::anyhow!("no verified component candidate exists"))?;
360    let attestation = state
361        .attestation
362        .as_ref()
363        .filter(|proof| {
364            proof.artifact_sha256 == candidate.sha256
365                && proof.source_sha256 == state.source_sha256
366                && proof.catalog_sha256 == state.catalog_sha256
367        })
368        .ok_or_else(|| anyhow::anyhow!("candidate lacks digest-bound conformance attestation"))?;
369    if attestation.fixture_count == 0 {
370        anyhow::bail!("candidate conformance attestation is empty");
371    }
372    verify_artifact(&candidate)?;
373
374    let deployed_component = state.source.with_extension("wasm");
375    let deployed_manifest = wasm_manifest_path(&deployed_component);
376    let backup = state
377        .python_backup
378        .clone()
379        .unwrap_or_else(|| state.source.with_extension("py.soma-backup"));
380    begin_transaction(
381        workspace,
382        &state,
383        &deployed_component,
384        &deployed_manifest,
385        &backup,
386    )?;
387    let result = (|| {
388        if state.active.is_none() {
389            if digest_file(&state.source)? != state.source_sha256 {
390                anyhow::bail!("Python source changed since graduation was scaffolded");
391            }
392            if backup.exists() {
393                anyhow::bail!("Python source backup already exists: {}", backup.display());
394            }
395            if deployed_component.exists() || deployed_manifest.exists() {
396                anyhow::bail!("refusing to overwrite an existing provider component or manifest");
397            }
398            fs::rename(&state.source, &backup)?;
399            sync_parent(&backup)?;
400            state.python_backup = Some(backup.clone());
401        }
402
403        let bytes = read_bounded(&candidate.path, MAX_COMPONENT_BYTES, "candidate component")?;
404        if digest_bytes(&bytes) != candidate.sha256 {
405            anyhow::bail!("candidate component changed during activation");
406        }
407        let mut catalog = state.catalog.clone();
408        catalog.provider.kind = ProviderKind::Wasm;
409        catalog.provider.source = Some(deployed_component.display().to_string());
410        catalog.provider.version = Some(format!("sha256:{}", candidate.sha256));
411        if let Err(error) = atomic_write(&deployed_component, &bytes)
412            .and_then(|()| atomic_write(&deployed_manifest, &serde_json::to_vec_pretty(&catalog)?))
413        {
414            if state.active.is_none() && !state.source.exists() {
415                let _ = fs::rename(&backup, &state.source);
416            }
417            return Err(error);
418        }
419        let previous = state.active.replace(candidate.clone());
420        state.previous = previous;
421        state.candidate = None;
422        state.attestation = None;
423        write_state(workspace, &state)?;
424        let response = json!({
425            "ok": true,
426            "active": candidate,
427            "previous": state.previous,
428            "deployed_component": deployed_component,
429            "deployed_manifest": deployed_manifest,
430            "live_provider_refresh_required": true
431        });
432        Ok(response)
433    })();
434    match result {
435        Ok(value) => Ok(value),
436        Err(error) => recover_after_error(workspace, provider_root, error),
437    }
438}
439
440/// Reactivate the retained component, or restore the original Python source.
441pub fn rollback(workspace: &Path, provider_root: &Path) -> anyhow::Result<Value> {
442    let _lock = WorkspaceLock::acquire(workspace)?;
443    ensure_no_transaction(workspace)?;
444    let mut state = read_state(workspace)?;
445    validate_state_paths(workspace, provider_root, &state)?;
446    let active = state
447        .active
448        .clone()
449        .ok_or_else(|| anyhow::anyhow!("no active graduated component exists"))?;
450    let deployed_component = state.source.with_extension("wasm");
451    let deployed_manifest = wasm_manifest_path(&deployed_component);
452    let backup = state
453        .python_backup
454        .clone()
455        .unwrap_or_else(|| state.source.with_extension("py.soma-backup"));
456    begin_transaction(
457        workspace,
458        &state,
459        &deployed_component,
460        &deployed_manifest,
461        &backup,
462    )?;
463    let result = (|| {
464        if let Some(previous) = state.previous.clone() {
465            verify_artifact(&previous)?;
466            let bytes = read_bounded(&previous.path, MAX_COMPONENT_BYTES, "previous component")?;
467            if digest_bytes(&bytes) != previous.sha256 {
468                anyhow::bail!("previous component changed during rollback");
469            }
470            atomic_write(&deployed_component, &bytes)?;
471            let mut catalog = state.catalog.clone();
472            catalog.provider.kind = ProviderKind::Wasm;
473            catalog.provider.source = Some(deployed_component.display().to_string());
474            catalog.provider.version = Some(format!("sha256:{}", previous.sha256));
475            atomic_write(&deployed_manifest, &serde_json::to_vec_pretty(&catalog)?)?;
476            state.active = Some(previous.clone());
477            state.previous = Some(active);
478            write_state(workspace, &state)?;
479            let response = json!({
480                "ok": true,
481                "active": previous,
482                "previous": state.previous,
483                "deployed_component": deployed_component,
484                "live_provider_refresh_required": true
485            });
486            return Ok(response);
487        }
488
489        let backup = state
490            .python_backup
491            .clone()
492            .ok_or_else(|| anyhow::anyhow!("original Python source backup is unavailable"))?;
493        if state.source.exists() {
494            anyhow::bail!("refusing to overwrite existing Python provider source");
495        }
496        fs::rename(&backup, &state.source)?;
497        if deployed_component.exists() {
498            fs::remove_file(&deployed_component)?;
499        }
500        if deployed_manifest.exists() {
501            fs::remove_file(&deployed_manifest)?;
502        }
503        sync_parent(&state.source)?;
504        state.candidate = Some(active);
505        state.active = None;
506        state.python_backup = None;
507        state.attestation = None;
508        write_state(workspace, &state)?;
509        let response = json!({
510            "ok": true,
511            "active": "python",
512            "source": state.source,
513            "live_provider_refresh_required": true
514        });
515        Ok(response)
516    })();
517    match result {
518        Ok(value) => Ok(value),
519        Err(error) => recover_after_error(workspace, provider_root, error),
520    }
521}
522
523/// Read-only operator status with integrity checks for referenced artifacts.
524pub fn status(workspace: &Path, provider_root: &Path) -> anyhow::Result<Value> {
525    let _lock = WorkspaceLock::acquire(workspace)?;
526    let state = read_state(workspace)?;
527    validate_state_paths(workspace, provider_root, &state)?;
528    let candidate_valid = state
529        .candidate
530        .as_ref()
531        .is_some_and(|artifact| verify_artifact(artifact).is_ok());
532    let active_valid = state
533        .active
534        .as_ref()
535        .is_some_and(|artifact| verify_artifact(artifact).is_ok());
536    let previous_valid = state
537        .previous
538        .as_ref()
539        .is_some_and(|artifact| verify_artifact(artifact).is_ok());
540    let deployed_component = state.source.with_extension("wasm");
541    let deployed_sha256 = deployed_component
542        .is_file()
543        .then(|| digest_file(&deployed_component).ok())
544        .flatten();
545    let deployed_matches_active = state
546        .active
547        .as_ref()
548        .is_some_and(|artifact| deployed_sha256.as_deref() == Some(&artifact.sha256));
549    Ok(json!({
550        "schema_version": state.schema_version,
551        "source": state.source,
552        "candidate": state.candidate,
553        "candidate_valid": candidate_valid,
554        "active": state.active,
555        "active_valid": active_valid,
556        "previous": state.previous,
557        "previous_valid": previous_valid,
558        "attestation": state.attestation,
559        "python_backup": state.python_backup,
560        "recovery_required": workspace.join(TRANSACTION_DIR).exists(),
561        "deployed_component": deployed_component,
562        "deployed_sha256": deployed_sha256,
563        "deployed_matches_active": deployed_matches_active,
564    }))
565}
566
567pub(crate) fn identity_before(
568    workspace: &Path,
569    provider_root: &Path,
570    deadline: Instant,
571) -> anyhow::Result<GraduationState> {
572    let _lock = WorkspaceLock::acquire_before(workspace, deadline)?;
573    let state = read_state(workspace)?;
574    validate_state_paths(workspace, provider_root, &state)?;
575    Ok(state)
576}
577
578pub(crate) fn catalog_contract_digest(catalog: &ProviderCatalog) -> anyhow::Result<String> {
579    let mut normalized = catalog.clone();
580    normalized.provider.source = None;
581    normalized.provider.version = None;
582    Ok(digest_bytes(&serde_json::to_vec(&normalized)?))
583}
584
585/// Commit a live activation only after the caller has refreshed and verified
586/// the active provider generation.
587pub fn commit_transaction(workspace: &Path) -> anyhow::Result<()> {
588    let _lock = WorkspaceLock::acquire(workspace)?;
589    finish_transaction(workspace)
590}
591
592/// Whether commit crossed the atomic marker transition but failed its
593/// directory durability sync. Such errors must never trigger rollback.
594pub fn is_ambiguous_commit(error: &anyhow::Error) -> bool {
595    error.downcast_ref::<AmbiguousCommitError>().is_some()
596}
597
598fn atomic_write(path: &Path, bytes: &[u8]) -> anyhow::Result<()> {
599    let parent = path
600        .parent()
601        .ok_or_else(|| anyhow::anyhow!("atomic destination requires a parent"))?;
602    fs::create_dir_all(parent)?;
603    AtomicFile::new(path, AllowOverwrite)
604        .write(|file| {
605            file.write_all(bytes)?;
606            file.sync_all()
607        })
608        .map_err(|error| anyhow::anyhow!("atomic write failed for {}: {error}", path.display()))?;
609    sync_parent(path)
610}
611
612fn verify_artifact(artifact: &GraduationArtifact) -> anyhow::Result<()> {
613    if digest_file(&artifact.path)? != artifact.sha256 {
614        anyhow::bail!(
615            "component artifact digest mismatch: {}",
616            artifact.path.display()
617        );
618    }
619    soma_provider_adapters::wasm::verify_component_artifact(&artifact.path)
620        .map_err(anyhow::Error::msg)
621}
622
623fn digest_file(path: &Path) -> anyhow::Result<String> {
624    Ok(digest_bytes(&read_bounded(
625        path,
626        MAX_COMPONENT_BYTES,
627        "digest input",
628    )?))
629}
630
631fn read_bounded(path: &Path, limit: usize, label: &str) -> anyhow::Result<Vec<u8>> {
632    let length = fs::metadata(path)?.len();
633    if length > limit as u64 {
634        anyhow::bail!("{label} exceeds {limit} bytes");
635    }
636    let bytes = fs::read(path)?;
637    if bytes.len() > limit {
638        anyhow::bail!("{label} exceeds {limit} bytes");
639    }
640    Ok(bytes)
641}
642
643fn recover_after_error<T>(
644    workspace: &Path,
645    provider_root: &Path,
646    original: anyhow::Error,
647) -> anyhow::Result<T> {
648    match recover_transaction(workspace, provider_root) {
649        Ok(()) => Err(original),
650        Err(recovery) => {
651            anyhow::bail!("{original}; automatic graduation recovery also failed: {recovery}")
652        }
653    }
654}
655
656pub(crate) fn ensure_no_transaction(workspace: &Path) -> anyhow::Result<()> {
657    if workspace.join(TRANSACTION_DIR).exists() {
658        anyhow::bail!(
659            "graduation workspace has an in-progress or interrupted transaction; recover it before another operation"
660        );
661    }
662    Ok(())
663}
664
665fn digest_bytes(bytes: &[u8]) -> String {
666    Sha256::digest(bytes)
667        .iter()
668        .map(|byte| format!("{byte:02x}"))
669        .collect()
670}
671
672fn wasm_manifest_path(component: &Path) -> PathBuf {
673    component.with_file_name(format!(
674        "{}.json",
675        component
676            .file_name()
677            .and_then(|name| name.to_str())
678            .unwrap_or("provider.wasm")
679    ))
680}
681
682fn set_read_only(path: &Path) -> anyhow::Result<()> {
683    let mut permissions = fs::metadata(path)?.permissions();
684    permissions.set_readonly(true);
685    fs::set_permissions(path, permissions)?;
686    Ok(())
687}
688
689#[cfg(unix)]
690fn sync_parent(path: &Path) -> anyhow::Result<()> {
691    let parent = path
692        .parent()
693        .ok_or_else(|| anyhow::anyhow!("path requires a parent"))?;
694    File::open(parent)?.sync_all()?;
695    Ok(())
696}
697
698#[cfg(not(unix))]
699fn sync_parent(_path: &Path) -> anyhow::Result<()> {
700    Ok(())
701}
702
703fn unix_ms() -> u64 {
704    SystemTime::now()
705        .duration_since(UNIX_EPOCH)
706        .unwrap_or_default()
707        .as_millis()
708        .min(u128::from(u64::MAX)) as u64
709}
710
711#[cfg(test)]
712mod tests {
713    use super::*;
714
715    #[test]
716    fn checked_templates_keep_manual_rewrite_contract() {
717        assert!(
718            include_str!("../templates/graduation/core.rs").contains("manual rewrite required")
719        );
720        assert!(include_str!("../templates/graduation/component.rs").contains("export!"));
721        assert!(include_str!("../templates/graduation/python.rs").contains("#[pymodule]"));
722    }
723
724    #[test]
725    fn workspace_lock_contention_honors_the_absolute_deadline() {
726        let workspace = tempfile::tempdir().expect("workspace");
727        let held = WorkspaceLock::acquire(workspace.path()).expect("held lock");
728        let error = WorkspaceLock::acquire_before(
729            workspace.path(),
730            Instant::now() + Duration::from_millis(20),
731        )
732        .err()
733        .expect("contended lock must time out");
734        assert!(error.to_string().contains("deadline"));
735        drop(held);
736        WorkspaceLock::acquire_before(workspace.path(), Instant::now() + Duration::from_secs(1))
737            .expect("released lock");
738    }
739
740    #[test]
741    fn interrupted_live_transaction_restores_source_files_and_state() {
742        let temp = tempfile::tempdir().expect("temporary directory");
743        let workspace = temp.path().join("graduated");
744        fs::create_dir(&workspace).expect("workspace");
745        let source = temp.path().join("provider.py");
746        fs::write(&source, b"original python").expect("source");
747        let backup = source.with_extension("py.soma-backup");
748        let component = source.with_extension("wasm");
749        let manifest = wasm_manifest_path(&component);
750        let state = GraduationState {
751            schema_version: STATE_SCHEMA_VERSION,
752            source: source.clone(),
753            source_sha256: digest_file(&source).expect("digest"),
754            catalog_sha256: catalog_contract_digest(
755                &serde_json::from_value(json!({
756                    "schema_version": 1,
757                    "provider": {"name": "transaction-test", "kind": "python", "source": source},
758                    "tools": []
759                }))
760                .expect("catalog"),
761            )
762            .expect("catalog digest"),
763            catalog: serde_json::from_value(json!({
764                "schema_version": 1,
765                "provider": {"name": "transaction-test", "kind": "python", "source": source},
766                "tools": []
767            }))
768            .expect("catalog"),
769            candidate: None,
770            active: None,
771            previous: None,
772            python_backup: None,
773            attestation: None,
774        };
775        write_state(&workspace, &state).expect("state");
776        begin_transaction(&workspace, &state, &component, &manifest, &backup).expect("transaction");
777        fs::rename(&source, &backup).expect("move source");
778        fs::write(&component, b"partial component").expect("component");
779        fs::write(&manifest, b"partial manifest").expect("manifest");
780        let mut changed = state.clone();
781        changed.python_backup = Some(backup.clone());
782        write_state(&workspace, &changed).expect("changed state");
783
784        recover_transaction(&workspace, temp.path()).expect("recovery");
785
786        assert_eq!(
787            fs::read(&source).expect("restored source"),
788            b"original python"
789        );
790        assert!(!backup.exists());
791        assert!(!component.exists());
792        assert!(!manifest.exists());
793        assert_eq!(
794            read_state(&workspace)
795                .expect("restored state")
796                .source_sha256,
797            state.source_sha256
798        );
799        assert!(!workspace.join(TRANSACTION_DIR).exists());
800    }
801
802    #[test]
803    fn status_reports_interrupted_transaction_without_mutating_it() {
804        let temp = tempfile::tempdir().expect("temporary directory");
805        let workspace = temp.path().join("graduated");
806        fs::create_dir(&workspace).expect("workspace");
807        let source = temp.path().join("provider.py");
808        fs::write(&source, b"original python").expect("source");
809        let backup = source.with_extension("py.soma-backup");
810        let component = source.with_extension("wasm");
811        let manifest = wasm_manifest_path(&component);
812        let state = GraduationState {
813            schema_version: STATE_SCHEMA_VERSION,
814            source: source.clone(),
815            source_sha256: digest_file(&source).expect("digest"),
816            catalog_sha256: catalog_contract_digest(
817                &serde_json::from_value(json!({
818                    "schema_version": 1,
819                    "provider": {"name": "status-test", "kind": "python", "source": source},
820                    "tools": []
821                }))
822                .expect("catalog"),
823            )
824            .expect("catalog digest"),
825            catalog: serde_json::from_value(json!({
826                "schema_version": 1,
827                "provider": {"name": "status-test", "kind": "python", "source": source},
828                "tools": []
829            }))
830            .expect("catalog"),
831            candidate: None,
832            active: None,
833            previous: None,
834            python_backup: None,
835            attestation: None,
836        };
837        write_state(&workspace, &state).expect("state");
838        begin_transaction(&workspace, &state, &component, &manifest, &backup).expect("transaction");
839
840        let report = status(&workspace, temp.path()).expect("status");
841
842        assert_eq!(report["recovery_required"], true);
843        assert!(workspace.join(TRANSACTION_DIR).is_dir());
844    }
845
846    #[test]
847    fn startup_recovery_finds_nested_workspaces() {
848        let temp = tempfile::tempdir().expect("temporary directory");
849        let workspace = temp.path().join("teams/python/graduated");
850        let source = create_interrupted_workspace(temp.path(), &workspace);
851
852        assert_eq!(
853            recover_all(temp.path(), &temp.path().join("providers")).expect("recursive recovery"),
854            1
855        );
856        assert_eq!(
857            fs::read(source).expect("restored source"),
858            b"original python"
859        );
860        assert!(!workspace.join(TRANSACTION_DIR).exists());
861    }
862
863    #[cfg(unix)]
864    #[test]
865    fn startup_recovery_does_not_follow_symlinked_directories() {
866        use std::os::unix::fs::symlink;
867
868        let root = tempfile::tempdir().expect("recovery root");
869        let external = tempfile::tempdir().expect("external root");
870        let workspace = external.path().join("graduated");
871        create_interrupted_workspace(external.path(), &workspace);
872        symlink(external.path(), root.path().join("external-link")).expect("symlink");
873
874        assert_eq!(
875            recover_all(root.path(), root.path()).expect("bounded recovery"),
876            0
877        );
878        assert!(workspace.join(TRANSACTION_DIR).exists());
879    }
880
881    #[cfg(unix)]
882    #[test]
883    fn startup_recovery_rejects_symlinked_transaction_directories() {
884        use std::os::unix::fs::symlink;
885
886        let root = tempfile::tempdir().expect("recovery root");
887        let external = tempfile::tempdir().expect("external transaction");
888        let workspace = root.path().join("graduated");
889        fs::create_dir(&workspace).expect("workspace");
890        symlink(external.path(), workspace.join(TRANSACTION_DIR)).expect("transaction symlink");
891
892        assert!(
893            recover_all(root.path(), root.path())
894                .expect_err("symlinked transaction must fail closed")
895                .to_string()
896                .contains("must not be a symlink")
897        );
898    }
899
900    #[cfg(unix)]
901    #[test]
902    fn direct_recovery_rejects_symlinked_transaction_directory() {
903        use std::os::unix::fs::symlink;
904
905        let root = tempfile::tempdir().expect("root");
906        let external = tempfile::tempdir().expect("external transaction");
907        let workspace = root.path().join("graduated");
908        fs::create_dir(&workspace).expect("workspace");
909        symlink(external.path(), workspace.join(TRANSACTION_DIR)).expect("transaction symlink");
910
911        assert!(
912            recover(&workspace, root.path())
913                .expect_err("direct recovery must reject the symlink")
914                .to_string()
915                .contains("must be a real directory")
916        );
917    }
918
919    #[test]
920    fn persisted_state_cannot_escape_managed_roots() {
921        let temp = tempfile::tempdir().expect("temporary directory");
922        let provider_root = temp.path().join("providers");
923        let workspace = temp.path().join("graduated");
924        fs::create_dir_all(workspace.join("artifacts")).expect("workspace");
925        fs::create_dir(&provider_root).expect("provider root");
926        let external = temp.path().join("external.py");
927        fs::write(&external, b"forged").expect("external source");
928        let state = GraduationState {
929            schema_version: STATE_SCHEMA_VERSION,
930            source: external,
931            source_sha256: "00".repeat(32),
932            catalog_sha256: catalog_contract_digest(
933                &serde_json::from_value(json!({
934                    "schema_version": 1,
935                    "provider": {"name": "forged", "kind": "python"},
936                    "tools": []
937                }))
938                .expect("catalog"),
939            )
940            .expect("catalog digest"),
941            catalog: serde_json::from_value(json!({
942                "schema_version": 1,
943                "provider": {"name": "forged", "kind": "python"},
944                "tools": []
945            }))
946            .expect("catalog"),
947            candidate: None,
948            active: None,
949            previous: None,
950            python_backup: None,
951            attestation: None,
952        };
953        write_state(&workspace, &state).expect("state");
954
955        assert!(
956            status(&workspace, &provider_root)
957                .expect_err("forged source must fail closed")
958                .to_string()
959                .contains("outside the managed provider root")
960        );
961    }
962
963    #[test]
964    fn persisted_state_source_must_match_scaffolded_provider_identity() {
965        let temp = tempfile::tempdir().expect("temporary directory");
966        let provider_root = temp.path().join("providers");
967        let workspace = temp.path().join("graduated");
968        fs::create_dir_all(workspace.join("artifacts")).expect("workspace");
969        fs::create_dir(&provider_root).expect("provider root");
970        let source = provider_root.join("target.py");
971        let other = provider_root.join("other.py");
972        fs::write(&source, b"target").expect("source");
973        fs::write(&other, b"other").expect("other");
974        let state = GraduationState {
975            schema_version: STATE_SCHEMA_VERSION,
976            source: other,
977            source_sha256: "00".repeat(32),
978            catalog_sha256: catalog_contract_digest(
979                &serde_json::from_value(json!({
980                    "schema_version": 1,
981                    "provider": {"name": "target", "kind": "python", "source": source},
982                    "tools": []
983                }))
984                .expect("catalog"),
985            )
986            .expect("catalog digest"),
987            catalog: serde_json::from_value(json!({
988                "schema_version": 1,
989                "provider": {"name": "target", "kind": "python", "source": source},
990                "tools": []
991            }))
992            .expect("catalog"),
993            candidate: None,
994            active: None,
995            previous: None,
996            python_backup: None,
997            attestation: None,
998        };
999        write_state(&workspace, &state).expect("state");
1000
1001        assert!(
1002            status(&workspace, &provider_root)
1003                .expect_err("in-root confused deputy must fail closed")
1004                .to_string()
1005                .contains("bound provider identity")
1006        );
1007    }
1008
1009    #[test]
1010    fn recovery_rejects_forged_transaction_destinations() {
1011        let temp = tempfile::tempdir().expect("temporary directory");
1012        let workspace = temp.path().join("graduated");
1013        create_interrupted_workspace(temp.path(), &workspace);
1014        let transaction_path = workspace.join(TRANSACTION_DIR).join("transaction.json");
1015        let mut transaction: Value =
1016            serde_json::from_slice(&fs::read(&transaction_path).expect("transaction"))
1017                .expect("transaction JSON");
1018        transaction["deployed_component"] =
1019            Value::String(temp.path().join("outside.wasm").display().to_string());
1020        fs::write(
1021            &transaction_path,
1022            serde_json::to_vec_pretty(&transaction).expect("encode transaction"),
1023        )
1024        .expect("forge transaction");
1025
1026        assert!(
1027            recover(&workspace, &temp.path().join("providers"))
1028                .expect_err("forged recovery path must fail closed")
1029                .to_string()
1030                .contains("forged destination")
1031        );
1032    }
1033
1034    #[test]
1035    fn startup_recovery_bounds_wide_directory_trees() {
1036        let root = tempfile::tempdir().expect("recovery root");
1037        for index in 0..=MAX_RECOVERY_ENTRIES {
1038            fs::write(root.path().join(format!("entry-{index}")), b"").expect("entry");
1039        }
1040        assert!(
1041            recover_all(root.path(), root.path())
1042                .expect_err("wide tree must fail closed")
1043                .to_string()
1044                .contains("entries")
1045        );
1046    }
1047
1048    #[test]
1049    fn startup_recovery_rejects_and_preserves_untrusted_tombstones() {
1050        let temp = tempfile::tempdir().expect("recovery root");
1051        let workspace = temp.path().join("graduated");
1052        create_interrupted_workspace(temp.path(), &workspace);
1053        let tombstone = workspace.join(".graduation-transaction-complete-decoy");
1054        fs::rename(workspace.join(TRANSACTION_DIR), &tombstone).expect("tombstone");
1055        fs::write(tombstone.join("unexpected"), b"do not delete").expect("decoy");
1056
1057        assert!(
1058            recover_all(temp.path(), &temp.path().join("providers"))
1059                .expect_err("untrusted tombstone must fail closed")
1060                .to_string()
1061                .contains("unexpected entry")
1062        );
1063        assert!(
1064            tombstone.join("unexpected").exists(),
1065            "recovery must preserve an untrusted directory for operator inspection"
1066        );
1067    }
1068
1069    fn create_interrupted_workspace(root: &Path, workspace: &Path) -> PathBuf {
1070        fs::create_dir_all(workspace).expect("workspace");
1071        let source_dir = root.join("providers");
1072        fs::create_dir_all(&source_dir).expect("source directory");
1073        let source = source_dir.join("provider.py");
1074        fs::write(&source, b"original python").expect("source");
1075        let backup = source.with_extension("py.soma-backup");
1076        let component = source.with_extension("wasm");
1077        let manifest = wasm_manifest_path(&component);
1078        let state = GraduationState {
1079            schema_version: STATE_SCHEMA_VERSION,
1080            source: source.clone(),
1081            source_sha256: digest_file(&source).expect("digest"),
1082            catalog_sha256: catalog_contract_digest(
1083                &serde_json::from_value(json!({
1084                    "schema_version": 1,
1085                    "provider": {"name": "nested-recovery", "kind": "python", "source": source},
1086                    "tools": []
1087                }))
1088                .expect("catalog"),
1089            )
1090            .expect("catalog digest"),
1091            catalog: serde_json::from_value(json!({
1092                "schema_version": 1,
1093                "provider": {"name": "nested-recovery", "kind": "python", "source": source},
1094                "tools": []
1095            }))
1096            .expect("catalog"),
1097            candidate: None,
1098            active: None,
1099            previous: None,
1100            python_backup: None,
1101            attestation: None,
1102        };
1103        write_state(workspace, &state).expect("state");
1104        begin_transaction(workspace, &state, &component, &manifest, &backup).expect("transaction");
1105        fs::rename(&source, &backup).expect("move source");
1106        fs::write(&component, b"partial component").expect("component");
1107        source
1108    }
1109}