1use 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
48#[serde(deny_unknown_fields)]
49pub struct GraduationArtifact {
50 pub path: PathBuf,
52 pub sha256: String,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
58#[serde(deny_unknown_fields)]
59pub struct ConformanceAttestation {
60 pub artifact_sha256: String,
62 pub fixtures_sha256: String,
64 pub fixture_count: usize,
66 pub source_sha256: String,
68 pub catalog_sha256: String,
70 pub verified_unix_ms: u64,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
76#[serde(deny_unknown_fields)]
77pub struct GraduationState {
78 pub schema_version: u32,
80 pub source: PathBuf,
82 pub source_sha256: String,
84 pub catalog_sha256: String,
86 pub catalog: ProviderCatalog,
88 pub candidate: Option<GraduationArtifact>,
90 pub active: Option<GraduationArtifact>,
92 pub previous: Option<GraduationArtifact>,
94 pub python_backup: Option<PathBuf>,
96 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
141pub 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
275pub 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
338pub 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
350pub 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
440pub 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
523pub 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
585pub fn commit_transaction(workspace: &Path) -> anyhow::Result<()> {
588 let _lock = WorkspaceLock::acquire(workspace)?;
589 finish_transaction(workspace)
590}
591
592pub 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}