From c4d62e4b5e85a6aa4361feb0da1455cbff382e61 Mon Sep 17 00:00:00 2001 From: t1k <154753100+t1ktakdev@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:34:07 +0600 Subject: [PATCH 01/73] ci: add Windows and Linux quality gates --- .github/workflows/ci.yml | 68 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9215532 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,68 @@ +name: CI + +on: + push: + branches: [main, "chatgpt/**"] + pull_request: + +permissions: + contents: read + +jobs: + rust: + name: Rust (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [windows-latest, ubuntu-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - name: Install Linux Tauri prerequisites + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Cache Rust + uses: Swatinem/rust-cache@v2 + + - name: cargo fmt + run: cargo fmt --all -- --check + + - name: cargo check + run: cargo check --workspace --all-targets --all-features + + - name: cargo clippy + run: cargo clippy --workspace --all-targets --all-features -- -D warnings + + - name: cargo test + run: cargo test --workspace --all-features + + - name: cargo test serial + run: cargo test --workspace --all-features -- --test-threads=1 + + frontend: + name: Frontend + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm run build + + diff-check: + name: Git diff check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: git diff --check From d4dd7117daf03bcf822bff7f2333b7e4a4609a2b Mon Sep 17 00:00:00 2001 From: t1k <154753100+t1ktakdev@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:35:43 +0600 Subject: [PATCH 02/73] fix: make timeline preview truncation UTF-8 safe --- crates/reprodeck-core/src/timeline.rs | 245 ++++++++++++++++++-------- 1 file changed, 172 insertions(+), 73 deletions(-) diff --git a/crates/reprodeck-core/src/timeline.rs b/crates/reprodeck-core/src/timeline.rs index 32b57fa..8ed78d8 100644 --- a/crates/reprodeck-core/src/timeline.rs +++ b/crates/reprodeck-core/src/timeline.rs @@ -1,7 +1,8 @@ use regex::Regex; -use rusqlite::Connection; +use rusqlite::{Connection, OptionalExtension}; use serde::{Deserialize, Serialize}; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::sync::OnceLock; +use std::time::{SystemTime, SystemTimeError, UNIX_EPOCH}; use thiserror::Error; use uuid::Uuid; @@ -28,6 +29,95 @@ pub fn create_action(conn: &Connection, a: &Action) -> Result<(), rusqlite::Erro pub enum TimelineError { #[error(transparent)] Db(#[from] rusqlite::Error), + #[error(transparent)] + Clock(#[from] SystemTimeError), + #[error("execution not found: {0}")] + ExecutionNotFound(String), +} + +fn unix_time_secs() -> Result { + Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as i64) +} + +fn bearer_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r"(?i)bearer\s+[A-Za-z0-9\-\._~\+\/]+=*") + .expect("static bearer regex must compile") + }) +} + +fn key_value_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r"(?i)(password|token|secret)\s*[=:]\s*[^\s,;]+") + .expect("static key/value regex must compile") + }) +} + +fn jwt_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r"[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+") + .expect("static JWT regex must compile") + }) +} + +fn aws_key_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r"AKIA[0-9A-Z]{16}").expect("static AWS access-key regex must compile") + }) +} + +fn long_hex_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r"\b[0-9a-fA-F]{40,64}\b").expect("static long-hex regex must compile") + }) +} + +fn long_token_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r"\b[A-Za-z0-9_\-]{40,}\b").expect("static long-token regex must compile") + }) +} + +fn sanitize_preview(input: &str) -> String { + let mut s = bearer_regex() + .replace_all(input, "[REDACTED]") + .into_owned(); + s = key_value_regex() + .replace_all(&s, "$1=[REDACTED]") + .into_owned(); + s = jwt_regex() + .replace_all(&s, "[REDACTED_JWT]") + .into_owned(); + s = aws_key_regex() + .replace_all(&s, "[REDACTED_AWS_KEY]") + .into_owned(); + s = long_hex_regex() + .replace_all(&s, "[REDACTED_TOKEN]") + .into_owned(); + long_token_regex() + .replace_all(&s, "[REDACTED_TOKEN]") + .into_owned() +} + +/// Truncate a UTF-8 string to at most `max_bytes` without ever slicing inside +/// a multi-byte scalar value. Returns the truncated string and whether bytes +/// were omitted. +fn truncate_utf8_bytes(input: &str, max_bytes: usize) -> (String, bool) { + if input.len() <= max_bytes { + return (input.to_owned(), false); + } + + let mut end = max_bytes.min(input.len()); + while end > 0 && !input.is_char_boundary(end) { + end -= 1; + } + (input[..end].to_owned(), true) } pub fn create_session( @@ -36,10 +126,7 @@ pub fn create_session( state: &str, meta: Option<&str>, ) -> Result<(), TimelineError> { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() as i64; + let now = unix_time_secs()?; conn.execute( "INSERT INTO sessions (id, created_at, updated_at, state, meta) VALUES (?1,?2,?3,?4,?5)", rusqlite::params![public_id, now, now, state, meta], @@ -64,10 +151,7 @@ pub fn get_session( pub fn start_execution(conn: &Connection, action_id: &str) -> Result { let exec_id = Uuid::new_v4().to_string(); - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() as i64; + let now = unix_time_secs()?; conn.execute( "INSERT INTO executions (id, action_id, status, started_at) VALUES (?1,?2,?3,?4)", @@ -76,7 +160,9 @@ pub fn start_execution(conn: &Connection, action_id: &str) -> Result, ) -> Result { let tx = conn.transaction()?; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() as i64; + let now = unix_time_secs()?; - // update execution - tx.execute( - "UPDATE executions SET status = ?1, finished_at = ?2 WHERE id = ?3", - rusqlite::params![status, now, execution_id], - )?; + let started_at = tx + .query_row( + "SELECT started_at FROM executions WHERE id = ?1", + rusqlite::params![execution_id], + |r| r.get::<_, i64>(0), + ) + .optional()? + .ok_or_else(|| TimelineError::ExecutionNotFound(execution_id.to_owned()))?; + let duration_ms = now.saturating_sub(started_at).saturating_mul(1000); - // insert receipt - let receipt_id = Uuid::new_v4().to_string(); - // sanitize then apply preview bounding - fn sanitize_preview(input: &str) -> String { - // redact bearer tokens - let bearer = Regex::new(r"(?i)bearer\s+[A-Za-z0-9\-\._~\+\/]+=*").unwrap(); - let mut s = bearer.replace_all(input, "[REDACTED]").into_owned(); - // redact common key=val patterns for token/password - let kv = Regex::new(r"(?i)(password|token|secret)\s*[=:]\s*[^\s,;]+").unwrap(); - s = kv.replace_all(&s, "$1=[REDACTED]").into_owned(); - // redact JWT-like tokens (three dot-separated base64url segments) - let jwt = Regex::new(r"[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+").unwrap(); - s = jwt.replace_all(&s, "[REDACTED_JWT]").into_owned(); - // redact AWS-style access keys (AKIA...) - let aws = Regex::new(r"AKIA[0-9A-Z]{16}").unwrap(); - s = aws.replace_all(&s, "[REDACTED_AWS_KEY]").into_owned(); - // redact long hex or base64-like tokens (heuristic) - let hex64 = Regex::new(r"\b[0-9a-fA-F]{40,64}\b").unwrap(); - s = hex64.replace_all(&s, "[REDACTED_TOKEN]").into_owned(); - let long_token = Regex::new(r"\b[A-Za-z0-9_\-]{40,}\b").unwrap(); - s = long_token.replace_all(&s, "[REDACTED_TOKEN]").into_owned(); - s + let updated = tx.execute( + "UPDATE executions SET status = ?1, finished_at = ?2, duration_ms = ?3 WHERE id = ?4", + rusqlite::params![status, now, duration_ms, execution_id], + )?; + if updated != 1 { + return Err(TimelineError::ExecutionNotFound(execution_id.to_owned())); } - // apply preview bounding const MAX_PREVIEW: usize = 1024; let (sp_owned, spt) = match stdout_preview { Some(s) => { - let san = sanitize_preview(s); - if san.len() > MAX_PREVIEW { - (Some(san[..MAX_PREVIEW].to_string()), 1) - } else { - (Some(san), 0) - } + let sanitized = sanitize_preview(s); + let (bounded, truncated) = truncate_utf8_bytes(&sanitized, MAX_PREVIEW); + (Some(bounded), i64::from(truncated)) } None => (None, 0), }; let (ep_owned, ept) = match stderr_preview { Some(s) => { - let san = sanitize_preview(s); - if san.len() > MAX_PREVIEW { - (Some(san[..MAX_PREVIEW].to_string()), 1) - } else { - (Some(san), 0) - } + let sanitized = sanitize_preview(s); + let (bounded, truncated) = truncate_utf8_bytes(&sanitized, MAX_PREVIEW); + (Some(bounded), i64::from(truncated)) } None => (None, 0), }; - tx.execute("INSERT INTO receipts (id, execution_id, summary, stdout_preview, stderr_preview, stdout_truncated, stderr_truncated, created_at) VALUES (?1,?2,?3,?4,?5,?6,?7,?8)", - rusqlite::params![receipt_id, execution_id, "", sp_owned, ep_owned, spt, ept, now])?; + let receipt_id = Uuid::new_v4().to_string(); + tx.execute( + "INSERT INTO receipts (id, execution_id, summary, stdout_preview, stderr_preview, stdout_truncated, stderr_truncated, created_at) VALUES (?1,?2,?3,?4,?5,?6,?7,?8)", + rusqlite::params![receipt_id, execution_id, "", sp_owned, ep_owned, spt, ept, now], + )?; tx.commit()?; Ok(receipt_id) } -/// Recovery: mark Running -> Interrupted on startup +/// Recovery: mark Running -> Interrupted on startup. pub fn recover_running(conn: &mut Connection) -> Result { let tx = conn.transaction()?; - let res = tx.execute("UPDATE executions SET status = 'Interrupted' WHERE status = 'Running' AND finished_at IS NULL", [])?; + let res = tx.execute( + "UPDATE executions SET status = 'Interrupted' WHERE status = 'Running' AND finished_at IS NULL", + [], + )?; tx.commit()?; Ok(res) } @@ -183,7 +253,6 @@ mod tests { created_at: 1, }; - // session must exist per FK; create minimal session conn.execute("INSERT INTO sessions(id, repo_id, created_at, updated_at, state) VALUES (?1,?2,?3,?4,?5)", rusqlite::params!["s-1","repo-x",1,1,"Active"]).unwrap(); create_action(&conn, &a).expect("insert"); @@ -202,7 +271,6 @@ mod tests { let tmp = NamedTempFile::new().unwrap(); let path = tmp.path(); let mut conn = crate::db::init_db(path).expect("init db"); - // insert session with duplicate id conn.execute("INSERT INTO sessions(id, repo_id, created_at, updated_at, state) VALUES (?1,?2,?3,?4,?5)", rusqlite::params!["dup-s","r",1,1,"Active"]).unwrap(); let res = conn.execute("INSERT INTO sessions(id, repo_id, created_at, updated_at, state) VALUES (?1,?2,?3,?4,?5)", rusqlite::params!["dup-s","r",2,2,"Active"]); assert!(res.is_err()); @@ -215,13 +283,11 @@ mod tests { let mut conn = crate::db::init_db(path).expect("init db"); conn.execute("INSERT INTO sessions(id, repo_id, created_at, updated_at, state) VALUES (?1,?2,?3,?4,?5)", rusqlite::params!["s-pag","r",1,1,"Active"]).unwrap(); - // insert multiple actions with same created_at for i in 0..5 { let id = format!("a-{}", i); conn.execute("INSERT INTO actions(id, session_id, kind, state, created_at) VALUES (?1,?2,?3,?4,?5)", rusqlite::params![id, "s-pag", "k", "Created", 1000]).unwrap(); } - // pagination by created_seq stable ordering let mut stmt = conn .prepare("SELECT id FROM actions WHERE session_id = ?1 ORDER BY created_seq LIMIT 2") .unwrap(); @@ -230,7 +296,7 @@ mod tests { .unwrap(); let ids: Vec = rows.map(|r| r.unwrap()).collect(); assert_eq!(ids.len(), 2); - // next page + let mut stmt2 = conn.prepare("SELECT id FROM actions WHERE session_id = ?1 AND created_seq > (SELECT created_seq FROM actions WHERE id = ?2) ORDER BY created_seq LIMIT 10").unwrap(); let rows2 = stmt2 .query_map(rusqlite::params!["s-pag", ids.last().unwrap()], |r| { @@ -258,7 +324,6 @@ mod tests { }; create_action(&conn, &a).unwrap(); let exec_id = start_execution(&conn, "asec").unwrap(); - // include a bearer token in stdout let token = "This has Bearer abcdef12345== inside"; let receipt = finish_execution(&mut conn, &exec_id, "Succeeded", Some(token), None).unwrap(); @@ -290,7 +355,6 @@ mod tests { }; create_action(&conn, &a).unwrap(); let exec_id = start_execution(&conn, "ajwt").unwrap(); - // JWT without Bearer let jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.sgnature"; let aws = format!("AKIA{}", "A".repeat(16)); let input = format!("start {} middle {} end", jwt, aws); @@ -313,13 +377,54 @@ mod tests { ); } + #[test] + fn unicode_preview_truncation_is_utf8_safe() { + let tmp = NamedTempFile::new().unwrap(); + let path = tmp.path(); + let mut conn = crate::db::init_db(path).expect("init db"); + create_session(&conn, "s-unicode", "Active", None).unwrap(); + let a = Action { + id: "a-unicode".to_string(), + session_id: "s-unicode".to_string(), + parent_id: None, + kind: "command".to_string(), + meta: None, + state: "Created".to_string(), + created_at: 1, + }; + create_action(&conn, &a).unwrap(); + let exec_id = start_execution(&conn, &a.id).unwrap(); + let unicode_output = format!("{}{}", "😀".repeat(300), "русский-текст".repeat(50)); + + let receipt = finish_execution( + &mut conn, + &exec_id, + "Succeeded", + Some(&unicode_output), + None, + ) + .unwrap(); + + let (stored, truncated): (String, i64) = conn + .query_row( + "SELECT stdout_preview, stdout_truncated FROM receipts WHERE id = ?1", + rusqlite::params![receipt], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert!(stored.len() <= MAX_PREVIEW_FOR_TEST); + assert_eq!(truncated, 1); + assert!(stored.is_char_boundary(stored.len())); + } + + const MAX_PREVIEW_FOR_TEST: usize = 1024; + #[test] fn session_action_foreign_key() { let tmp = NamedTempFile::new().unwrap(); let path = tmp.path(); let mut conn = crate::db::init_db(path).expect("init db"); - // create session create_session(&conn, "s-123", "Active", Some("{}")).expect("create session"); let a = Action { @@ -334,7 +439,6 @@ mod tests { create_action(&conn, &a).expect("insert action"); - // deleting session should cascade to actions conn.execute( "DELETE FROM sessions WHERE id = ?1", rusqlite::params!["s-123"], @@ -356,7 +460,6 @@ mod tests { let path = tmp.path(); let mut conn = crate::db::init_db(path).expect("init db"); - // prepare session & action create_session(&conn, "s-rcv", "Active", None).unwrap(); let a = Action { id: "act-r".to_string(), @@ -369,14 +472,10 @@ mod tests { }; create_action(&conn, &a).unwrap(); - // start execution let exec_id = start_execution(&conn, "act-r").unwrap(); - - // simulate restart by calling recover_running let changed = recover_running(&mut conn).unwrap(); assert!(changed >= 1); - // check status let status: String = conn .query_row( "SELECT status FROM executions WHERE id = ?1", From d11ec5179091f0adbd0b93461ab3737ba0f78087 Mon Sep 17 00:00:00 2001 From: t1k <154753100+t1ktakdev@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:36:42 +0600 Subject: [PATCH 03/73] chore: point workspace metadata at ReproDeck repository --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 50f6401..a49542e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ version = "0.1.0" edition = "2021" authors = ["ReproDeck Team"] license = "MIT OR Apache-2.0" -repository = "https://github.com/user/reprodeck" +repository = "https://github.com/t1ktakdev/ReproDeck" [profile.release] codegen-units = 1 From 9c35cff690c2728e00c3a2f3b7cee4faa9526267 Mon Sep 17 00:00:00 2001 From: t1k <154753100+t1ktakdev@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:38:03 +0600 Subject: [PATCH 04/73] fix: make artifact storage race-safe and idempotent --- crates/reprodeck-core/src/evidence.rs | 153 +++++++++++++++++--------- 1 file changed, 99 insertions(+), 54 deletions(-) diff --git a/crates/reprodeck-core/src/evidence.rs b/crates/reprodeck-core/src/evidence.rs index 832ac19..9711b37 100644 --- a/crates/reprodeck-core/src/evidence.rs +++ b/crates/reprodeck-core/src/evidence.rs @@ -1,80 +1,115 @@ use sha2::{Digest, Sha256}; use std::fs; use std::path::{Path, PathBuf}; +use uuid::Uuid; + +fn is_symlink_or_reparse(path: &Path) -> std::io::Result { + let meta = fs::symlink_metadata(path)?; + let mut is_bad = meta.file_type().is_symlink(); + + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + if (meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT) != 0 { + is_bad = true; + } + } -pub fn store_artifact(storage_dir: &Path, data: &[u8]) -> std::io::Result<(String, PathBuf)> { - // compute checksum - let mut hasher = Sha256::new(); - hasher.update(data); - let checksum = hex::encode(hasher.finalize()); + Ok(is_bad) +} + +fn verify_existing_artifact(path: &Path, expected_checksum: &str, expected_size: usize) -> std::io::Result<()> { + if is_symlink_or_reparse(path)? { + return Err(std::io::Error::other( + "artifact final path is a symlink or reparse point", + )); + } - // two-level directory by first two chars - if checksum.len() < 2 { - return Err(std::io::Error::other("checksum too short")); + let bytes = fs::read(path)?; + if bytes.len() != expected_size { + return Err(std::io::Error::other( + "artifact store integrity mismatch: existing size differs", + )); } + let actual = hex::encode(Sha256::digest(&bytes)); + if actual != expected_checksum { + return Err(std::io::Error::other( + "artifact store integrity mismatch: existing checksum differs", + )); + } + Ok(()) +} - // canonicalize storage root +pub fn store_artifact(storage_dir: &Path, data: &[u8]) -> std::io::Result<(String, PathBuf)> { + fs::create_dir_all(storage_dir)?; let base = storage_dir.canonicalize()?; - let prefix = &checksum[0..2]; + + let checksum = hex::encode(Sha256::digest(data)); + let prefix = checksum + .get(0..2) + .ok_or_else(|| std::io::Error::other("checksum too short"))?; let dir = storage_dir.join(prefix); - // If an attacker pre-created a symlink or reparse point at dir, refuse to proceed. - if let Ok(meta) = std::fs::symlink_metadata(&dir) { - // On Unix, file_type().is_symlink() detects symlinks. - let mut is_bad = meta.file_type().is_symlink(); - // On Windows, also treat reparse points/junctions as unsafe (FILE_ATTRIBUTE_REPARSE_POINT = 0x400). - #[cfg(windows)] - { - use std::os::windows::fs::MetadataExt; - const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; - if (meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT) != 0 { - is_bad = true; - } - } - if is_bad { - return Err(std::io::Error::other( - "artifact storage prefix is a symlink or reparse point", - )); - } + if dir.exists() && is_symlink_or_reparse(&dir)? { + return Err(std::io::Error::other( + "artifact storage prefix is a symlink or reparse point", + )); } fs::create_dir_all(&dir)?; - let tmp = dir.join(format!("{}.tmp", checksum)); - let finalp = dir.join(&checksum); - - // write to tmp - match fs::write(&tmp, data) { - Ok(()) => {} - Err(e) => { - let _ = fs::remove_file(&tmp); - return Err(e); - } - } - - // Re-check containment before rename to mitigate TOCTOU where possible let dir_canon = dir.canonicalize()?; if !dir_canon.starts_with(&base) { - let _ = fs::remove_file(&tmp); return Err(std::io::Error::other( "artifact dir canonicalization outside storage root", )); } - // atomic rename into final path - if let Err(e) = fs::rename(&tmp, &finalp) { + let finalp = dir.join(&checksum); + if finalp.exists() { + verify_existing_artifact(&finalp, &checksum, data.len())?; + return Ok((checksum, finalp)); + } + + // A unique temp name avoids concurrent writers clobbering each other's + // temporary file before the final content-addressed rename. + let tmp = dir.join(format!("{}.{}.tmp", checksum, Uuid::new_v4())); + if let Err(e) = fs::write(&tmp, data) { let _ = fs::remove_file(&tmp); return Err(e); } - // Verify final path containment + // Re-check containment after the temporary write and before rename. + let current_dir_canon = dir.canonicalize()?; + if current_dir_canon != dir_canon || !current_dir_canon.starts_with(&base) { + let _ = fs::remove_file(&tmp); + return Err(std::io::Error::other( + "artifact directory changed or escaped storage root", + )); + } + + match fs::rename(&tmp, &finalp) { + Ok(()) => {} + Err(e) if finalp.exists() => { + // Another writer may have won the race. Accept it only if the + // existing content matches the content-addressed identity. + let _ = fs::remove_file(&tmp); + verify_existing_artifact(&finalp, &checksum, data.len())?; + } + Err(e) => { + let _ = fs::remove_file(&tmp); + return Err(e); + } + } + let final_canon = finalp.canonicalize()?; if !final_canon.starts_with(&base) { - // attempt to remove the file we just created let _ = fs::remove_file(&finalp); return Err(std::io::Error::other( "artifact stored outside storage root", )); } + verify_existing_artifact(&finalp, &checksum, data.len())?; Ok((checksum, finalp)) } @@ -101,8 +136,8 @@ mod tests { let (checksum, path) = store_artifact(dir.path(), b"hello world").unwrap(); assert!(path.exists()); assert_eq!(checksum.len(), 64); - // ensure containment under storage dir assert!(path.starts_with(dir.path())); + assert_eq!(fs::read(path).unwrap(), b"hello world"); } #[test] @@ -112,8 +147,22 @@ mod tests { let (c1, p1) = store_artifact(dir.path(), data).unwrap(); let (c2, p2) = store_artifact(dir.path(), data).unwrap(); assert_eq!(c1, c2); - assert!(p1.exists()); - assert!(p2.exists()); + assert_eq!(p1, p2); + assert_eq!(fs::read(p1).unwrap(), data); + } + + #[test] + fn existing_corrupt_content_is_rejected() { + let dir = tempdir().unwrap(); + let data = b"expected content"; + let checksum = hex::encode(Sha256::digest(data)); + let prefix = &checksum[0..2]; + let prefix_dir = dir.path().join(prefix); + fs::create_dir_all(&prefix_dir).unwrap(); + fs::write(prefix_dir.join(&checksum), b"corrupt").unwrap(); + + let res = store_artifact(dir.path(), data); + assert!(res.is_err()); } #[test] @@ -121,7 +170,7 @@ mod tests { let dir = tempdir().unwrap(); let outside = tempdir().unwrap(); let outside_file = outside.path().join("foo"); - std::fs::write(&outside_file, b"x").unwrap(); + fs::write(&outside_file, b"x").unwrap(); assert!(!path_within_storage(dir.path(), &outside_file)); } @@ -135,14 +184,10 @@ mod tests { let checksum = hex::encode(Sha256::digest(data)); let prefix = &checksum[0..2]; let prefix_path = dir.path().join(prefix); - // create symlink at prefix pointing outside unixfs::symlink(outside.path(), &prefix_path).unwrap(); - // ensure symlink exists assert!(prefix_path.exists()); - // attempt to store artifact -> should error and not write outside file let res = store_artifact(dir.path(), data); assert!(res.is_err()); - // ensure outside did not receive file named checksum let outside_file = outside.path().join(&checksum); assert!(!outside_file.exists()); } From 6e3a45352366cc81116084fd179300026fec9776 Mon Sep 17 00:00:00 2001 From: t1k <154753100+t1ktakdev@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:40:03 +0600 Subject: [PATCH 05/73] fix: make outcome verification relationally consistent --- crates/reprodeck-core/src/verification.rs | 569 ++++++++++++++++++---- 1 file changed, 480 insertions(+), 89 deletions(-) diff --git a/crates/reprodeck-core/src/verification.rs b/crates/reprodeck-core/src/verification.rs index 89683a5..7b33679 100644 --- a/crates/reprodeck-core/src/verification.rs +++ b/crates/reprodeck-core/src/verification.rs @@ -2,9 +2,36 @@ use crate::timeline; use rusqlite::{Connection, OptionalExtension}; use serde::{Deserialize, Serialize}; use std::fmt::{self, Display}; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::{SystemTime, SystemTimeError, UNIX_EPOCH}; +use thiserror::Error; use uuid::Uuid; +#[derive(Debug, Error)] +pub enum VerificationError { + #[error(transparent)] + Db(#[from] rusqlite::Error), + #[error(transparent)] + Timeline(#[from] timeline::TimelineError), + #[error(transparent)] + Clock(#[from] SystemTimeError), + #[error(transparent)] + Json(#[from] serde_json::Error), + #[error("verification run not found: {0}")] + RunNotFound(String), + #[error("verification check not found or does not belong to contract: {0}")] + CheckNotFound(String), + #[error("verification run cannot finish from status {0}")] + InvalidFinishStatus(RunStatus), + #[error("receipt {receipt_id} does not belong to verification run {run_id}")] + ReceiptMismatch { run_id: String, receipt_id: String }, +} + +type Result = std::result::Result; + +fn unix_time_secs() -> Result { + Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as i64) +} + #[derive(Debug, Serialize, Deserialize)] pub struct OutcomeContract { pub id: String, @@ -17,7 +44,7 @@ pub struct OutcomeContract { pub updated_at: Option, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Clone)] pub struct VerificationCheck { pub id: String, pub contract_id: String, @@ -45,6 +72,14 @@ pub enum RunStatus { Interrupted, } +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy)] +pub enum OutcomeState { + VerifiedFix, + ReproductionNotProven, + NotFixed, + Inconclusive, +} + impl Display for RunPhase { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -67,17 +102,37 @@ impl Display for RunStatus { } } +impl Display for OutcomeState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + OutcomeState::VerifiedFix => write!(f, "VerifiedFix"), + OutcomeState::ReproductionNotProven => write!(f, "ReproductionNotProven"), + OutcomeState::NotFixed => write!(f, "NotFixed"), + OutcomeState::Inconclusive => write!(f, "Inconclusive"), + } + } +} + +fn parse_run_status(value: &str) -> Option { + match value { + "Pending" => Some(RunStatus::Pending), + "Running" => Some(RunStatus::Running), + "Passed" => Some(RunStatus::Passed), + "Failed" => Some(RunStatus::Failed), + "Error" => Some(RunStatus::Error), + "Interrupted" => Some(RunStatus::Interrupted), + _ => None, + } +} + pub fn create_outcome_contract( conn: &Connection, session_id: &str, title: &str, description: Option<&str>, -) -> Result { +) -> Result { let id = Uuid::new_v4().to_string(); - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() as i64; + let now = unix_time_secs()?; conn.execute( "INSERT INTO outcome_contracts (id, session_id, title, description, state, version, created_at) VALUES (?1,?2,?3,?4,?5,?6,?7)", rusqlite::params![id, session_id, title, description, "Draft", 1, now], @@ -86,7 +141,7 @@ pub fn create_outcome_contract( id, session_id: session_id.to_string(), title: title.to_string(), - description: description.map(|s| s.to_string()), + description: description.map(str::to_owned), state: "Draft".to_string(), version: 1, created_at: now, @@ -94,14 +149,11 @@ pub fn create_outcome_contract( }) } -pub fn get_outcome_contract( - conn: &Connection, - id: &str, -) -> Result, rusqlite::Error> { +pub fn get_outcome_contract(conn: &Connection, id: &str) -> Result> { let mut stmt = conn.prepare("SELECT id, session_id, title, description, state, version, created_at, updated_at FROM outcome_contracts WHERE id = ?1")?; let mut rows = stmt.query(rusqlite::params![id])?; if let Some(r) = rows.next()? { - let c = OutcomeContract { + Ok(Some(OutcomeContract { id: r.get(0)?, session_id: r.get(1)?, title: r.get(2)?, @@ -110,156 +162,495 @@ pub fn get_outcome_contract( version: r.get(5)?, created_at: r.get(6)?, updated_at: r.get(7)?, - }; - Ok(Some(c)) + })) } else { Ok(None) } } -/// Start a verification run. This creates a Timeline Action and starts an execution; it records a verification_runs row with status Running. -pub fn start_verification_run( +pub fn add_verification_check( + conn: &Connection, + contract_id: &str, + stable_id: &str, + description: &str, + command_ref: Option<&str>, + expected_condition: Option<&str>, + required: bool, + ordering: i64, +) -> Result { + let id = Uuid::new_v4().to_string(); + conn.execute( + "INSERT INTO verification_checks (id, contract_id, stable_id, description, command_ref, expected_condition, required, ordering) VALUES (?1,?2,?3,?4,?5,?6,?7,?8)", + rusqlite::params![id, contract_id, stable_id, description, command_ref, expected_condition, required, ordering], + )?; + Ok(VerificationCheck { + id, + contract_id: contract_id.to_owned(), + stable_id: stable_id.to_owned(), + description: description.to_owned(), + command_ref: command_ref.map(str::to_owned), + expected_condition: expected_condition.map(str::to_owned), + required, + ordering, + }) +} + +pub fn list_verification_checks( + conn: &Connection, + contract_id: &str, +) -> Result> { + let mut stmt = conn.prepare( + "SELECT id, contract_id, stable_id, description, command_ref, expected_condition, required, ordering FROM verification_checks WHERE contract_id = ?1 ORDER BY ordering, id", + )?; + let rows = stmt.query_map(rusqlite::params![contract_id], |r| { + Ok(VerificationCheck { + id: r.get(0)?, + contract_id: r.get(1)?, + stable_id: r.get(2)?, + description: r.get(3)?, + command_ref: r.get(4)?, + expected_condition: r.get(5)?, + required: r.get(6)?, + ordering: r.get(7)?, + }) + })?; + rows.collect::, _>>() + .map_err(VerificationError::Db) +} + +fn start_run( conn: &Connection, contract_id: &str, + check_id: Option<&str>, phase: RunPhase, -) -> Result { - let run_id = Uuid::new_v4().to_string(); - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() as i64; +) -> Result { + if let Some(check_id) = check_id { + let exists: Option = conn + .query_row( + "SELECT 1 FROM verification_checks WHERE id = ?1 AND contract_id = ?2", + rusqlite::params![check_id, contract_id], + |r| r.get(0), + ) + .optional()?; + if exists.is_none() { + return Err(VerificationError::CheckNotFound(check_id.to_owned())); + } + } - // find session_id for contract to populate action + let run_id = Uuid::new_v4().to_string(); + let now = unix_time_secs()?; let session_id: String = conn.query_row( "SELECT session_id FROM outcome_contracts WHERE id = ?1", rusqlite::params![contract_id], |r| r.get(0), )?; - // create action + let meta = serde_json::to_string(&serde_json::json!({ + "contract_id": contract_id, + "check_id": check_id, + "phase": phase.to_string(), + }))?; let action = timeline::Action { id: run_id.clone(), - session_id: session_id.clone(), + session_id, parent_id: None, kind: "verification:run".to_string(), - meta: Some(format!( - "{{\"contract_id\":\"{}\",\"phase\":\"{}\"}}", - contract_id, phase, - )), + meta: Some(meta), state: "Created".to_string(), created_at: now, }; timeline::create_action(conn, &action)?; + timeline::start_execution(conn, &action.id)?; - // start execution - let exec_id = timeline::start_execution(conn, &action.id)?; - - // insert verification_runs + // receipt_id is deliberately NULL until timeline::finish_execution creates + // an actual receipt. The previous implementation stored an execution ID in + // this column, corrupting the relationship between verification and evidence. conn.execute( - "INSERT INTO verification_runs (id, contract_id, phase, status, started_at, receipt_id) VALUES (?1,?2,?3,?4,?5,?6)", - rusqlite::params![run_id, contract_id, phase.to_string(), RunStatus::Running.to_string(), now, exec_id], + "INSERT INTO verification_runs (id, contract_id, check_id, phase, status, started_at, receipt_id) VALUES (?1,?2,?3,?4,?5,?6,NULL)", + rusqlite::params![run_id, contract_id, check_id, phase.to_string(), RunStatus::Running.to_string(), now], )?; Ok(run_id) } -/// Finish a verification run by updating its status and recording receipt_id (receipt_id is expected to be created by timeline.finish_execution and returned by it). +/// Start a contract-level verification run. Prefer `start_verification_check_run` +/// for contracts that contain explicit checks. +pub fn start_verification_run( + conn: &Connection, + contract_id: &str, + phase: RunPhase, +) -> Result { + start_run(conn, contract_id, None, phase) +} + +pub fn start_verification_check_run( + conn: &Connection, + contract_id: &str, + check_id: &str, + phase: RunPhase, +) -> Result { + start_run(conn, contract_id, Some(check_id), phase) +} + +fn execution_id_for_run(conn: &Connection, run_id: &str) -> Result { + conn.query_row( + "SELECT id FROM executions WHERE action_id = ?1 ORDER BY created_seq DESC LIMIT 1", + rusqlite::params![run_id], + |r| r.get(0), + ) + .optional()? + .ok_or_else(|| VerificationError::RunNotFound(run_id.to_owned())) +} + +fn validate_finish_status(status: RunStatus) -> Result<()> { + match status { + RunStatus::Passed | RunStatus::Failed | RunStatus::Error | RunStatus::Interrupted => Ok(()), + RunStatus::Pending | RunStatus::Running => Err(VerificationError::InvalidFinishStatus(status)), + } +} + +fn timeline_status(status: RunStatus) -> &'static str { + match status { + RunStatus::Passed => "Succeeded", + RunStatus::Failed | RunStatus::Error => "Failed", + RunStatus::Interrupted => "Interrupted", + RunStatus::Pending => "Pending", + RunStatus::Running => "Running", + } +} + +/// Finish a verification run and its underlying Timeline execution together. +/// The returned receipt is the real receipt created by Timeline persistence. +pub fn finish_verification_run_with_output( + conn: &mut Connection, + run_id: &str, + status: RunStatus, + stdout_preview: Option<&str>, + stderr_preview: Option<&str>, +) -> Result { + validate_finish_status(status)?; + let execution_id = execution_id_for_run(conn, run_id)?; + let receipt_id = timeline::finish_execution( + conn, + &execution_id, + timeline_status(status), + stdout_preview, + stderr_preview, + )?; + finish_verification_run(conn, run_id, status, &receipt_id)?; + Ok(receipt_id) +} + +/// Attach an already-created receipt to a verification run. The receipt must +/// belong to the Timeline execution created for this run. pub fn finish_verification_run( conn: &mut Connection, run_id: &str, status: RunStatus, receipt_id: &str, -) -> Result<(), rusqlite::Error> { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() as i64; - conn.execute( - "UPDATE verification_runs SET status = ?1, finished_at = ?2, receipt_id = ?3 WHERE id = ?4", - rusqlite::params![status.to_string(), now, receipt_id, run_id], +) -> Result<()> { + validate_finish_status(status)?; + let execution_id = execution_id_for_run(conn, run_id)?; + let matching_receipt: Option = conn + .query_row( + "SELECT 1 FROM receipts WHERE id = ?1 AND execution_id = ?2", + rusqlite::params![receipt_id, execution_id], + |r| r.get(0), + ) + .optional()?; + if matching_receipt.is_none() { + return Err(VerificationError::ReceiptMismatch { + run_id: run_id.to_owned(), + receipt_id: receipt_id.to_owned(), + }); + } + + let now = unix_time_secs()?; + let started_at: i64 = conn + .query_row( + "SELECT started_at FROM verification_runs WHERE id = ?1", + rusqlite::params![run_id], + |r| r.get(0), + ) + .optional()? + .ok_or_else(|| VerificationError::RunNotFound(run_id.to_owned()))?; + let duration_ms = now.saturating_sub(started_at).saturating_mul(1000); + let changed = conn.execute( + "UPDATE verification_runs SET status = ?1, finished_at = ?2, duration_ms = ?3, receipt_id = ?4 WHERE id = ?5 AND status = 'Running'", + rusqlite::params![status.to_string(), now, duration_ms, receipt_id, run_id], )?; + if changed != 1 { + return Err(VerificationError::RunNotFound(run_id.to_owned())); + } Ok(()) } -pub fn evaluate_outcome(conn: &Connection, contract_id: &str) -> Result { - // naive evaluation for initial implementation: - // if any Before run exists with status Failed -> before_failed - // if any After run exists with status Passed and before_failed -> Verified Fix - let before_failed: Option = conn.query_row( - "SELECT id FROM verification_runs WHERE contract_id = ?1 AND phase = 'Before' AND status = 'Failed' LIMIT 1", - rusqlite::params![contract_id], - |r| r.get(0), - ).optional()?; +pub fn recover_running_verifications(conn: &mut Connection) -> Result { + let tx = conn.transaction()?; + let changed = tx.execute( + "UPDATE verification_runs SET status = 'Interrupted', finished_at = COALESCE(finished_at, started_at) WHERE status = 'Running'", + [], + )?; + tx.commit()?; + timeline::recover_running(conn)?; + Ok(changed) +} + +fn latest_status( + conn: &Connection, + contract_id: &str, + check_id: &str, + phase: RunPhase, +) -> Result> { + let value: Option = conn + .query_row( + "SELECT status FROM verification_runs WHERE contract_id = ?1 AND check_id = ?2 AND phase = ?3 ORDER BY started_at DESC, id DESC LIMIT 1", + rusqlite::params![contract_id, check_id, phase.to_string()], + |r| r.get(0), + ) + .optional()?; + Ok(value.as_deref().and_then(parse_run_status)) +} - if before_failed.is_none() { - return Ok("BeforePassedOrNotObserved".to_string()); +fn evaluate_check(before: Option, after: Option) -> OutcomeState { + match (before, after) { + (Some(RunStatus::Failed), Some(RunStatus::Passed)) => OutcomeState::VerifiedFix, + (Some(RunStatus::Passed), _) => OutcomeState::ReproductionNotProven, + (Some(RunStatus::Failed), Some(RunStatus::Failed)) => OutcomeState::NotFixed, + (Some(RunStatus::Error | RunStatus::Interrupted), _) + | (_, Some(RunStatus::Error | RunStatus::Interrupted)) + | (None, _) + | (_, None) + | (Some(RunStatus::Pending | RunStatus::Running), _) + | (_, Some(RunStatus::Pending | RunStatus::Running)) => OutcomeState::Inconclusive, + // BEFORE failed and AFTER has any non-terminal/non-passing state is not + // enough evidence to claim a fix. + (Some(RunStatus::Failed), Some(RunStatus::Passed)) => OutcomeState::VerifiedFix, } +} - let after_passed: Option = conn.query_row( - "SELECT id FROM verification_runs WHERE contract_id = ?1 AND phase = 'After' AND status = 'Passed' LIMIT 1", - rusqlite::params![contract_id], - |r| r.get(0), - ).optional()?; +pub fn evaluate_outcome_state(conn: &Connection, contract_id: &str) -> Result { + let checks = list_verification_checks(conn, contract_id)?; + let required: Vec<_> = checks.into_iter().filter(|check| check.required).collect(); + + if required.is_empty() { + return Ok(OutcomeState::Inconclusive); + } - if after_passed.is_some() { - Ok("VerifiedFix".to_string()) + let mut saw_reproduction_not_proven = false; + let mut saw_inconclusive = false; + for check in required { + let before = latest_status(conn, contract_id, &check.id, RunPhase::Before)?; + let after = latest_status(conn, contract_id, &check.id, RunPhase::After)?; + match evaluate_check(before, after) { + OutcomeState::NotFixed => return Ok(OutcomeState::NotFixed), + OutcomeState::ReproductionNotProven => saw_reproduction_not_proven = true, + OutcomeState::Inconclusive => saw_inconclusive = true, + OutcomeState::VerifiedFix => {} + } + } + + if saw_reproduction_not_proven { + Ok(OutcomeState::ReproductionNotProven) + } else if saw_inconclusive { + Ok(OutcomeState::Inconclusive) } else { - Ok("NotFixed".to_string()) + Ok(OutcomeState::VerifiedFix) } } +pub fn evaluate_outcome(conn: &Connection, contract_id: &str) -> Result { + Ok(evaluate_outcome_state(conn, contract_id)?.to_string()) +} + #[cfg(test)] mod tests { use super::*; use crate::db::init_db; use tempfile::NamedTempFile; - #[test] - fn create_and_query_contract() { + fn setup() -> (NamedTempFile, Connection, OutcomeContract, VerificationCheck) { let tmp = NamedTempFile::new().unwrap(); - let path = tmp.path(); - let conn = init_db(path).expect("init db"); + let mut conn = init_db(tmp.path()).expect("init db"); + conn.execute( + "INSERT INTO sessions(id, repo_id, created_at, updated_at, state) VALUES (?1,?2,?3,?4,?5)", + rusqlite::params!["s-test", "r", 1, 1, "Active"], + ) + .unwrap(); + let contract = create_outcome_contract(&conn, "s-test", "T", Some("d")).unwrap(); + let check = add_verification_check( + &conn, + &contract.id, + "check-1", + "Regression test", + Some("cargo test"), + Some("exit 0"), + true, + 0, + ) + .unwrap(); + (tmp, conn, contract, check) + } - // create minimal session required by FK - conn.execute("INSERT INTO sessions(id, repo_id, created_at, updated_at, state) VALUES (?1,?2,?3,?4,?5)", rusqlite::params!["s-test","r",1,1,"Active"]).unwrap(); + fn complete( + conn: &mut Connection, + contract: &OutcomeContract, + check: &VerificationCheck, + phase: RunPhase, + status: RunStatus, + ) -> String { + let run = start_verification_check_run(conn, &contract.id, &check.id, phase).unwrap(); + finish_verification_run_with_output(conn, &run, status, Some("verification output"), None) + .unwrap(); + run + } - let c = create_outcome_contract(&conn, "s-test", "T", Some("d")).expect("create"); - let got = get_outcome_contract(&conn, &c.id) + #[test] + fn create_and_query_contract() { + let (_tmp, conn, contract, _check) = setup(); + let got = get_outcome_contract(&conn, &contract.id) .expect("get") .expect("found"); assert_eq!(got.title, "T"); } #[test] - fn start_and_finish_run_lifecycle() { - let tmp = NamedTempFile::new().unwrap(); - let path = tmp.path(); - let mut conn = init_db(path).expect("init db"); + fn start_and_finish_run_lifecycle_uses_real_receipt() { + let (_tmp, mut conn, contract, check) = setup(); + let run_id = + start_verification_check_run(&conn, &contract.id, &check.id, RunPhase::Before) + .expect("start"); + + let (status, receipt_at_start): (String, Option) = conn + .query_row( + "SELECT status, receipt_id FROM verification_runs WHERE id = ?1", + rusqlite::params![&run_id], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!(status, "Running"); + assert!(receipt_at_start.is_none()); - conn.execute("INSERT INTO sessions(id, repo_id, created_at, updated_at, state) VALUES (?1,?2,?3,?4,?5)", rusqlite::params!["s-r","r",1,1,"Active"]).unwrap(); - let c = create_outcome_contract(&conn, "s-r", "title", None).unwrap(); + let receipt = finish_verification_run_with_output( + &mut conn, + &run_id, + RunStatus::Failed, + Some("failure"), + None, + ) + .unwrap(); - let run_id = start_verification_run(&conn, &c.id, RunPhase::Before).expect("start"); - // there should be a verification_runs row - let status: String = conn + let (run_status, stored_receipt): (String, Option) = conn .query_row( - "SELECT status FROM verification_runs WHERE id = ?1", + "SELECT status, receipt_id FROM verification_runs WHERE id = ?1", + rusqlite::params![&run_id], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!(run_status, "Failed"); + assert_eq!(stored_receipt.as_deref(), Some(receipt.as_str())); + + let execution_status: String = conn + .query_row( + "SELECT status FROM executions WHERE action_id = ?1", rusqlite::params![&run_id], |r| r.get(0), ) .unwrap(); - assert_eq!(status, "Running"); + assert_eq!(execution_status, "Failed"); + } + + #[test] + fn before_failed_after_passed_is_verified_fix() { + let (_tmp, mut conn, contract, check) = setup(); + complete(&mut conn, &contract, &check, RunPhase::Before, RunStatus::Failed); + complete(&mut conn, &contract, &check, RunPhase::After, RunStatus::Passed); + assert_eq!( + evaluate_outcome_state(&conn, &contract.id).unwrap(), + OutcomeState::VerifiedFix + ); + } + + #[test] + fn before_passed_means_reproduction_not_proven() { + let (_tmp, mut conn, contract, check) = setup(); + complete(&mut conn, &contract, &check, RunPhase::Before, RunStatus::Passed); + complete(&mut conn, &contract, &check, RunPhase::After, RunStatus::Passed); + assert_eq!( + evaluate_outcome_state(&conn, &contract.id).unwrap(), + OutcomeState::ReproductionNotProven + ); + } + + #[test] + fn before_failed_after_failed_is_not_fixed() { + let (_tmp, mut conn, contract, check) = setup(); + complete(&mut conn, &contract, &check, RunPhase::Before, RunStatus::Failed); + complete(&mut conn, &contract, &check, RunPhase::After, RunStatus::Failed); + assert_eq!( + evaluate_outcome_state(&conn, &contract.id).unwrap(), + OutcomeState::NotFixed + ); + } - // simulate finishing by calling finish_verification_run - finish_verification_run(&mut conn, &run_id, RunStatus::Passed, "receipt-x") - .expect("finish"); - let status2: String = conn + #[test] + fn error_or_interruption_is_inconclusive() { + let (_tmp, mut conn, contract, check) = setup(); + complete(&mut conn, &contract, &check, RunPhase::Before, RunStatus::Error); + complete(&mut conn, &contract, &check, RunPhase::After, RunStatus::Passed); + assert_eq!( + evaluate_outcome_state(&conn, &contract.id).unwrap(), + OutcomeState::Inconclusive + ); + } + + #[test] + fn different_checks_cannot_prove_each_other() { + let (_tmp, mut conn, contract, check_a) = setup(); + let check_b = add_verification_check( + &conn, + &contract.id, + "check-2", + "Second regression check", + None, + None, + true, + 1, + ) + .unwrap(); + + complete(&mut conn, &contract, &check_a, RunPhase::Before, RunStatus::Failed); + complete(&mut conn, &contract, &check_b, RunPhase::After, RunStatus::Passed); + + assert_eq!( + evaluate_outcome_state(&conn, &contract.id).unwrap(), + OutcomeState::Inconclusive + ); + } + + #[test] + fn recovery_interrupts_verification_and_timeline_execution() { + let (_tmp, mut conn, contract, check) = setup(); + let run = start_verification_check_run(&conn, &contract.id, &check.id, RunPhase::Before) + .unwrap(); + assert_eq!(recover_running_verifications(&mut conn).unwrap(), 1); + + let run_status: String = conn .query_row( "SELECT status FROM verification_runs WHERE id = ?1", - rusqlite::params![&run_id], + rusqlite::params![run], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(run_status, "Interrupted"); + + let execution_status: String = conn + .query_row( + "SELECT status FROM executions WHERE action_id = ?1", + rusqlite::params![run], |r| r.get(0), ) .unwrap(); - assert_eq!(status2, "Passed"); + assert_eq!(execution_status, "Interrupted"); } } From fa6aa5e9a3ab3a1da6211c71bbb7fc2715e69adc Mon Sep 17 00:00:00 2001 From: t1k <154753100+t1ktakdev@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:40:59 +0600 Subject: [PATCH 06/73] fix: clean cross-platform artifact-store warnings --- crates/reprodeck-core/src/evidence.rs | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/crates/reprodeck-core/src/evidence.rs b/crates/reprodeck-core/src/evidence.rs index 9711b37..1ebc6c0 100644 --- a/crates/reprodeck-core/src/evidence.rs +++ b/crates/reprodeck-core/src/evidence.rs @@ -5,21 +5,27 @@ use uuid::Uuid; fn is_symlink_or_reparse(path: &Path) -> std::io::Result { let meta = fs::symlink_metadata(path)?; - let mut is_bad = meta.file_type().is_symlink(); + let is_symlink = meta.file_type().is_symlink(); #[cfg(windows)] { use std::os::windows::fs::MetadataExt; const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; - if (meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT) != 0 { - is_bad = true; - } + let is_reparse = (meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT) != 0; + Ok(is_symlink || is_reparse) } - Ok(is_bad) + #[cfg(not(windows))] + { + Ok(is_symlink) + } } -fn verify_existing_artifact(path: &Path, expected_checksum: &str, expected_size: usize) -> std::io::Result<()> { +fn verify_existing_artifact( + path: &Path, + expected_checksum: &str, + expected_size: usize, +) -> std::io::Result<()> { if is_symlink_or_reparse(path)? { return Err(std::io::Error::other( "artifact final path is a symlink or reparse point", @@ -90,7 +96,7 @@ pub fn store_artifact(storage_dir: &Path, data: &[u8]) -> std::io::Result<(Strin match fs::rename(&tmp, &finalp) { Ok(()) => {} - Err(e) if finalp.exists() => { + Err(_e) if finalp.exists() => { // Another writer may have won the race. Accept it only if the // existing content matches the content-addressed identity. let _ = fs::remove_file(&tmp); From 53f347999cd9aee41b22d523bac1a7f2622b05dd Mon Sep 17 00:00:00 2001 From: t1k <154753100+t1ktakdev@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:42:03 +0600 Subject: [PATCH 07/73] refactor: allow atomic verification receipt persistence --- crates/reprodeck-core/src/timeline.rs | 45 +++++++++++++++++---------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/crates/reprodeck-core/src/timeline.rs b/crates/reprodeck-core/src/timeline.rs index 8ed78d8..e94a38b 100644 --- a/crates/reprodeck-core/src/timeline.rs +++ b/crates/reprodeck-core/src/timeline.rs @@ -1,5 +1,5 @@ use regex::Regex; -use rusqlite::{Connection, OptionalExtension}; +use rusqlite::{Connection, OptionalExtension, Transaction}; use serde::{Deserialize, Serialize}; use std::sync::OnceLock; use std::time::{SystemTime, SystemTimeError, UNIX_EPOCH}; @@ -105,9 +105,8 @@ fn sanitize_preview(input: &str) -> String { .into_owned() } -/// Truncate a UTF-8 string to at most `max_bytes` without ever slicing inside -/// a multi-byte scalar value. Returns the truncated string and whether bytes -/// were omitted. +/// Truncate a UTF-8 string to at most `max_bytes` without slicing inside a +/// multi-byte scalar value. fn truncate_utf8_bytes(input: &str, max_bytes: usize) -> (String, bool) { if input.len() <= max_bytes { return (input.to_owned(), false); @@ -160,19 +159,14 @@ pub fn start_execution(conn: &Connection, action_id: &str) -> Result, execution_id: &str, status: &str, stdout_preview: Option<&str>, stderr_preview: Option<&str>, ) -> Result { - let tx = conn.transaction()?; let now = unix_time_secs()?; - let started_at = tx .query_row( "SELECT started_at FROM executions WHERE id = ?1", @@ -192,19 +186,19 @@ pub fn finish_execution( } const MAX_PREVIEW: usize = 1024; - let (sp_owned, spt) = match stdout_preview { + let (stdout_preview, stdout_truncated) = match stdout_preview { Some(s) => { let sanitized = sanitize_preview(s); let (bounded, truncated) = truncate_utf8_bytes(&sanitized, MAX_PREVIEW); - (Some(bounded), i64::from(truncated)) + (Some(bounded), if truncated { 1_i64 } else { 0_i64 }) } None => (None, 0), }; - let (ep_owned, ept) = match stderr_preview { + let (stderr_preview, stderr_truncated) = match stderr_preview { Some(s) => { let sanitized = sanitize_preview(s); let (bounded, truncated) = truncate_utf8_bytes(&sanitized, MAX_PREVIEW); - (Some(bounded), i64::from(truncated)) + (Some(bounded), if truncated { 1_i64 } else { 0_i64 }) } None => (None, 0), }; @@ -212,9 +206,28 @@ pub fn finish_execution( let receipt_id = Uuid::new_v4().to_string(); tx.execute( "INSERT INTO receipts (id, execution_id, summary, stdout_preview, stderr_preview, stdout_truncated, stderr_truncated, created_at) VALUES (?1,?2,?3,?4,?5,?6,?7,?8)", - rusqlite::params![receipt_id, execution_id, "", sp_owned, ep_owned, spt, ept, now], + rusqlite::params![receipt_id, execution_id, "", stdout_preview, stderr_preview, stdout_truncated, stderr_truncated, now], )?; + Ok(receipt_id) +} + +/// Finish an execution and create its receipt in a single transaction. +pub fn finish_execution( + conn: &mut Connection, + execution_id: &str, + status: &str, + stdout_preview: Option<&str>, + stderr_preview: Option<&str>, +) -> Result { + let tx = conn.transaction()?; + let receipt_id = finish_execution_in_transaction( + &tx, + execution_id, + status, + stdout_preview, + stderr_preview, + )?; tx.commit()?; Ok(receipt_id) } From 12a60a14adf4fde159d057727e69130810c876b0 Mon Sep 17 00:00:00 2001 From: t1k <154753100+t1ktakdev@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:43:08 +0600 Subject: [PATCH 08/73] fix: make verification lifecycle atomic and check-scoped --- crates/reprodeck-core/src/verification.rs | 212 +++++++++++++++------- 1 file changed, 145 insertions(+), 67 deletions(-) diff --git a/crates/reprodeck-core/src/verification.rs b/crates/reprodeck-core/src/verification.rs index 7b33679..e96e73a 100644 --- a/crates/reprodeck-core/src/verification.rs +++ b/crates/reprodeck-core/src/verification.rs @@ -219,13 +219,15 @@ pub fn list_verification_checks( } fn start_run( - conn: &Connection, + conn: &mut Connection, contract_id: &str, check_id: Option<&str>, phase: RunPhase, ) -> Result { + let tx = conn.transaction()?; + if let Some(check_id) = check_id { - let exists: Option = conn + let exists: Option = tx .query_row( "SELECT 1 FROM verification_checks WHERE id = ?1 AND contract_id = ?2", rusqlite::params![check_id, contract_id], @@ -239,7 +241,7 @@ fn start_run( let run_id = Uuid::new_v4().to_string(); let now = unix_time_secs()?; - let session_id: String = conn.query_row( + let session_id: String = tx.query_row( "SELECT session_id FROM outcome_contracts WHERE id = ?1", rusqlite::params![contract_id], |r| r.get(0), @@ -259,16 +261,14 @@ fn start_run( state: "Created".to_string(), created_at: now, }; - timeline::create_action(conn, &action)?; - timeline::start_execution(conn, &action.id)?; + timeline::create_action(&tx, &action)?; + timeline::start_execution(&tx, &action.id)?; - // receipt_id is deliberately NULL until timeline::finish_execution creates - // an actual receipt. The previous implementation stored an execution ID in - // this column, corrupting the relationship between verification and evidence. - conn.execute( + tx.execute( "INSERT INTO verification_runs (id, contract_id, check_id, phase, status, started_at, receipt_id) VALUES (?1,?2,?3,?4,?5,?6,NULL)", rusqlite::params![run_id, contract_id, check_id, phase.to_string(), RunStatus::Running.to_string(), now], )?; + tx.commit()?; Ok(run_id) } @@ -276,7 +276,7 @@ fn start_run( /// Start a contract-level verification run. Prefer `start_verification_check_run` /// for contracts that contain explicit checks. pub fn start_verification_run( - conn: &Connection, + conn: &mut Connection, contract_id: &str, phase: RunPhase, ) -> Result { @@ -284,7 +284,7 @@ pub fn start_verification_run( } pub fn start_verification_check_run( - conn: &Connection, + conn: &mut Connection, contract_id: &str, check_id: &str, phase: RunPhase, @@ -305,7 +305,9 @@ fn execution_id_for_run(conn: &Connection, run_id: &str) -> Result { fn validate_finish_status(status: RunStatus) -> Result<()> { match status { RunStatus::Passed | RunStatus::Failed | RunStatus::Error | RunStatus::Interrupted => Ok(()), - RunStatus::Pending | RunStatus::Running => Err(VerificationError::InvalidFinishStatus(status)), + RunStatus::Pending | RunStatus::Running => { + Err(VerificationError::InvalidFinishStatus(status)) + } } } @@ -319,8 +321,34 @@ fn timeline_status(status: RunStatus) -> &'static str { } } -/// Finish a verification run and its underlying Timeline execution together. -/// The returned receipt is the real receipt created by Timeline persistence. +fn update_finished_run( + conn: &Connection, + run_id: &str, + status: RunStatus, + receipt_id: &str, + now: i64, +) -> Result<()> { + let started_at: i64 = conn + .query_row( + "SELECT started_at FROM verification_runs WHERE id = ?1 AND status = 'Running'", + rusqlite::params![run_id], + |r| r.get(0), + ) + .optional()? + .ok_or_else(|| VerificationError::RunNotFound(run_id.to_owned()))?; + let duration_ms = now.saturating_sub(started_at).saturating_mul(1000); + let changed = conn.execute( + "UPDATE verification_runs SET status = ?1, finished_at = ?2, duration_ms = ?3, receipt_id = ?4 WHERE id = ?5 AND status = 'Running'", + rusqlite::params![status.to_string(), now, duration_ms, receipt_id, run_id], + )?; + if changed != 1 { + return Err(VerificationError::RunNotFound(run_id.to_owned())); + } + Ok(()) +} + +/// Finish a verification run and its underlying Timeline execution in one DB +/// transaction. The returned receipt is the actual Timeline receipt. pub fn finish_verification_run_with_output( conn: &mut Connection, run_id: &str, @@ -329,15 +357,17 @@ pub fn finish_verification_run_with_output( stderr_preview: Option<&str>, ) -> Result { validate_finish_status(status)?; - let execution_id = execution_id_for_run(conn, run_id)?; - let receipt_id = timeline::finish_execution( - conn, + let tx = conn.transaction()?; + let execution_id = execution_id_for_run(&tx, run_id)?; + let receipt_id = timeline::finish_execution_in_transaction( + &tx, &execution_id, timeline_status(status), stdout_preview, stderr_preview, )?; - finish_verification_run(conn, run_id, status, &receipt_id)?; + update_finished_run(&tx, run_id, status, &receipt_id, unix_time_secs()?)?; + tx.commit()?; Ok(receipt_id) } @@ -350,8 +380,9 @@ pub fn finish_verification_run( receipt_id: &str, ) -> Result<()> { validate_finish_status(status)?; - let execution_id = execution_id_for_run(conn, run_id)?; - let matching_receipt: Option = conn + let tx = conn.transaction()?; + let execution_id = execution_id_for_run(&tx, run_id)?; + let matching_receipt: Option = tx .query_row( "SELECT 1 FROM receipts WHERE id = ?1 AND execution_id = ?2", rusqlite::params![receipt_id, execution_id], @@ -365,23 +396,8 @@ pub fn finish_verification_run( }); } - let now = unix_time_secs()?; - let started_at: i64 = conn - .query_row( - "SELECT started_at FROM verification_runs WHERE id = ?1", - rusqlite::params![run_id], - |r| r.get(0), - ) - .optional()? - .ok_or_else(|| VerificationError::RunNotFound(run_id.to_owned()))?; - let duration_ms = now.saturating_sub(started_at).saturating_mul(1000); - let changed = conn.execute( - "UPDATE verification_runs SET status = ?1, finished_at = ?2, duration_ms = ?3, receipt_id = ?4 WHERE id = ?5 AND status = 'Running'", - rusqlite::params![status.to_string(), now, duration_ms, receipt_id, run_id], - )?; - if changed != 1 { - return Err(VerificationError::RunNotFound(run_id.to_owned())); - } + update_finished_run(&tx, run_id, status, receipt_id, unix_time_secs()?)?; + tx.commit()?; Ok(()) } @@ -391,8 +407,11 @@ pub fn recover_running_verifications(conn: &mut Connection) -> Result { "UPDATE verification_runs SET status = 'Interrupted', finished_at = COALESCE(finished_at, started_at) WHERE status = 'Running'", [], )?; + tx.execute( + "UPDATE executions SET status = 'Interrupted' WHERE status = 'Running' AND finished_at IS NULL", + [], + )?; tx.commit()?; - timeline::recover_running(conn)?; Ok(changed) } @@ -413,19 +432,17 @@ fn latest_status( } fn evaluate_check(before: Option, after: Option) -> OutcomeState { - match (before, after) { - (Some(RunStatus::Failed), Some(RunStatus::Passed)) => OutcomeState::VerifiedFix, - (Some(RunStatus::Passed), _) => OutcomeState::ReproductionNotProven, - (Some(RunStatus::Failed), Some(RunStatus::Failed)) => OutcomeState::NotFixed, - (Some(RunStatus::Error | RunStatus::Interrupted), _) - | (_, Some(RunStatus::Error | RunStatus::Interrupted)) - | (None, _) - | (_, None) - | (Some(RunStatus::Pending | RunStatus::Running), _) - | (_, Some(RunStatus::Pending | RunStatus::Running)) => OutcomeState::Inconclusive, - // BEFORE failed and AFTER has any non-terminal/non-passing state is not - // enough evidence to claim a fix. - (Some(RunStatus::Failed), Some(RunStatus::Passed)) => OutcomeState::VerifiedFix, + match before { + Some(RunStatus::Passed) => OutcomeState::ReproductionNotProven, + Some(RunStatus::Failed) => match after { + Some(RunStatus::Passed) => OutcomeState::VerifiedFix, + Some(RunStatus::Failed) => OutcomeState::NotFixed, + _ => OutcomeState::Inconclusive, + }, + Some( + RunStatus::Pending | RunStatus::Running | RunStatus::Error | RunStatus::Interrupted, + ) + | None => OutcomeState::Inconclusive, } } @@ -471,7 +488,7 @@ mod tests { fn setup() -> (NamedTempFile, Connection, OutcomeContract, VerificationCheck) { let tmp = NamedTempFile::new().unwrap(); - let mut conn = init_db(tmp.path()).expect("init db"); + let conn = init_db(tmp.path()).expect("init db"); conn.execute( "INSERT INTO sessions(id, repo_id, created_at, updated_at, state) VALUES (?1,?2,?3,?4,?5)", rusqlite::params!["s-test", "r", 1, 1, "Active"], @@ -518,7 +535,7 @@ mod tests { fn start_and_finish_run_lifecycle_uses_real_receipt() { let (_tmp, mut conn, contract, check) = setup(); let run_id = - start_verification_check_run(&conn, &contract.id, &check.id, RunPhase::Before) + start_verification_check_run(&mut conn, &contract.id, &check.id, RunPhase::Before) .expect("start"); let (status, receipt_at_start): (String, Option) = conn @@ -563,8 +580,20 @@ mod tests { #[test] fn before_failed_after_passed_is_verified_fix() { let (_tmp, mut conn, contract, check) = setup(); - complete(&mut conn, &contract, &check, RunPhase::Before, RunStatus::Failed); - complete(&mut conn, &contract, &check, RunPhase::After, RunStatus::Passed); + complete( + &mut conn, + &contract, + &check, + RunPhase::Before, + RunStatus::Failed, + ); + complete( + &mut conn, + &contract, + &check, + RunPhase::After, + RunStatus::Passed, + ); assert_eq!( evaluate_outcome_state(&conn, &contract.id).unwrap(), OutcomeState::VerifiedFix @@ -574,8 +603,20 @@ mod tests { #[test] fn before_passed_means_reproduction_not_proven() { let (_tmp, mut conn, contract, check) = setup(); - complete(&mut conn, &contract, &check, RunPhase::Before, RunStatus::Passed); - complete(&mut conn, &contract, &check, RunPhase::After, RunStatus::Passed); + complete( + &mut conn, + &contract, + &check, + RunPhase::Before, + RunStatus::Passed, + ); + complete( + &mut conn, + &contract, + &check, + RunPhase::After, + RunStatus::Passed, + ); assert_eq!( evaluate_outcome_state(&conn, &contract.id).unwrap(), OutcomeState::ReproductionNotProven @@ -585,8 +626,20 @@ mod tests { #[test] fn before_failed_after_failed_is_not_fixed() { let (_tmp, mut conn, contract, check) = setup(); - complete(&mut conn, &contract, &check, RunPhase::Before, RunStatus::Failed); - complete(&mut conn, &contract, &check, RunPhase::After, RunStatus::Failed); + complete( + &mut conn, + &contract, + &check, + RunPhase::Before, + RunStatus::Failed, + ); + complete( + &mut conn, + &contract, + &check, + RunPhase::After, + RunStatus::Failed, + ); assert_eq!( evaluate_outcome_state(&conn, &contract.id).unwrap(), OutcomeState::NotFixed @@ -596,8 +649,20 @@ mod tests { #[test] fn error_or_interruption_is_inconclusive() { let (_tmp, mut conn, contract, check) = setup(); - complete(&mut conn, &contract, &check, RunPhase::Before, RunStatus::Error); - complete(&mut conn, &contract, &check, RunPhase::After, RunStatus::Passed); + complete( + &mut conn, + &contract, + &check, + RunPhase::Before, + RunStatus::Error, + ); + complete( + &mut conn, + &contract, + &check, + RunPhase::After, + RunStatus::Passed, + ); assert_eq!( evaluate_outcome_state(&conn, &contract.id).unwrap(), OutcomeState::Inconclusive @@ -619,8 +684,20 @@ mod tests { ) .unwrap(); - complete(&mut conn, &contract, &check_a, RunPhase::Before, RunStatus::Failed); - complete(&mut conn, &contract, &check_b, RunPhase::After, RunStatus::Passed); + complete( + &mut conn, + &contract, + &check_a, + RunPhase::Before, + RunStatus::Failed, + ); + complete( + &mut conn, + &contract, + &check_b, + RunPhase::After, + RunStatus::Passed, + ); assert_eq!( evaluate_outcome_state(&conn, &contract.id).unwrap(), @@ -631,14 +708,15 @@ mod tests { #[test] fn recovery_interrupts_verification_and_timeline_execution() { let (_tmp, mut conn, contract, check) = setup(); - let run = start_verification_check_run(&conn, &contract.id, &check.id, RunPhase::Before) - .unwrap(); + let run = + start_verification_check_run(&mut conn, &contract.id, &check.id, RunPhase::Before) + .unwrap(); assert_eq!(recover_running_verifications(&mut conn).unwrap(), 1); let run_status: String = conn .query_row( "SELECT status FROM verification_runs WHERE id = ?1", - rusqlite::params![run], + rusqlite::params![&run], |r| r.get(0), ) .unwrap(); @@ -647,7 +725,7 @@ mod tests { let execution_status: String = conn .query_row( "SELECT status FROM executions WHERE action_id = ?1", - rusqlite::params![run], + rusqlite::params![&run], |r| r.get(0), ) .unwrap(); From 6109179505e4ee1875bb54dbc166d09f1df83c6d Mon Sep 17 00:00:00 2001 From: t1k <154753100+t1ktakdev@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:08:28 +0600 Subject: [PATCH 09/73] style: rustfmt bridge tester --- tools/bridge_tester/src/main.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tools/bridge_tester/src/main.rs b/tools/bridge_tester/src/main.rs index 4773ce4..d43a3ef 100644 --- a/tools/bridge_tester/src/main.rs +++ b/tools/bridge_tester/src/main.rs @@ -1,7 +1,11 @@ use reprodeck_lib::*; + fn main() { match list_sessions() { - Ok(v) => println!("list_sessions ok: {}", serde_json::to_string_pretty(&v).unwrap()), + Ok(v) => println!( + "list_sessions ok: {}", + serde_json::to_string_pretty(&v).unwrap() + ), Err(e) => println!("list_sessions err: {}", e), } } From dc63a4ebd03329cae1c2d305cc54d542a3ffb88c Mon Sep 17 00:00:00 2001 From: t1k <154753100+t1ktakdev@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:09:09 +0600 Subject: [PATCH 10/73] ci: temporarily auto-format repair branch --- .github/workflows/format-repair.yml | 38 +++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/format-repair.yml diff --git a/.github/workflows/format-repair.yml b/.github/workflows/format-repair.yml new file mode 100644 index 0000000..75fe28a --- /dev/null +++ b/.github/workflows/format-repair.yml @@ -0,0 +1,38 @@ +name: Temporary repair formatter + +on: + push: + branches: + - chatgpt/repair-foundations + +permissions: + contents: write + +jobs: + rustfmt: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: chatgpt/repair-foundations + fetch-depth: 0 + + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + + - name: Format Rust workspace + run: cargo fmt --all + + - name: Commit formatting if needed + shell: bash + run: | + if git diff --quiet; then + echo "Already formatted" + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "style: apply rustfmt" + git push origin HEAD:chatgpt/repair-foundations From 1e11051225312950a15d43a8d4d3f7bb0a1f0d26 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:09:19 +0000 Subject: [PATCH 11/73] style: apply rustfmt --- crates/reprodeck-core/src/timeline.rs | 17 ++++------------- crates/reprodeck-core/src/verification.rs | 7 ++++++- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/crates/reprodeck-core/src/timeline.rs b/crates/reprodeck-core/src/timeline.rs index e94a38b..15458ad 100644 --- a/crates/reprodeck-core/src/timeline.rs +++ b/crates/reprodeck-core/src/timeline.rs @@ -85,15 +85,11 @@ fn long_token_regex() -> &'static Regex { } fn sanitize_preview(input: &str) -> String { - let mut s = bearer_regex() - .replace_all(input, "[REDACTED]") - .into_owned(); + let mut s = bearer_regex().replace_all(input, "[REDACTED]").into_owned(); s = key_value_regex() .replace_all(&s, "$1=[REDACTED]") .into_owned(); - s = jwt_regex() - .replace_all(&s, "[REDACTED_JWT]") - .into_owned(); + s = jwt_regex().replace_all(&s, "[REDACTED_JWT]").into_owned(); s = aws_key_regex() .replace_all(&s, "[REDACTED_AWS_KEY]") .into_owned(); @@ -221,13 +217,8 @@ pub fn finish_execution( stderr_preview: Option<&str>, ) -> Result { let tx = conn.transaction()?; - let receipt_id = finish_execution_in_transaction( - &tx, - execution_id, - status, - stdout_preview, - stderr_preview, - )?; + let receipt_id = + finish_execution_in_transaction(&tx, execution_id, status, stdout_preview, stderr_preview)?; tx.commit()?; Ok(receipt_id) } diff --git a/crates/reprodeck-core/src/verification.rs b/crates/reprodeck-core/src/verification.rs index e96e73a..d683967 100644 --- a/crates/reprodeck-core/src/verification.rs +++ b/crates/reprodeck-core/src/verification.rs @@ -486,7 +486,12 @@ mod tests { use crate::db::init_db; use tempfile::NamedTempFile; - fn setup() -> (NamedTempFile, Connection, OutcomeContract, VerificationCheck) { + fn setup() -> ( + NamedTempFile, + Connection, + OutcomeContract, + VerificationCheck, + ) { let tmp = NamedTempFile::new().unwrap(); let conn = init_db(tmp.path()).expect("init db"); conn.execute( From d5b9d2410fee41718574728c2a90d9b131e89e6b Mon Sep 17 00:00:00 2001 From: t1k <154753100+t1ktakdev@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:09:38 +0600 Subject: [PATCH 12/73] ci: remove temporary repair formatter --- .github/workflows/format-repair.yml | 38 ----------------------------- 1 file changed, 38 deletions(-) delete mode 100644 .github/workflows/format-repair.yml diff --git a/.github/workflows/format-repair.yml b/.github/workflows/format-repair.yml deleted file mode 100644 index 75fe28a..0000000 --- a/.github/workflows/format-repair.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Temporary repair formatter - -on: - push: - branches: - - chatgpt/repair-foundations - -permissions: - contents: write - -jobs: - rustfmt: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: chatgpt/repair-foundations - fetch-depth: 0 - - - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt - - - name: Format Rust workspace - run: cargo fmt --all - - - name: Commit formatting if needed - shell: bash - run: | - if git diff --quiet; then - echo "Already formatted" - exit 0 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "style: apply rustfmt" - git push origin HEAD:chatgpt/repair-foundations From ba60200ded62e6dc99c785fa70034ba22b8d44ad Mon Sep 17 00:00:00 2001 From: t1k <154753100+t1ktakdev@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:20:04 +0600 Subject: [PATCH 13/73] fix(bridge): separate Tauri commands from typed service API --- src-tauri/src/lib.rs | 416 +++++++++++++++++++++++++++++++------------ 1 file changed, 306 insertions(+), 110 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0731a79..91e4d00 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,148 +1,322 @@ -// Tauri command bridge to ReproDeck core APIs -// serde::Serialize imported by other modules when needed -use serde_json::json; +use reprodeck_core::{db, timeline, verification}; +use rusqlite::OptionalExtension; +use serde::{Deserialize, Serialize}; +use std::fmt::{self, Display}; use std::path::PathBuf; -use reprodeck_core::db; -use reprodeck_core::timeline; -use reprodeck_core::verification; +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct BridgeError { + pub code: String, + pub message: String, +} + +impl BridgeError { + fn new(code: impl Into, message: impl Into) -> Self { + Self { + code: code.into(), + message: message.into(), + } + } + + fn database(context: &str, error: impl Display) -> Self { + Self::new("database_error", format!("{context}: {error}")) + } +} + +impl Display for BridgeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}: {}", self.code, self.message) + } +} + +impl std::error::Error for BridgeError {} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SessionDto { + pub id: String, + pub created_at: i64, + pub updated_at: i64, + pub state: String, + pub meta: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ActionDto { + pub id: String, + pub kind: String, + pub state: String, + pub created_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ReceiptDto { + pub id: String, + pub execution_id: String, + pub stdout_preview: Option, + pub stderr_preview: Option, + pub stdout_truncated: bool, + pub stderr_truncated: bool, + pub created_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ContractDto { + pub id: String, + pub session_id: String, + pub title: String, + pub description: Option, + pub state: String, + pub version: i64, + pub created_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct VerificationRunDto { + pub id: String, + pub check_id: Option, + pub phase: String, + pub status: String, + pub started_at: i64, + pub finished_at: Option, + pub receipt_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct VerdictDto { + pub verdict: String, +} fn app_db_path() -> PathBuf { - if let Some(b) = directories::BaseDirs::new() { - let mut p = b.data_local_dir().to_path_buf(); - p.push("reprodeck"); - std::fs::create_dir_all(&p).ok(); - p.push("reprodeck.db"); - p + if let Some(base) = directories::BaseDirs::new() { + let mut path = base.data_local_dir().to_path_buf(); + path.push("reprodeck"); + let _ = std::fs::create_dir_all(&path); + path.push("reprodeck.db"); + path } else { - // fallback to temp dir - let mut p = std::env::temp_dir(); - p.push("reprodeck.db"); - p + std::env::temp_dir().join("reprodeck.db") } } -fn open_conn() -> Result { - let p = app_db_path(); - db::init_db(&p).map_err(|_e| "db init error".to_string()) +fn open_conn() -> Result { + db::init_db(&app_db_path()).map_err(|error| BridgeError::database("initialize database", error)) } -#[tauri::command] -pub fn list_sessions() -> Result { +pub fn list_sessions_service() -> Result, BridgeError> { let conn = open_conn()?; let mut stmt = conn .prepare( - "SELECT id, created_at, updated_at, state, meta FROM sessions ORDER BY created_at DESC", + "SELECT id, created_at, updated_at, state, meta \ + FROM sessions ORDER BY created_at DESC, id DESC", ) - .map_err(|_| "query error")?; - let mut rows = stmt.query([]).map_err(|_| "query error")?; - let mut out = Vec::new(); - while let Some(r) = rows.next().map_err(|_| "row error")? { - let id: String = r.get(0).unwrap_or_default(); - let created_at: i64 = r.get(1).unwrap_or(0); - let updated_at: Option = r.get(2).ok(); - let state: String = r.get(3).unwrap_or_default(); - let meta: Option = r.get(4).ok(); - out.push(json!({"id": id, "created_at": created_at, "updated_at": updated_at, "state": state, "meta": meta })); - } - Ok(serde_json::Value::Array(out)) + .map_err(|error| BridgeError::database("prepare session query", error))?; + + let rows = stmt + .query_map([], |row| { + Ok(SessionDto { + id: row.get(0)?, + created_at: row.get(1)?, + updated_at: row.get(2)?, + state: row.get(3)?, + meta: row.get(4)?, + }) + }) + .map_err(|error| BridgeError::database("query sessions", error))?; + + rows.collect::, _>>() + .map_err(|error| BridgeError::database("decode session row", error)) } -#[tauri::command] -pub fn create_session(id: &str) -> Result { +pub fn create_session_service(id: &str) -> Result { + let id = id.trim(); + if id.is_empty() { + return Err(BridgeError::new( + "invalid_request", + "session id must not be empty", + )); + } + let conn = open_conn()?; - timeline::create_session(&conn, id, "Active", None).map_err(|_| "create session failed")?; - Ok(json!({"id": id})) + timeline::create_session(&conn, id, "Active", None) + .map_err(|error| BridgeError::database("create session", error))?; + + conn.query_row( + "SELECT id, created_at, updated_at, state, meta FROM sessions WHERE id = ?1", + rusqlite::params![id], + |row| { + Ok(SessionDto { + id: row.get(0)?, + created_at: row.get(1)?, + updated_at: row.get(2)?, + state: row.get(3)?, + meta: row.get(4)?, + }) + }, + ) + .map_err(|error| BridgeError::database("read created session", error)) } -#[tauri::command] -pub fn list_actions(session_id: &str) -> Result { +pub fn list_actions_service(session_id: &str) -> Result, BridgeError> { let conn = open_conn()?; let mut stmt = conn - .prepare("SELECT id, kind, state, created_at FROM actions WHERE session_id = ?1 ORDER BY created_seq DESC") - .map_err(|_| "query error")?; - let mut rows = stmt - .query(rusqlite::params![session_id]) - .map_err(|_| "query error")?; - let mut out = Vec::new(); - while let Some(r) = rows.next().map_err(|_| "row error")? { - let id: String = r.get(0).unwrap_or_default(); - let kind: String = r.get(1).unwrap_or_default(); - let state: String = r.get(2).unwrap_or_default(); - let created_at: i64 = r.get(3).unwrap_or(0); - out.push(json!({"id": id, "kind": kind, "state": state, "created_at": created_at })); - } - Ok(serde_json::Value::Array(out)) + .prepare( + "SELECT id, kind, state, created_at FROM actions \ + WHERE session_id = ?1 ORDER BY created_seq DESC", + ) + .map_err(|error| BridgeError::database("prepare action query", error))?; + + let rows = stmt + .query_map(rusqlite::params![session_id], |row| { + Ok(ActionDto { + id: row.get(0)?, + kind: row.get(1)?, + state: row.get(2)?, + created_at: row.get(3)?, + }) + }) + .map_err(|error| BridgeError::database("query actions", error))?; + + rows.collect::, _>>() + .map_err(|error| BridgeError::database("decode action row", error)) } -#[tauri::command] -pub fn get_receipt(receipt_id: &str) -> Result { +pub fn get_receipt_service(receipt_id: &str) -> Result { let conn = open_conn()?; - let mut stmt = conn - .prepare("SELECT id, execution_id, stdout_preview, stderr_preview, created_at FROM receipts WHERE id = ?1") - .map_err(|_| "query error")?; - let row = stmt.query_row(rusqlite::params![receipt_id], |r| { - let id: String = r.get(0)?; - let execution_id: String = r.get(1)?; - let stdout_preview: Option = r.get(2)?; - let stderr_preview: Option = r.get(3)?; - let created_at: i64 = r.get(4)?; - Ok(json!({"id": id, "execution_id": execution_id, "stdout_preview": stdout_preview, "stderr_preview": stderr_preview, "created_at": created_at })) - }).map_err(|_| "not found")?; - Ok(row) + conn.query_row( + "SELECT id, execution_id, stdout_preview, stderr_preview, \ + stdout_truncated, stderr_truncated, created_at \ + FROM receipts WHERE id = ?1", + rusqlite::params![receipt_id], + |row| { + Ok(ReceiptDto { + id: row.get(0)?, + execution_id: row.get(1)?, + stdout_preview: row.get(2)?, + stderr_preview: row.get(3)?, + stdout_truncated: row.get::<_, i64>(4)? != 0, + stderr_truncated: row.get::<_, i64>(5)? != 0, + created_at: row.get(6)?, + }) + }, + ) + .optional() + .map_err(|error| BridgeError::database("query receipt", error))? + .ok_or_else(|| BridgeError::new("not_found", "receipt not found")) } -#[tauri::command] -pub fn list_contracts() -> Result { +pub fn list_contracts_service(session_id: Option<&str>) -> Result, BridgeError> { let conn = open_conn()?; + let sql = match session_id { + Some(_) => { + "SELECT id, session_id, title, description, state, version, created_at \ + FROM outcome_contracts WHERE session_id = ?1 ORDER BY created_at DESC, id DESC" + } + None => { + "SELECT id, session_id, title, description, state, version, created_at \ + FROM outcome_contracts ORDER BY created_at DESC, id DESC" + } + }; let mut stmt = conn - .prepare("SELECT id, session_id, title, description, state, version, created_at FROM outcome_contracts ORDER BY created_at DESC") - .map_err(|_| "query error")?; - let mut rows = stmt.query([]).map_err(|_| "query error")?; - let mut out = Vec::new(); - while let Some(r) = rows.next().map_err(|_| "row error")? { - let id: String = r.get(0).unwrap_or_default(); - let session_id: String = r.get(1).unwrap_or_default(); - let title: String = r.get(2).unwrap_or_default(); - let description: Option = r.get(3).ok(); - let state: String = r.get(4).unwrap_or_default(); - let version: i64 = r.get(5).unwrap_or(1); - let created_at: i64 = r.get(6).unwrap_or(0); - out.push(json!({"id": id, "session_id": session_id, "title": title, "description": description, "state": state, "version": version, "created_at": created_at })); - } - Ok(serde_json::Value::Array(out)) + .prepare(sql) + .map_err(|error| BridgeError::database("prepare contract query", error))?; + + let map_row = |row: &rusqlite::Row<'_>| -> rusqlite::Result { + Ok(ContractDto { + id: row.get(0)?, + session_id: row.get(1)?, + title: row.get(2)?, + description: row.get(3)?, + state: row.get(4)?, + version: row.get(5)?, + created_at: row.get(6)?, + }) + }; + + let contracts = if let Some(session_id) = session_id { + stmt.query_map(rusqlite::params![session_id], map_row) + .map_err(|error| BridgeError::database("query contracts", error))? + .collect::, _>>() + } else { + stmt.query_map([], map_row) + .map_err(|error| BridgeError::database("query contracts", error))? + .collect::, _>>() + }; + + contracts.map_err(|error| BridgeError::database("decode contract row", error)) } -#[tauri::command] -pub fn list_verification_runs(contract_id: &str) -> Result { +pub fn list_verification_runs_service( + contract_id: &str, +) -> Result, BridgeError> { let conn = open_conn()?; let mut stmt = conn - .prepare("SELECT id, check_id, phase, status, started_at, finished_at, receipt_id FROM verification_runs WHERE contract_id = ?1 ORDER BY started_at DESC") - .map_err(|_| "query error")?; - let mut rows = stmt - .query(rusqlite::params![contract_id]) - .map_err(|_| "query error")?; - let mut out = Vec::new(); - while let Some(r) = rows.next().map_err(|_| "row error")? { - let id: String = r.get(0).unwrap_or_default(); - let check_id: Option = r.get(1).ok(); - let phase: String = r.get(2).unwrap_or_default(); - let status: String = r.get(3).unwrap_or_default(); - let started_at: Option = r.get(4).ok(); - let finished_at: Option = r.get(5).ok(); - let receipt_id: Option = r.get(6).ok(); - out.push(json!({"id": id, "check_id": check_id, "phase": phase, "status": status, "started_at": started_at, "finished_at": finished_at, "receipt_id": receipt_id })); - } - Ok(serde_json::Value::Array(out)) + .prepare( + "SELECT id, check_id, phase, status, started_at, finished_at, receipt_id \ + FROM verification_runs WHERE contract_id = ?1 \ + ORDER BY started_at DESC, id DESC", + ) + .map_err(|error| BridgeError::database("prepare verification query", error))?; + + let rows = stmt + .query_map(rusqlite::params![contract_id], |row| { + Ok(VerificationRunDto { + id: row.get(0)?, + check_id: row.get(1)?, + phase: row.get(2)?, + status: row.get(3)?, + started_at: row.get(4)?, + finished_at: row.get(5)?, + receipt_id: row.get(6)?, + }) + }) + .map_err(|error| BridgeError::database("query verification runs", error))?; + + rows.collect::, _>>() + .map_err(|error| BridgeError::database("decode verification row", error)) } -#[tauri::command] -pub fn evaluate_contract(contract_id: &str) -> Result { +pub fn evaluate_contract_service(contract_id: &str) -> Result { let conn = open_conn()?; - match verification::evaluate_outcome(&conn, contract_id) { - Ok(s) => Ok(json!({"verdict": s})), - Err(_) => Err("evaluation failed".to_string()), - } + let verdict = verification::evaluate_outcome(&conn, contract_id) + .map_err(|error| BridgeError::new("evaluation_failed", error.to_string()))?; + Ok(VerdictDto { verdict }) +} + +#[tauri::command] +fn list_sessions() -> Result, BridgeError> { + list_sessions_service() +} + +#[tauri::command] +fn create_session(id: String) -> Result { + create_session_service(&id) +} + +#[tauri::command] +fn list_actions(session_id: String) -> Result, BridgeError> { + list_actions_service(&session_id) +} + +#[tauri::command] +fn get_receipt(receipt_id: String) -> Result { + get_receipt_service(&receipt_id) +} + +#[tauri::command] +fn list_contracts(session_id: Option) -> Result, BridgeError> { + list_contracts_service(session_id.as_deref()) +} + +#[tauri::command] +fn list_verification_runs( + contract_id: String, +) -> Result, BridgeError> { + list_verification_runs_service(&contract_id) +} + +#[tauri::command] +fn evaluate_contract(contract_id: String) -> Result { + evaluate_contract_service(&contract_id) } #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -161,3 +335,25 @@ pub fn run() { .run(tauri::generate_context!()) .expect("error while running tauri application"); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bridge_error_serializes_stably() { + let error = BridgeError::new("not_found", "missing"); + let json = serde_json::to_value(&error).unwrap(); + assert_eq!(json["code"], "not_found"); + assert_eq!(json["message"], "missing"); + } + + #[test] + fn verdict_dto_serializes_as_object() { + let value = serde_json::to_value(VerdictDto { + verdict: "VerifiedFix".to_string(), + }) + .unwrap(); + assert_eq!(value["verdict"], "VerifiedFix"); + } +} From 897610d341f2570cc0f02079e8fd1c529a987e6d Mon Sep 17 00:00:00 2001 From: t1k <154753100+t1ktakdev@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:20:22 +0600 Subject: [PATCH 14/73] test(bridge): use public service API in bridge tester --- tools/bridge_tester/src/main.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tools/bridge_tester/src/main.rs b/tools/bridge_tester/src/main.rs index d43a3ef..2833f80 100644 --- a/tools/bridge_tester/src/main.rs +++ b/tools/bridge_tester/src/main.rs @@ -1,11 +1,14 @@ -use reprodeck_lib::*; +use reprodeck_lib::list_sessions_service; fn main() { - match list_sessions() { - Ok(v) => println!( - "list_sessions ok: {}", - serde_json::to_string_pretty(&v).unwrap() + match list_sessions_service() { + Ok(sessions) => println!( + "list_sessions_service ok: {}", + serde_json::to_string_pretty(&sessions).expect("serialize sessions") ), - Err(e) => println!("list_sessions err: {}", e), + Err(error) => { + eprintln!("list_sessions_service error: {error}"); + std::process::exit(1); + } } } From 4db5151d76d7a71bf9165b3e599ed9b564e5161d Mon Sep 17 00:00:00 2001 From: t1k <154753100+t1ktakdev@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:22:09 +0600 Subject: [PATCH 15/73] style(bridge): apply rustfmt layout --- src-tauri/src/lib.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 91e4d00..2e06e2a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -308,9 +308,7 @@ fn list_contracts(session_id: Option) -> Result, Bridge } #[tauri::command] -fn list_verification_runs( - contract_id: String, -) -> Result, BridgeError> { +fn list_verification_runs(contract_id: String) -> Result, BridgeError> { list_verification_runs_service(&contract_id) } From 68574b1c65639fbd1d5ffea264618e55bd1f7d14 Mon Sep 17 00:00:00 2001 From: t1k <154753100+t1ktakdev@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:29:37 +0600 Subject: [PATCH 16/73] feat(timeline): add typed query APIs for desktop bridge --- crates/reprodeck-core/src/timeline.rs | 649 ++++++++++++++++---------- 1 file changed, 398 insertions(+), 251 deletions(-) diff --git a/crates/reprodeck-core/src/timeline.rs b/crates/reprodeck-core/src/timeline.rs index 15458ad..3e61e58 100644 --- a/crates/reprodeck-core/src/timeline.rs +++ b/crates/reprodeck-core/src/timeline.rs @@ -6,7 +6,7 @@ use std::time::{SystemTime, SystemTimeError, UNIX_EPOCH}; use thiserror::Error; use uuid::Uuid; -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct Action { pub id: String, pub session_id: String, @@ -17,12 +17,51 @@ pub struct Action { pub created_at: i64, } -pub fn create_action(conn: &Connection, a: &Action) -> Result<(), rusqlite::Error> { - conn.execute( - "INSERT INTO actions (id, session_id, parent_id, kind, meta, state, created_at) VALUES (?1,?2,?3,?4,?5,?6,?7)", - rusqlite::params![a.id, a.session_id, a.parent_id, a.kind, a.meta, a.state, a.created_at], - )?; - Ok(()) +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SessionRecord { + pub created_seq: i64, + pub id: String, + pub repo_id: Option, + pub created_at: i64, + pub updated_at: Option, + pub state: String, + pub meta: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ActionRecord { + pub created_seq: i64, + pub id: String, + pub session_id: String, + pub parent_id: Option, + pub kind: String, + pub meta: Option, + pub state: String, + pub created_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ExecutionRecord { + pub created_seq: i64, + pub id: String, + pub action_id: String, + pub status: String, + pub started_at: i64, + pub finished_at: Option, + pub duration_ms: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ReceiptRecord { + pub created_seq: i64, + pub id: String, + pub execution_id: String, + pub summary: Option, + pub stdout_preview: Option, + pub stderr_preview: Option, + pub stdout_truncated: bool, + pub stderr_truncated: bool, + pub created_at: i64, } #[derive(Debug, Error)] @@ -33,12 +72,21 @@ pub enum TimelineError { Clock(#[from] SystemTimeError), #[error("execution not found: {0}")] ExecutionNotFound(String), + #[error("pagination limit must be between 1 and 500")] + InvalidLimit, } fn unix_time_secs() -> Result { Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as i64) } +fn checked_limit(limit: usize) -> Result { + if !(1..=500).contains(&limit) { + return Err(TimelineError::InvalidLimit); + } + Ok(limit as i64) +} + fn bearer_regex() -> &'static Regex { static RE: OnceLock = OnceLock::new(); RE.get_or_init(|| { @@ -85,29 +133,30 @@ fn long_token_regex() -> &'static Regex { } fn sanitize_preview(input: &str) -> String { - let mut s = bearer_regex().replace_all(input, "[REDACTED]").into_owned(); - s = key_value_regex() - .replace_all(&s, "$1=[REDACTED]") + let mut value = bearer_regex() + .replace_all(input, "[REDACTED]") .into_owned(); - s = jwt_regex().replace_all(&s, "[REDACTED_JWT]").into_owned(); - s = aws_key_regex() - .replace_all(&s, "[REDACTED_AWS_KEY]") + value = key_value_regex() + .replace_all(&value, "$1=[REDACTED]") .into_owned(); - s = long_hex_regex() - .replace_all(&s, "[REDACTED_TOKEN]") + value = jwt_regex() + .replace_all(&value, "[REDACTED_JWT]") + .into_owned(); + value = aws_key_regex() + .replace_all(&value, "[REDACTED_AWS_KEY]") + .into_owned(); + value = long_hex_regex() + .replace_all(&value, "[REDACTED_TOKEN]") .into_owned(); long_token_regex() - .replace_all(&s, "[REDACTED_TOKEN]") + .replace_all(&value, "[REDACTED_TOKEN]") .into_owned() } -/// Truncate a UTF-8 string to at most `max_bytes` without slicing inside a -/// multi-byte scalar value. fn truncate_utf8_bytes(input: &str, max_bytes: usize) -> (String, bool) { if input.len() <= max_bytes { return (input.to_owned(), false); } - let mut end = max_bytes.min(input.len()); while end > 0 && !input.is_char_boundary(end) { end -= 1; @@ -133,26 +182,172 @@ pub fn get_session( conn: &Connection, public_id: &str, ) -> Result, TimelineError> { - let mut stmt = conn.prepare("SELECT id, created_at FROM sessions WHERE id = ?1")?; - let mut rows = stmt.query(rusqlite::params![public_id])?; - if let Some(r) = rows.next()? { - let id: String = r.get(0)?; - let created_at: i64 = r.get(1)?; - Ok(Some((id, created_at))) - } else { - Ok(None) - } + Ok(conn + .query_row( + "SELECT id, created_at FROM sessions WHERE id = ?1", + rusqlite::params![public_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional()?) +} + +pub fn get_session_record( + conn: &Connection, + public_id: &str, +) -> Result, TimelineError> { + Ok(conn + .query_row( + "SELECT created_seq, id, repo_id, created_at, updated_at, state, meta FROM sessions WHERE id = ?1", + rusqlite::params![public_id], + |row| { + Ok(SessionRecord { + created_seq: row.get(0)?, + id: row.get(1)?, + repo_id: row.get(2)?, + created_at: row.get(3)?, + updated_at: row.get(4)?, + state: row.get(5)?, + meta: row.get(6)?, + }) + }, + ) + .optional()?) +} + +pub fn list_sessions( + conn: &Connection, + before_seq: Option, + limit: usize, +) -> Result, TimelineError> { + let limit = checked_limit(limit)?; + let cursor = before_seq.unwrap_or(i64::MAX); + let mut stmt = conn.prepare( + "SELECT created_seq, id, repo_id, created_at, updated_at, state, meta FROM sessions WHERE created_seq < ?1 ORDER BY created_seq DESC LIMIT ?2", + )?; + let rows = stmt.query_map(rusqlite::params![cursor, limit], |row| { + Ok(SessionRecord { + created_seq: row.get(0)?, + id: row.get(1)?, + repo_id: row.get(2)?, + created_at: row.get(3)?, + updated_at: row.get(4)?, + state: row.get(5)?, + meta: row.get(6)?, + }) + })?; + Ok(rows.collect::, _>>()?) +} + +pub fn create_action(conn: &Connection, action: &Action) -> Result<(), rusqlite::Error> { + conn.execute( + "INSERT INTO actions (id, session_id, parent_id, kind, meta, state, created_at) VALUES (?1,?2,?3,?4,?5,?6,?7)", + rusqlite::params![action.id, action.session_id, action.parent_id, action.kind, action.meta, action.state, action.created_at], + )?; + Ok(()) +} + +pub fn get_action( + conn: &Connection, + action_id: &str, +) -> Result, TimelineError> { + Ok(conn + .query_row( + "SELECT created_seq, id, session_id, parent_id, kind, meta, state, created_at FROM actions WHERE id = ?1", + rusqlite::params![action_id], + |row| { + Ok(ActionRecord { + created_seq: row.get(0)?, + id: row.get(1)?, + session_id: row.get(2)?, + parent_id: row.get(3)?, + kind: row.get(4)?, + meta: row.get(5)?, + state: row.get(6)?, + created_at: row.get(7)?, + }) + }, + ) + .optional()?) +} + +pub fn list_actions( + conn: &Connection, + session_id: &str, + before_seq: Option, + limit: usize, +) -> Result, TimelineError> { + let limit = checked_limit(limit)?; + let cursor = before_seq.unwrap_or(i64::MAX); + let mut stmt = conn.prepare( + "SELECT created_seq, id, session_id, parent_id, kind, meta, state, created_at FROM actions WHERE session_id = ?1 AND created_seq < ?2 ORDER BY created_seq DESC LIMIT ?3", + )?; + let rows = stmt.query_map(rusqlite::params![session_id, cursor, limit], |row| { + Ok(ActionRecord { + created_seq: row.get(0)?, + id: row.get(1)?, + session_id: row.get(2)?, + parent_id: row.get(3)?, + kind: row.get(4)?, + meta: row.get(5)?, + state: row.get(6)?, + created_at: row.get(7)?, + }) + })?; + Ok(rows.collect::, _>>()?) } pub fn start_execution(conn: &Connection, action_id: &str) -> Result { - let exec_id = Uuid::new_v4().to_string(); + let execution_id = Uuid::new_v4().to_string(); let now = unix_time_secs()?; - conn.execute( "INSERT INTO executions (id, action_id, status, started_at) VALUES (?1,?2,?3,?4)", - rusqlite::params![exec_id, action_id, "Running", now], + rusqlite::params![execution_id, action_id, "Running", now], )?; - Ok(exec_id) + Ok(execution_id) +} + +pub fn get_execution( + conn: &Connection, + execution_id: &str, +) -> Result, TimelineError> { + Ok(conn + .query_row( + "SELECT created_seq, id, action_id, status, started_at, finished_at, duration_ms FROM executions WHERE id = ?1", + rusqlite::params![execution_id], + |row| { + Ok(ExecutionRecord { + created_seq: row.get(0)?, + id: row.get(1)?, + action_id: row.get(2)?, + status: row.get(3)?, + started_at: row.get(4)?, + finished_at: row.get(5)?, + duration_ms: row.get(6)?, + }) + }, + ) + .optional()?) +} + +pub fn list_executions( + conn: &Connection, + action_id: &str, +) -> Result, TimelineError> { + let mut stmt = conn.prepare( + "SELECT created_seq, id, action_id, status, started_at, finished_at, duration_ms FROM executions WHERE action_id = ?1 ORDER BY created_seq ASC", + )?; + let rows = stmt.query_map(rusqlite::params![action_id], |row| { + Ok(ExecutionRecord { + created_seq: row.get(0)?, + id: row.get(1)?, + action_id: row.get(2)?, + status: row.get(3)?, + started_at: row.get(4)?, + finished_at: row.get(5)?, + duration_ms: row.get(6)?, + }) + })?; + Ok(rows.collect::, _>>()?) } pub(crate) fn finish_execution_in_transaction( @@ -165,16 +360,15 @@ pub(crate) fn finish_execution_in_transaction( let now = unix_time_secs()?; let started_at = tx .query_row( - "SELECT started_at FROM executions WHERE id = ?1", + "SELECT started_at FROM executions WHERE id = ?1 AND finished_at IS NULL", rusqlite::params![execution_id], - |r| r.get::<_, i64>(0), + |row| row.get::<_, i64>(0), ) .optional()? .ok_or_else(|| TimelineError::ExecutionNotFound(execution_id.to_owned()))?; let duration_ms = now.saturating_sub(started_at).saturating_mul(1000); - let updated = tx.execute( - "UPDATE executions SET status = ?1, finished_at = ?2, duration_ms = ?3 WHERE id = ?4", + "UPDATE executions SET status = ?1, finished_at = ?2, duration_ms = ?3 WHERE id = ?4 AND finished_at IS NULL", rusqlite::params![status, now, duration_ms, execution_id], )?; if updated != 1 { @@ -183,18 +377,18 @@ pub(crate) fn finish_execution_in_transaction( const MAX_PREVIEW: usize = 1024; let (stdout_preview, stdout_truncated) = match stdout_preview { - Some(s) => { - let sanitized = sanitize_preview(s); + Some(value) => { + let sanitized = sanitize_preview(value); let (bounded, truncated) = truncate_utf8_bytes(&sanitized, MAX_PREVIEW); - (Some(bounded), if truncated { 1_i64 } else { 0_i64 }) + (Some(bounded), i64::from(truncated)) } None => (None, 0), }; let (stderr_preview, stderr_truncated) = match stderr_preview { - Some(s) => { - let sanitized = sanitize_preview(s); + Some(value) => { + let sanitized = sanitize_preview(value); let (bounded, truncated) = truncate_utf8_bytes(&sanitized, MAX_PREVIEW); - (Some(bounded), if truncated { 1_i64 } else { 0_i64 }) + (Some(bounded), i64::from(truncated)) } None => (None, 0), }; @@ -204,11 +398,9 @@ pub(crate) fn finish_execution_in_transaction( "INSERT INTO receipts (id, execution_id, summary, stdout_preview, stderr_preview, stdout_truncated, stderr_truncated, created_at) VALUES (?1,?2,?3,?4,?5,?6,?7,?8)", rusqlite::params![receipt_id, execution_id, "", stdout_preview, stderr_preview, stdout_truncated, stderr_truncated, now], )?; - Ok(receipt_id) } -/// Finish an execution and create its receipt in a single transaction. pub fn finish_execution( conn: &mut Connection, execution_id: &str, @@ -217,276 +409,231 @@ pub fn finish_execution( stderr_preview: Option<&str>, ) -> Result { let tx = conn.transaction()?; - let receipt_id = - finish_execution_in_transaction(&tx, execution_id, status, stdout_preview, stderr_preview)?; + let receipt_id = finish_execution_in_transaction(&tx, execution_id, status, stdout_preview, stderr_preview)?; tx.commit()?; Ok(receipt_id) } -/// Recovery: mark Running -> Interrupted on startup. +pub fn get_receipt( + conn: &Connection, + receipt_id: &str, +) -> Result, TimelineError> { + Ok(conn + .query_row( + "SELECT created_seq, id, execution_id, summary, stdout_preview, stderr_preview, stdout_truncated, stderr_truncated, created_at FROM receipts WHERE id = ?1", + rusqlite::params![receipt_id], + |row| { + Ok(ReceiptRecord { + created_seq: row.get(0)?, + id: row.get(1)?, + execution_id: row.get(2)?, + summary: row.get(3)?, + stdout_preview: row.get(4)?, + stderr_preview: row.get(5)?, + stdout_truncated: row.get::<_, i64>(6)? != 0, + stderr_truncated: row.get::<_, i64>(7)? != 0, + created_at: row.get(8)?, + }) + }, + ) + .optional()?) +} + +pub fn list_receipts( + conn: &Connection, + execution_id: &str, +) -> Result, TimelineError> { + let mut stmt = conn.prepare( + "SELECT created_seq, id, execution_id, summary, stdout_preview, stderr_preview, stdout_truncated, stderr_truncated, created_at FROM receipts WHERE execution_id = ?1 ORDER BY created_seq ASC", + )?; + let rows = stmt.query_map(rusqlite::params![execution_id], |row| { + Ok(ReceiptRecord { + created_seq: row.get(0)?, + id: row.get(1)?, + execution_id: row.get(2)?, + summary: row.get(3)?, + stdout_preview: row.get(4)?, + stderr_preview: row.get(5)?, + stdout_truncated: row.get::<_, i64>(6)? != 0, + stderr_truncated: row.get::<_, i64>(7)? != 0, + created_at: row.get(8)?, + }) + })?; + Ok(rows.collect::, _>>()?) +} + pub fn recover_running(conn: &mut Connection) -> Result { let tx = conn.transaction()?; - let res = tx.execute( - "UPDATE executions SET status = 'Interrupted' WHERE status = 'Running' AND finished_at IS NULL", + let changed = tx.execute( + "UPDATE executions SET status = 'Interrupted', finished_at = COALESCE(finished_at, started_at), duration_ms = COALESCE(duration_ms, 0) WHERE status = 'Running' AND finished_at IS NULL", [], )?; tx.commit()?; - Ok(res) + Ok(changed) } #[cfg(test)] -#[allow(unused_mut)] mod tests { use super::*; use crate::db::init_db; use tempfile::NamedTempFile; - #[test] - fn create_and_read_action() { - let tmp = NamedTempFile::new().unwrap(); - let path = tmp.path(); - let mut conn = init_db(path).expect("init db"); + fn setup_session(conn: &Connection, id: &str) { + conn.execute( + "INSERT INTO sessions(id, repo_id, created_at, updated_at, state) VALUES (?1,?2,?3,?4,?5)", + rusqlite::params![id, "repo", 1, 1, "Active"], + ) + .unwrap(); + } - let a = Action { - id: "act-1".to_string(), - session_id: "s-1".to_string(), + fn action(id: &str, session_id: &str) -> Action { + Action { + id: id.to_string(), + session_id: session_id.to_string(), parent_id: None, - kind: "test".to_string(), + kind: "command".to_string(), meta: Some("{}".to_string()), state: "Created".to_string(), created_at: 1, - }; - - conn.execute("INSERT INTO sessions(id, repo_id, created_at, updated_at, state) VALUES (?1,?2,?3,?4,?5)", rusqlite::params!["s-1","repo-x",1,1,"Active"]).unwrap(); - - create_action(&conn, &a).expect("insert"); + } + } - let mut stmt = conn - .prepare("SELECT id, session_id, kind FROM actions WHERE id = ?1") - .unwrap(); - let mut rows = stmt.query(rusqlite::params!["act-1"]).unwrap(); - let row = rows.next().unwrap().unwrap(); - let id: String = row.get(0).unwrap(); - assert_eq!(id, "act-1"); + #[test] + fn create_and_read_action() { + let tmp = NamedTempFile::new().unwrap(); + let conn = init_db(tmp.path()).unwrap(); + setup_session(&conn, "s-1"); + create_action(&conn, &action("act-1", "s-1")).unwrap(); + assert_eq!(get_action(&conn, "act-1").unwrap().unwrap().id, "act-1"); } #[test] fn duplicate_uuid_rejected() { let tmp = NamedTempFile::new().unwrap(); - let path = tmp.path(); - let mut conn = crate::db::init_db(path).expect("init db"); - conn.execute("INSERT INTO sessions(id, repo_id, created_at, updated_at, state) VALUES (?1,?2,?3,?4,?5)", rusqlite::params!["dup-s","r",1,1,"Active"]).unwrap(); - let res = conn.execute("INSERT INTO sessions(id, repo_id, created_at, updated_at, state) VALUES (?1,?2,?3,?4,?5)", rusqlite::params!["dup-s","r",2,2,"Active"]); - assert!(res.is_err()); + let conn = init_db(tmp.path()).unwrap(); + setup_session(&conn, "dup-s"); + assert!(conn.execute( + "INSERT INTO sessions(id, repo_id, created_at, updated_at, state) VALUES (?1,?2,?3,?4,?5)", + rusqlite::params!["dup-s", "r", 2, 2, "Active"], + ).is_err()); } #[test] - fn action_ordering_and_pagination() { + fn stable_action_pagination_has_no_overlap() { let tmp = NamedTempFile::new().unwrap(); - let path = tmp.path(); - let mut conn = crate::db::init_db(path).expect("init db"); - conn.execute("INSERT INTO sessions(id, repo_id, created_at, updated_at, state) VALUES (?1,?2,?3,?4,?5)", rusqlite::params!["s-pag","r",1,1,"Active"]).unwrap(); - + let conn = init_db(tmp.path()).unwrap(); + setup_session(&conn, "s-pag"); for i in 0..5 { - let id = format!("a-{}", i); - conn.execute("INSERT INTO actions(id, session_id, kind, state, created_at) VALUES (?1,?2,?3,?4,?5)", rusqlite::params![id, "s-pag", "k", "Created", 1000]).unwrap(); + create_action(&conn, &action(&format!("a-{i}"), "s-pag")).unwrap(); } + let first = list_actions(&conn, "s-pag", None, 2).unwrap(); + let second = list_actions(&conn, "s-pag", Some(first.last().unwrap().created_seq), 10).unwrap(); + assert_eq!(first.len(), 2); + assert_eq!(second.len(), 3); + assert!(first.iter().all(|a| second.iter().all(|b| a.id != b.id))); + } + + #[test] + fn invalid_pagination_limit_rejected() { + let tmp = NamedTempFile::new().unwrap(); + let conn = init_db(tmp.path()).unwrap(); + assert!(matches!(list_sessions(&conn, None, 0), Err(TimelineError::InvalidLimit))); + assert!(matches!(list_sessions(&conn, None, 501), Err(TimelineError::InvalidLimit))); + } - let mut stmt = conn - .prepare("SELECT id FROM actions WHERE session_id = ?1 ORDER BY created_seq LIMIT 2") - .unwrap(); - let rows = stmt - .query_map(rusqlite::params!["s-pag"], |r| r.get::<_, String>(0)) - .unwrap(); - let ids: Vec = rows.map(|r| r.unwrap()).collect(); - assert_eq!(ids.len(), 2); - - let mut stmt2 = conn.prepare("SELECT id FROM actions WHERE session_id = ?1 AND created_seq > (SELECT created_seq FROM actions WHERE id = ?2) ORDER BY created_seq LIMIT 10").unwrap(); - let rows2 = stmt2 - .query_map(rusqlite::params!["s-pag", ids.last().unwrap()], |r| { - r.get::<_, String>(0) - }) - .unwrap(); - let ids2: Vec = rows2.map(|r| r.unwrap()).collect(); - assert!(!ids2.is_empty()); + #[test] + fn execution_and_receipt_query_round_trip() { + let tmp = NamedTempFile::new().unwrap(); + let mut conn = init_db(tmp.path()).unwrap(); + setup_session(&conn, "s-roundtrip"); + create_action(&conn, &action("a-roundtrip", "s-roundtrip")).unwrap(); + let execution_id = start_execution(&conn, "a-roundtrip").unwrap(); + let receipt_id = finish_execution(&mut conn, &execution_id, "Succeeded", Some("hello"), Some("warning")).unwrap(); + let execution = get_execution(&conn, &execution_id).unwrap().unwrap(); + let receipt = get_receipt(&conn, &receipt_id).unwrap().unwrap(); + assert_eq!(execution.status, "Succeeded"); + assert_eq!(receipt.execution_id, execution_id); + assert_eq!(receipt.stdout_preview.as_deref(), Some("hello")); + assert_eq!(list_receipts(&conn, &execution.id).unwrap().len(), 1); + } + + #[test] + fn finishing_execution_twice_is_rejected() { + let tmp = NamedTempFile::new().unwrap(); + let mut conn = init_db(tmp.path()).unwrap(); + setup_session(&conn, "s-double"); + create_action(&conn, &action("a-double", "s-double")).unwrap(); + let execution_id = start_execution(&conn, "a-double").unwrap(); + finish_execution(&mut conn, &execution_id, "Succeeded", None, None).unwrap(); + assert!(matches!(finish_execution(&mut conn, &execution_id, "Succeeded", None, None), Err(TimelineError::ExecutionNotFound(_)))); } #[test] fn sanitization_precedes_persistence() { let tmp = NamedTempFile::new().unwrap(); - let path = tmp.path(); - let mut conn = crate::db::init_db(path).expect("init db"); + let mut conn = init_db(tmp.path()).unwrap(); create_session(&conn, "s-sec", "Active", None).unwrap(); - let a = Action { - id: "asec".to_string(), - session_id: "s-sec".to_string(), - parent_id: None, - kind: "k".to_string(), - meta: None, - state: "Created".to_string(), - created_at: 1, - }; - create_action(&conn, &a).unwrap(); - let exec_id = start_execution(&conn, "asec").unwrap(); - let token = "This has Bearer abcdef12345== inside"; - let receipt = - finish_execution(&mut conn, &exec_id, "Succeeded", Some(token), None).unwrap(); - let stored: String = conn - .query_row( - "SELECT stdout_preview FROM receipts WHERE id = ?1", - rusqlite::params![receipt], - |r| r.get(0), - ) - .unwrap(); - assert!(!stored.contains("abcdef12345")); - assert!(stored.contains("[REDACTED]") || stored.contains("REDACTED")); + create_action(&conn, &action("asec", "s-sec")).unwrap(); + let execution_id = start_execution(&conn, "asec").unwrap(); + let receipt_id = finish_execution(&mut conn, &execution_id, "Succeeded", Some("Bearer abcdef12345== password=hunter2"), None).unwrap(); + let preview = get_receipt(&conn, &receipt_id).unwrap().unwrap().stdout_preview.unwrap(); + assert!(!preview.contains("abcdef12345")); + assert!(!preview.contains("hunter2")); + assert!(preview.contains("REDACTED")); } #[test] fn sanitization_detects_jwt_and_aws() { let tmp = NamedTempFile::new().unwrap(); - let path = tmp.path(); - let mut conn = crate::db::init_db(path).expect("init db"); + let mut conn = init_db(tmp.path()).unwrap(); create_session(&conn, "s-sec2", "Active", None).unwrap(); - let a = Action { - id: "ajwt".to_string(), - session_id: "s-sec2".to_string(), - parent_id: None, - kind: "k".to_string(), - meta: None, - state: "Created".to_string(), - created_at: 1, - }; - create_action(&conn, &a).unwrap(); - let exec_id = start_execution(&conn, "ajwt").unwrap(); - let jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.sgnature"; + create_action(&conn, &action("ajwt", "s-sec2")).unwrap(); + let execution_id = start_execution(&conn, "ajwt").unwrap(); + let jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signature"; let aws = format!("AKIA{}", "A".repeat(16)); - let input = format!("start {} middle {} end", jwt, aws); - let receipt = - finish_execution(&mut conn, &exec_id, "Succeeded", Some(&input), None).unwrap(); - let stored: String = conn - .query_row( - "SELECT stdout_preview FROM receipts WHERE id = ?1", - rusqlite::params![receipt], - |r| r.get(0), - ) - .unwrap(); - assert!(!stored.contains("eyJhbGci")); - assert!(!stored.contains("AKIA")); - assert!(stored.contains("REDACTED_JWT") || stored.contains("REDACTED")); - assert!( - stored.contains("REDACTED_AWS_KEY") - || stored.contains("REDACTED_TOKEN") - || stored.contains("REDACTED") - ); + let input = format!("start {jwt} middle {aws} end"); + let receipt_id = finish_execution(&mut conn, &execution_id, "Succeeded", Some(&input), None).unwrap(); + let preview = get_receipt(&conn, &receipt_id).unwrap().unwrap().stdout_preview.unwrap(); + assert!(!preview.contains("eyJhbGci")); + assert!(!preview.contains("AKIA")); } #[test] fn unicode_preview_truncation_is_utf8_safe() { let tmp = NamedTempFile::new().unwrap(); - let path = tmp.path(); - let mut conn = crate::db::init_db(path).expect("init db"); + let mut conn = init_db(tmp.path()).unwrap(); create_session(&conn, "s-unicode", "Active", None).unwrap(); - let a = Action { - id: "a-unicode".to_string(), - session_id: "s-unicode".to_string(), - parent_id: None, - kind: "command".to_string(), - meta: None, - state: "Created".to_string(), - created_at: 1, - }; - create_action(&conn, &a).unwrap(); - let exec_id = start_execution(&conn, &a.id).unwrap(); - let unicode_output = format!("{}{}", "😀".repeat(300), "русский-текст".repeat(50)); - - let receipt = finish_execution( - &mut conn, - &exec_id, - "Succeeded", - Some(&unicode_output), - None, - ) - .unwrap(); - - let (stored, truncated): (String, i64) = conn - .query_row( - "SELECT stdout_preview, stdout_truncated FROM receipts WHERE id = ?1", - rusqlite::params![receipt], - |r| Ok((r.get(0)?, r.get(1)?)), - ) - .unwrap(); - assert!(stored.len() <= MAX_PREVIEW_FOR_TEST); - assert_eq!(truncated, 1); - assert!(stored.is_char_boundary(stored.len())); + create_action(&conn, &action("a-unicode", "s-unicode")).unwrap(); + let execution_id = start_execution(&conn, "a-unicode").unwrap(); + let output = format!("{}{}", "😀".repeat(300), "русский-текст".repeat(50)); + let receipt_id = finish_execution(&mut conn, &execution_id, "Succeeded", Some(&output), None).unwrap(); + let receipt = get_receipt(&conn, &receipt_id).unwrap().unwrap(); + let preview = receipt.stdout_preview.unwrap(); + assert!(preview.len() <= 1024); + assert!(receipt.stdout_truncated); + assert!(preview.is_char_boundary(preview.len())); } - const MAX_PREVIEW_FOR_TEST: usize = 1024; - #[test] - fn session_action_foreign_key() { + fn session_action_foreign_key_cascades() { let tmp = NamedTempFile::new().unwrap(); - let path = tmp.path(); - let mut conn = crate::db::init_db(path).expect("init db"); - - create_session(&conn, "s-123", "Active", Some("{}")).expect("create session"); - - let a = Action { - id: "act-2".to_string(), - session_id: "s-123".to_string(), - parent_id: None, - kind: "test".to_string(), - meta: None, - state: "Created".to_string(), - created_at: 1, - }; - - create_action(&conn, &a).expect("insert action"); - - conn.execute( - "DELETE FROM sessions WHERE id = ?1", - rusqlite::params!["s-123"], - ) - .unwrap(); - let count: i64 = conn - .query_row( - "SELECT COUNT(*) FROM actions WHERE id = ?1", - rusqlite::params!["act-2"], - |r| r.get(0), - ) - .unwrap(); - assert_eq!(count, 0); + let conn = init_db(tmp.path()).unwrap(); + create_session(&conn, "s-123", "Active", Some("{}")).unwrap(); + create_action(&conn, &action("act-2", "s-123")).unwrap(); + conn.execute("DELETE FROM sessions WHERE id = ?1", rusqlite::params!["s-123"]).unwrap(); + assert!(get_action(&conn, "act-2").unwrap().is_none()); } #[test] fn running_to_interrupted_on_recover() { let tmp = NamedTempFile::new().unwrap(); - let path = tmp.path(); - let mut conn = crate::db::init_db(path).expect("init db"); - + let mut conn = init_db(tmp.path()).unwrap(); create_session(&conn, "s-rcv", "Active", None).unwrap(); - let a = Action { - id: "act-r".to_string(), - session_id: "s-rcv".to_string(), - parent_id: None, - kind: "k".to_string(), - meta: None, - state: "Created".to_string(), - created_at: 1, - }; - create_action(&conn, &a).unwrap(); - - let exec_id = start_execution(&conn, "act-r").unwrap(); - let changed = recover_running(&mut conn).unwrap(); - assert!(changed >= 1); - - let status: String = conn - .query_row( - "SELECT status FROM executions WHERE id = ?1", - rusqlite::params![exec_id], - |r| r.get(0), - ) - .unwrap(); - assert_eq!(status, "Interrupted"); + create_action(&conn, &action("act-r", "s-rcv")).unwrap(); + let execution_id = start_execution(&conn, "act-r").unwrap(); + assert_eq!(recover_running(&mut conn).unwrap(), 1); + let execution = get_execution(&conn, &execution_id).unwrap().unwrap(); + assert_eq!(execution.status, "Interrupted"); + assert!(execution.finished_at.is_some()); } } From c36707b7ec73a935505bc0e0c25223cd38f6ce62 Mon Sep 17 00:00:00 2001 From: t1k <154753100+t1ktakdev@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:30:29 +0600 Subject: [PATCH 17/73] refactor(bridge): route timeline reads through core APIs --- src-tauri/src/lib.rs | 182 +++++++++++++++++++------------------------ 1 file changed, 82 insertions(+), 100 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2e06e2a..b7486b3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,5 +1,4 @@ use reprodeck_core::{db, timeline, verification}; -use rusqlite::OptionalExtension; use serde::{Deserialize, Serialize}; use std::fmt::{self, Display}; use std::path::PathBuf; @@ -18,8 +17,8 @@ impl BridgeError { } } - fn database(context: &str, error: impl Display) -> Self { - Self::new("database_error", format!("{context}: {error}")) + fn database(context: &str) -> Self { + Self::new("database_error", context) } } @@ -35,7 +34,7 @@ impl std::error::Error for BridgeError {} pub struct SessionDto { pub id: String, pub created_at: i64, - pub updated_at: i64, + pub updated_at: Option, pub state: String, pub meta: Option, } @@ -76,7 +75,7 @@ pub struct VerificationRunDto { pub check_id: Option, pub phase: String, pub status: String, - pub started_at: i64, + pub started_at: Option, pub finished_at: Option, pub receipt_id: Option, } @@ -86,6 +85,43 @@ pub struct VerdictDto { pub verdict: String, } +impl From for SessionDto { + fn from(value: timeline::SessionRecord) -> Self { + Self { + id: value.id, + created_at: value.created_at, + updated_at: value.updated_at, + state: value.state, + meta: value.meta, + } + } +} + +impl From for ActionDto { + fn from(value: timeline::ActionRecord) -> Self { + Self { + id: value.id, + kind: value.kind, + state: value.state, + created_at: value.created_at, + } + } +} + +impl From for ReceiptDto { + fn from(value: timeline::ReceiptRecord) -> Self { + Self { + id: value.id, + execution_id: value.execution_id, + stdout_preview: value.stdout_preview, + stderr_preview: value.stderr_preview, + stdout_truncated: value.stdout_truncated, + stderr_truncated: value.stderr_truncated, + created_at: value.created_at, + } + } +} + fn app_db_path() -> PathBuf { if let Some(base) = directories::BaseDirs::new() { let mut path = base.data_local_dir().to_path_buf(); @@ -99,32 +135,15 @@ fn app_db_path() -> PathBuf { } fn open_conn() -> Result { - db::init_db(&app_db_path()).map_err(|error| BridgeError::database("initialize database", error)) + db::init_db(&app_db_path()) + .map_err(|_| BridgeError::database("Unable to initialize ReproDeck storage.")) } pub fn list_sessions_service() -> Result, BridgeError> { let conn = open_conn()?; - let mut stmt = conn - .prepare( - "SELECT id, created_at, updated_at, state, meta \ - FROM sessions ORDER BY created_at DESC, id DESC", - ) - .map_err(|error| BridgeError::database("prepare session query", error))?; - - let rows = stmt - .query_map([], |row| { - Ok(SessionDto { - id: row.get(0)?, - created_at: row.get(1)?, - updated_at: row.get(2)?, - state: row.get(3)?, - meta: row.get(4)?, - }) - }) - .map_err(|error| BridgeError::database("query sessions", error))?; - - rows.collect::, _>>() - .map_err(|error| BridgeError::database("decode session row", error)) + timeline::list_sessions(&conn, None, 200) + .map(|values| values.into_iter().map(SessionDto::from).collect()) + .map_err(|_| BridgeError::database("Unable to list sessions.")) } pub fn create_session_service(id: &str) -> Result { @@ -132,93 +151,47 @@ pub fn create_session_service(id: &str) -> Result { if id.is_empty() { return Err(BridgeError::new( "invalid_request", - "session id must not be empty", + "Session id must not be empty.", )); } let conn = open_conn()?; timeline::create_session(&conn, id, "Active", None) - .map_err(|error| BridgeError::database("create session", error))?; - - conn.query_row( - "SELECT id, created_at, updated_at, state, meta FROM sessions WHERE id = ?1", - rusqlite::params![id], - |row| { - Ok(SessionDto { - id: row.get(0)?, - created_at: row.get(1)?, - updated_at: row.get(2)?, - state: row.get(3)?, - meta: row.get(4)?, - }) - }, - ) - .map_err(|error| BridgeError::database("read created session", error)) + .map_err(|_| BridgeError::database("Unable to create the session."))?; + timeline::get_session_record(&conn, id) + .map_err(|_| BridgeError::database("Unable to read the created session."))? + .map(SessionDto::from) + .ok_or_else(|| BridgeError::database("Created session could not be loaded.")) } pub fn list_actions_service(session_id: &str) -> Result, BridgeError> { let conn = open_conn()?; - let mut stmt = conn - .prepare( - "SELECT id, kind, state, created_at FROM actions \ - WHERE session_id = ?1 ORDER BY created_seq DESC", - ) - .map_err(|error| BridgeError::database("prepare action query", error))?; - - let rows = stmt - .query_map(rusqlite::params![session_id], |row| { - Ok(ActionDto { - id: row.get(0)?, - kind: row.get(1)?, - state: row.get(2)?, - created_at: row.get(3)?, - }) - }) - .map_err(|error| BridgeError::database("query actions", error))?; - - rows.collect::, _>>() - .map_err(|error| BridgeError::database("decode action row", error)) + timeline::list_actions(&conn, session_id, None, 500) + .map(|values| values.into_iter().map(ActionDto::from).collect()) + .map_err(|_| BridgeError::database("Unable to load the session timeline.")) } pub fn get_receipt_service(receipt_id: &str) -> Result { let conn = open_conn()?; - conn.query_row( - "SELECT id, execution_id, stdout_preview, stderr_preview, \ - stdout_truncated, stderr_truncated, created_at \ - FROM receipts WHERE id = ?1", - rusqlite::params![receipt_id], - |row| { - Ok(ReceiptDto { - id: row.get(0)?, - execution_id: row.get(1)?, - stdout_preview: row.get(2)?, - stderr_preview: row.get(3)?, - stdout_truncated: row.get::<_, i64>(4)? != 0, - stderr_truncated: row.get::<_, i64>(5)? != 0, - created_at: row.get(6)?, - }) - }, - ) - .optional() - .map_err(|error| BridgeError::database("query receipt", error))? - .ok_or_else(|| BridgeError::new("not_found", "receipt not found")) + timeline::get_receipt(&conn, receipt_id) + .map_err(|_| BridgeError::database("Unable to load the receipt."))? + .map(ReceiptDto::from) + .ok_or_else(|| BridgeError::new("not_found", "Receipt not found.")) } pub fn list_contracts_service(session_id: Option<&str>) -> Result, BridgeError> { let conn = open_conn()?; let sql = match session_id { Some(_) => { - "SELECT id, session_id, title, description, state, version, created_at \ - FROM outcome_contracts WHERE session_id = ?1 ORDER BY created_at DESC, id DESC" + "SELECT id, session_id, title, description, state, version, created_at FROM outcome_contracts WHERE session_id = ?1 ORDER BY created_at DESC, id DESC" } None => { - "SELECT id, session_id, title, description, state, version, created_at \ - FROM outcome_contracts ORDER BY created_at DESC, id DESC" + "SELECT id, session_id, title, description, state, version, created_at FROM outcome_contracts ORDER BY created_at DESC, id DESC" } }; let mut stmt = conn .prepare(sql) - .map_err(|error| BridgeError::database("prepare contract query", error))?; + .map_err(|_| BridgeError::database("Unable to prepare the outcome query."))?; let map_row = |row: &rusqlite::Row<'_>| -> rusqlite::Result { Ok(ContractDto { @@ -234,15 +207,15 @@ pub fn list_contracts_service(session_id: Option<&str>) -> Result, _>>() } else { stmt.query_map([], map_row) - .map_err(|error| BridgeError::database("query contracts", error))? + .map_err(|_| BridgeError::database("Unable to query outcome contracts."))? .collect::, _>>() }; - contracts.map_err(|error| BridgeError::database("decode contract row", error)) + contracts.map_err(|_| BridgeError::database("Unable to decode outcome contracts.")) } pub fn list_verification_runs_service( @@ -251,11 +224,9 @@ pub fn list_verification_runs_service( let conn = open_conn()?; let mut stmt = conn .prepare( - "SELECT id, check_id, phase, status, started_at, finished_at, receipt_id \ - FROM verification_runs WHERE contract_id = ?1 \ - ORDER BY started_at DESC, id DESC", + "SELECT id, check_id, phase, status, started_at, finished_at, receipt_id FROM verification_runs WHERE contract_id = ?1 ORDER BY started_at DESC, id DESC", ) - .map_err(|error| BridgeError::database("prepare verification query", error))?; + .map_err(|_| BridgeError::database("Unable to prepare the verification query."))?; let rows = stmt .query_map(rusqlite::params![contract_id], |row| { @@ -269,16 +240,20 @@ pub fn list_verification_runs_service( receipt_id: row.get(6)?, }) }) - .map_err(|error| BridgeError::database("query verification runs", error))?; + .map_err(|_| BridgeError::database("Unable to query verification runs."))?; rows.collect::, _>>() - .map_err(|error| BridgeError::database("decode verification row", error)) + .map_err(|_| BridgeError::database("Unable to decode verification runs.")) } pub fn evaluate_contract_service(contract_id: &str) -> Result { let conn = open_conn()?; - let verdict = verification::evaluate_outcome(&conn, contract_id) - .map_err(|error| BridgeError::new("evaluation_failed", error.to_string()))?; + let verdict = verification::evaluate_outcome(&conn, contract_id).map_err(|_| { + BridgeError::new( + "evaluation_failed", + "Unable to evaluate this outcome contract.", + ) + })?; Ok(VerdictDto { verdict }) } @@ -354,4 +329,11 @@ mod tests { .unwrap(); assert_eq!(value["verdict"], "VerifiedFix"); } + + #[test] + fn bridge_error_does_not_require_internal_error_text() { + let error = BridgeError::database("Unable to load timeline."); + assert_eq!(error.code, "database_error"); + assert_eq!(error.message, "Unable to load timeline."); + } } From e014cafe42f06ffa6ed2de8e348ee19da6540437 Mon Sep 17 00:00:00 2001 From: t1k <154753100+t1ktakdev@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:32:15 +0600 Subject: [PATCH 18/73] feat(verification): complete typed query and summary APIs --- crates/reprodeck-core/src/verification.rs | 629 ++++++++++++++-------- 1 file changed, 401 insertions(+), 228 deletions(-) diff --git a/crates/reprodeck-core/src/verification.rs b/crates/reprodeck-core/src/verification.rs index d683967..541674d 100644 --- a/crates/reprodeck-core/src/verification.rs +++ b/crates/reprodeck-core/src/verification.rs @@ -24,6 +24,8 @@ pub enum VerificationError { InvalidFinishStatus(RunStatus), #[error("receipt {receipt_id} does not belong to verification run {run_id}")] ReceiptMismatch { run_id: String, receipt_id: String }, + #[error("invalid persisted verification value for {field}: {value}")] + InvalidPersistedState { field: &'static str, value: String }, } type Result = std::result::Result; @@ -32,7 +34,7 @@ fn unix_time_secs() -> Result { Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as i64) } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct OutcomeContract { pub id: String, pub session_id: String, @@ -44,7 +46,7 @@ pub struct OutcomeContract { pub updated_at: Option, } -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct VerificationCheck { pub id: String, pub contract_id: String, @@ -80,6 +82,34 @@ pub enum OutcomeState { Inconclusive, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct VerificationRun { + pub id: String, + pub contract_id: String, + pub check_id: Option, + pub phase: RunPhase, + pub status: RunStatus, + pub started_at: Option, + pub finished_at: Option, + pub duration_ms: Option, + pub receipt_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct VerificationCheckSummary { + pub check: VerificationCheck, + pub before: Option, + pub after: Option, + pub outcome: OutcomeState, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct OutcomeSummary { + pub contract_id: String, + pub overall: OutcomeState, + pub checks: Vec, +} + impl Display for RunPhase { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -113,18 +143,88 @@ impl Display for OutcomeState { } } -fn parse_run_status(value: &str) -> Option { +fn parse_run_phase(value: &str) -> Result { + match value { + "Before" => Ok(RunPhase::Before), + "After" => Ok(RunPhase::After), + _ => Err(VerificationError::InvalidPersistedState { + field: "phase", + value: value.to_owned(), + }), + } +} + +fn parse_run_status(value: &str) -> Result { match value { - "Pending" => Some(RunStatus::Pending), - "Running" => Some(RunStatus::Running), - "Passed" => Some(RunStatus::Passed), - "Failed" => Some(RunStatus::Failed), - "Error" => Some(RunStatus::Error), - "Interrupted" => Some(RunStatus::Interrupted), - _ => None, + "Pending" => Ok(RunStatus::Pending), + "Running" => Ok(RunStatus::Running), + "Passed" => Ok(RunStatus::Passed), + "Failed" => Ok(RunStatus::Failed), + "Error" => Ok(RunStatus::Error), + "Interrupted" => Ok(RunStatus::Interrupted), + _ => Err(VerificationError::InvalidPersistedState { + field: "status", + value: value.to_owned(), + }), } } +fn contract_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(OutcomeContract { + id: row.get(0)?, + session_id: row.get(1)?, + title: row.get(2)?, + description: row.get(3)?, + state: row.get(4)?, + version: row.get(5)?, + created_at: row.get(6)?, + updated_at: row.get(7)?, + }) +} + +fn check_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(VerificationCheck { + id: row.get(0)?, + contract_id: row.get(1)?, + stable_id: row.get(2)?, + description: row.get(3)?, + command_ref: row.get(4)?, + expected_condition: row.get(5)?, + required: row.get::<_, i64>(6)? != 0, + ordering: row.get(7)?, + }) +} + +fn run_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<(String, String, Option, String, String, Option, Option, Option, Option)> { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + row.get(6)?, + row.get(7)?, + row.get(8)?, + )) +} + +fn decode_run( + raw: (String, String, Option, String, String, Option, Option, Option, Option), +) -> Result { + Ok(VerificationRun { + id: raw.0, + contract_id: raw.1, + check_id: raw.2, + phase: parse_run_phase(&raw.3)?, + status: parse_run_status(&raw.4)?, + started_at: raw.5, + finished_at: raw.6, + duration_ms: raw.7, + receipt_id: raw.8, + }) +} + pub fn create_outcome_contract( conn: &Connection, session_id: &str, @@ -150,22 +250,36 @@ pub fn create_outcome_contract( } pub fn get_outcome_contract(conn: &Connection, id: &str) -> Result> { - let mut stmt = conn.prepare("SELECT id, session_id, title, description, state, version, created_at, updated_at FROM outcome_contracts WHERE id = ?1")?; - let mut rows = stmt.query(rusqlite::params![id])?; - if let Some(r) = rows.next()? { - Ok(Some(OutcomeContract { - id: r.get(0)?, - session_id: r.get(1)?, - title: r.get(2)?, - description: r.get(3)?, - state: r.get(4)?, - version: r.get(5)?, - created_at: r.get(6)?, - updated_at: r.get(7)?, - })) + Ok(conn + .query_row( + "SELECT id, session_id, title, description, state, version, created_at, updated_at FROM outcome_contracts WHERE id = ?1", + rusqlite::params![id], + contract_from_row, + ) + .optional()?) +} + +pub fn list_outcome_contracts( + conn: &Connection, + session_id: Option<&str>, +) -> Result> { + let sql = match session_id { + Some(_) => { + "SELECT id, session_id, title, description, state, version, created_at, updated_at FROM outcome_contracts WHERE session_id = ?1 ORDER BY rowid DESC" + } + None => { + "SELECT id, session_id, title, description, state, version, created_at, updated_at FROM outcome_contracts ORDER BY rowid DESC" + } + }; + let mut stmt = conn.prepare(sql)?; + let values = if let Some(session_id) = session_id { + stmt.query_map(rusqlite::params![session_id], contract_from_row)? + .collect::, _>>()? } else { - Ok(None) - } + stmt.query_map([], contract_from_row)? + .collect::, _>>()? + }; + Ok(values) } pub fn add_verification_check( @@ -195,27 +309,39 @@ pub fn add_verification_check( }) } +pub fn update_verification_check( + conn: &Connection, + check: &VerificationCheck, +) -> Result<()> { + let changed = conn.execute( + "UPDATE verification_checks SET stable_id = ?1, description = ?2, command_ref = ?3, expected_condition = ?4, required = ?5, ordering = ?6 WHERE id = ?7 AND contract_id = ?8", + rusqlite::params![ + check.stable_id, + check.description, + check.command_ref, + check.expected_condition, + check.required, + check.ordering, + check.id, + check.contract_id + ], + )?; + if changed != 1 { + return Err(VerificationError::CheckNotFound(check.id.clone())); + } + Ok(()) +} + pub fn list_verification_checks( conn: &Connection, contract_id: &str, ) -> Result> { let mut stmt = conn.prepare( - "SELECT id, contract_id, stable_id, description, command_ref, expected_condition, required, ordering FROM verification_checks WHERE contract_id = ?1 ORDER BY ordering, id", + "SELECT id, contract_id, stable_id, description, command_ref, expected_condition, required, ordering FROM verification_checks WHERE contract_id = ?1 ORDER BY ordering ASC, rowid ASC", )?; - let rows = stmt.query_map(rusqlite::params![contract_id], |r| { - Ok(VerificationCheck { - id: r.get(0)?, - contract_id: r.get(1)?, - stable_id: r.get(2)?, - description: r.get(3)?, - command_ref: r.get(4)?, - expected_condition: r.get(5)?, - required: r.get(6)?, - ordering: r.get(7)?, - }) - })?; - rows.collect::, _>>() - .map_err(VerificationError::Db) + Ok(stmt + .query_map(rusqlite::params![contract_id], check_from_row)? + .collect::, _>>()?) } fn start_run( @@ -231,7 +357,7 @@ fn start_run( .query_row( "SELECT 1 FROM verification_checks WHERE id = ?1 AND contract_id = ?2", rusqlite::params![check_id, contract_id], - |r| r.get(0), + |row| row.get(0), ) .optional()?; if exists.is_none() { @@ -244,7 +370,7 @@ fn start_run( let session_id: String = tx.query_row( "SELECT session_id FROM outcome_contracts WHERE id = ?1", rusqlite::params![contract_id], - |r| r.get(0), + |row| row.get(0), )?; let meta = serde_json::to_string(&serde_json::json!({ @@ -258,7 +384,7 @@ fn start_run( parent_id: None, kind: "verification:run".to_string(), meta: Some(meta), - state: "Created".to_string(), + state: "Running".to_string(), created_at: now, }; timeline::create_action(&tx, &action)?; @@ -269,12 +395,9 @@ fn start_run( rusqlite::params![run_id, contract_id, check_id, phase.to_string(), RunStatus::Running.to_string(), now], )?; tx.commit()?; - Ok(run_id) } -/// Start a contract-level verification run. Prefer `start_verification_check_run` -/// for contracts that contain explicit checks. pub fn start_verification_run( conn: &mut Connection, contract_id: &str, @@ -292,11 +415,38 @@ pub fn start_verification_check_run( start_run(conn, contract_id, Some(check_id), phase) } +pub fn get_verification_run( + conn: &Connection, + run_id: &str, +) -> Result> { + let raw = conn + .query_row( + "SELECT id, contract_id, check_id, phase, status, started_at, finished_at, duration_ms, receipt_id FROM verification_runs WHERE id = ?1", + rusqlite::params![run_id], + run_from_row, + ) + .optional()?; + raw.map(decode_run).transpose() +} + +pub fn list_verification_runs( + conn: &Connection, + contract_id: &str, +) -> Result> { + let mut stmt = conn.prepare( + "SELECT id, contract_id, check_id, phase, status, started_at, finished_at, duration_ms, receipt_id FROM verification_runs WHERE contract_id = ?1 ORDER BY rowid DESC", + )?; + let raws = stmt + .query_map(rusqlite::params![contract_id], run_from_row)? + .collect::, _>>()?; + raws.into_iter().map(decode_run).collect() +} + fn execution_id_for_run(conn: &Connection, run_id: &str) -> Result { conn.query_row( "SELECT id FROM executions WHERE action_id = ?1 ORDER BY created_seq DESC LIMIT 1", rusqlite::params![run_id], - |r| r.get(0), + |row| row.get(0), ) .optional()? .ok_or_else(|| VerificationError::RunNotFound(run_id.to_owned())) @@ -305,9 +455,7 @@ fn execution_id_for_run(conn: &Connection, run_id: &str) -> Result { fn validate_finish_status(status: RunStatus) -> Result<()> { match status { RunStatus::Passed | RunStatus::Failed | RunStatus::Error | RunStatus::Interrupted => Ok(()), - RunStatus::Pending | RunStatus::Running => { - Err(VerificationError::InvalidFinishStatus(status)) - } + RunStatus::Pending | RunStatus::Running => Err(VerificationError::InvalidFinishStatus(status)), } } @@ -332,7 +480,7 @@ fn update_finished_run( .query_row( "SELECT started_at FROM verification_runs WHERE id = ?1 AND status = 'Running'", rusqlite::params![run_id], - |r| r.get(0), + |row| row.get(0), ) .optional()? .ok_or_else(|| VerificationError::RunNotFound(run_id.to_owned()))?; @@ -344,11 +492,13 @@ fn update_finished_run( if changed != 1 { return Err(VerificationError::RunNotFound(run_id.to_owned())); } + conn.execute( + "UPDATE actions SET state = ?1 WHERE id = ?2", + rusqlite::params![status.to_string(), run_id], + )?; Ok(()) } -/// Finish a verification run and its underlying Timeline execution in one DB -/// transaction. The returned receipt is the actual Timeline receipt. pub fn finish_verification_run_with_output( conn: &mut Connection, run_id: &str, @@ -371,8 +521,6 @@ pub fn finish_verification_run_with_output( Ok(receipt_id) } -/// Attach an already-created receipt to a verification run. The receipt must -/// belong to the Timeline execution created for this run. pub fn finish_verification_run( conn: &mut Connection, run_id: &str, @@ -386,7 +534,7 @@ pub fn finish_verification_run( .query_row( "SELECT 1 FROM receipts WHERE id = ?1 AND execution_id = ?2", rusqlite::params![receipt_id, execution_id], - |r| r.get(0), + |row| row.get(0), ) .optional()?; if matching_receipt.is_none() { @@ -395,26 +543,33 @@ pub fn finish_verification_run( receipt_id: receipt_id.to_owned(), }); } - update_finished_run(&tx, run_id, status, receipt_id, unix_time_secs()?)?; tx.commit()?; Ok(()) } -pub fn recover_running_verifications(conn: &mut Connection) -> Result { +pub fn interrupt_running_verifications(conn: &mut Connection) -> Result { let tx = conn.transaction()?; - let changed = tx.execute( - "UPDATE verification_runs SET status = 'Interrupted', finished_at = COALESCE(finished_at, started_at) WHERE status = 'Running'", + tx.execute( + "UPDATE executions SET status = 'Interrupted', finished_at = COALESCE(finished_at, started_at), duration_ms = COALESCE(duration_ms, 0) WHERE status = 'Running' AND action_id IN (SELECT id FROM verification_runs WHERE status = 'Running')", [], )?; tx.execute( - "UPDATE executions SET status = 'Interrupted' WHERE status = 'Running' AND finished_at IS NULL", + "UPDATE actions SET state = 'Interrupted' WHERE id IN (SELECT id FROM verification_runs WHERE status = 'Running')", + [], + )?; + let changed = tx.execute( + "UPDATE verification_runs SET status = 'Interrupted', finished_at = COALESCE(finished_at, started_at), duration_ms = COALESCE(duration_ms, 0) WHERE status = 'Running'", [], )?; tx.commit()?; Ok(changed) } +pub fn recover_running_verifications(conn: &mut Connection) -> Result { + interrupt_running_verifications(conn) +} + fn latest_status( conn: &Connection, contract_id: &str, @@ -423,12 +578,12 @@ fn latest_status( ) -> Result> { let value: Option = conn .query_row( - "SELECT status FROM verification_runs WHERE contract_id = ?1 AND check_id = ?2 AND phase = ?3 ORDER BY started_at DESC, id DESC LIMIT 1", + "SELECT status FROM verification_runs WHERE contract_id = ?1 AND check_id = ?2 AND phase = ?3 ORDER BY rowid DESC LIMIT 1", rusqlite::params![contract_id, check_id, phase.to_string()], - |r| r.get(0), + |row| row.get(0), ) .optional()?; - Ok(value.as_deref().and_then(parse_run_status)) + value.map(|value| parse_run_status(&value)).transpose() } fn evaluate_check(before: Option, after: Option) -> OutcomeState { @@ -439,41 +594,58 @@ fn evaluate_check(before: Option, after: Option) -> Outcom Some(RunStatus::Failed) => OutcomeState::NotFixed, _ => OutcomeState::Inconclusive, }, - Some( - RunStatus::Pending | RunStatus::Running | RunStatus::Error | RunStatus::Interrupted, - ) + Some(RunStatus::Pending | RunStatus::Running | RunStatus::Error | RunStatus::Interrupted) | None => OutcomeState::Inconclusive, } } -pub fn evaluate_outcome_state(conn: &Connection, contract_id: &str) -> Result { +pub fn get_outcome_summary(conn: &Connection, contract_id: &str) -> Result { let checks = list_verification_checks(conn, contract_id)?; - let required: Vec<_> = checks.into_iter().filter(|check| check.required).collect(); - - if required.is_empty() { - return Ok(OutcomeState::Inconclusive); - } - - let mut saw_reproduction_not_proven = false; - let mut saw_inconclusive = false; - for check in required { + let mut summaries = Vec::with_capacity(checks.len()); + for check in checks { let before = latest_status(conn, contract_id, &check.id, RunPhase::Before)?; let after = latest_status(conn, contract_id, &check.id, RunPhase::After)?; - match evaluate_check(before, after) { - OutcomeState::NotFixed => return Ok(OutcomeState::NotFixed), - OutcomeState::ReproductionNotProven => saw_reproduction_not_proven = true, - OutcomeState::Inconclusive => saw_inconclusive = true, - OutcomeState::VerifiedFix => {} - } + let outcome = evaluate_check(before, after); + summaries.push(VerificationCheckSummary { + check, + before, + after, + outcome, + }); } - if saw_reproduction_not_proven { - Ok(OutcomeState::ReproductionNotProven) - } else if saw_inconclusive { - Ok(OutcomeState::Inconclusive) + let required: Vec<&VerificationCheckSummary> = + summaries.iter().filter(|item| item.check.required).collect(); + let overall = if required.is_empty() { + OutcomeState::Inconclusive + } else if required + .iter() + .any(|item| item.outcome == OutcomeState::NotFixed) + { + OutcomeState::NotFixed + } else if required + .iter() + .any(|item| item.outcome == OutcomeState::ReproductionNotProven) + { + OutcomeState::ReproductionNotProven + } else if required + .iter() + .any(|item| item.outcome == OutcomeState::Inconclusive) + { + OutcomeState::Inconclusive } else { - Ok(OutcomeState::VerifiedFix) - } + OutcomeState::VerifiedFix + }; + + Ok(OutcomeSummary { + contract_id: contract_id.to_owned(), + overall, + checks: summaries, + }) +} + +pub fn evaluate_outcome_state(conn: &Connection, contract_id: &str) -> Result { + Ok(get_outcome_summary(conn, contract_id)?.overall) } pub fn evaluate_outcome(conn: &Connection, contract_id: &str) -> Result { @@ -486,14 +658,9 @@ mod tests { use crate::db::init_db; use tempfile::NamedTempFile; - fn setup() -> ( - NamedTempFile, - Connection, - OutcomeContract, - VerificationCheck, - ) { + fn setup() -> (NamedTempFile, Connection, OutcomeContract, VerificationCheck) { let tmp = NamedTempFile::new().unwrap(); - let conn = init_db(tmp.path()).expect("init db"); + let conn = init_db(tmp.path()).unwrap(); conn.execute( "INSERT INTO sessions(id, repo_id, created_at, updated_at, state) VALUES (?1,?2,?3,?4,?5)", rusqlite::params!["s-test", "r", 1, 1, "Active"], @@ -528,30 +695,50 @@ mod tests { } #[test] - fn create_and_query_contract() { + fn create_query_and_list_contract() { let (_tmp, conn, contract, _check) = setup(); - let got = get_outcome_contract(&conn, &contract.id) - .expect("get") - .expect("found"); + let got = get_outcome_contract(&conn, &contract.id).unwrap().unwrap(); assert_eq!(got.title, "T"); + let contracts = list_outcome_contracts(&conn, Some("s-test")).unwrap(); + assert_eq!(contracts, vec![contract]); + assert!(list_outcome_contracts(&conn, Some("other")).unwrap().is_empty()); + } + + #[test] + fn check_update_and_ordering_are_deterministic() { + let (_tmp, conn, contract, mut check) = setup(); + let second = add_verification_check( + &conn, + &contract.id, + "check-2", + "Second", + None, + None, + false, + 10, + ) + .unwrap(); + check.description = "Updated regression".to_string(); + check.ordering = 20; + update_verification_check(&conn, &check).unwrap(); + let checks = list_verification_checks(&conn, &contract.id).unwrap(); + assert_eq!(checks[0].id, second.id); + assert_eq!(checks[1].description, "Updated regression"); } #[test] fn start_and_finish_run_lifecycle_uses_real_receipt() { let (_tmp, mut conn, contract, check) = setup(); - let run_id = - start_verification_check_run(&mut conn, &contract.id, &check.id, RunPhase::Before) - .expect("start"); - - let (status, receipt_at_start): (String, Option) = conn - .query_row( - "SELECT status, receipt_id FROM verification_runs WHERE id = ?1", - rusqlite::params![&run_id], - |r| Ok((r.get(0)?, r.get(1)?)), - ) - .unwrap(); - assert_eq!(status, "Running"); - assert!(receipt_at_start.is_none()); + let run_id = start_verification_check_run( + &mut conn, + &contract.id, + &check.id, + RunPhase::Before, + ) + .unwrap(); + let running = get_verification_run(&conn, &run_id).unwrap().unwrap(); + assert_eq!(running.status, RunStatus::Running); + assert!(running.receipt_id.is_none()); let receipt = finish_verification_run_with_output( &mut conn, @@ -561,67 +748,42 @@ mod tests { None, ) .unwrap(); - - let (run_status, stored_receipt): (String, Option) = conn - .query_row( - "SELECT status, receipt_id FROM verification_runs WHERE id = ?1", - rusqlite::params![&run_id], - |r| Ok((r.get(0)?, r.get(1)?)), - ) - .unwrap(); - assert_eq!(run_status, "Failed"); - assert_eq!(stored_receipt.as_deref(), Some(receipt.as_str())); - - let execution_status: String = conn - .query_row( - "SELECT status FROM executions WHERE action_id = ?1", - rusqlite::params![&run_id], - |r| r.get(0), + let finished = get_verification_run(&conn, &run_id).unwrap().unwrap(); + assert_eq!(finished.status, RunStatus::Failed); + assert_eq!(finished.receipt_id.as_deref(), Some(receipt.as_str())); + assert_eq!( + timeline::get_execution( + &conn, + &conn.query_row( + "SELECT id FROM executions WHERE action_id = ?1", + rusqlite::params![&run_id], + |row| row.get::<_, String>(0), + ) + .unwrap(), ) - .unwrap(); - assert_eq!(execution_status, "Failed"); + .unwrap() + .unwrap() + .status, + "Failed" + ); } #[test] fn before_failed_after_passed_is_verified_fix() { let (_tmp, mut conn, contract, check) = setup(); - complete( - &mut conn, - &contract, - &check, - RunPhase::Before, - RunStatus::Failed, - ); - complete( - &mut conn, - &contract, - &check, - RunPhase::After, - RunStatus::Passed, - ); - assert_eq!( - evaluate_outcome_state(&conn, &contract.id).unwrap(), - OutcomeState::VerifiedFix - ); + complete(&mut conn, &contract, &check, RunPhase::Before, RunStatus::Failed); + complete(&mut conn, &contract, &check, RunPhase::After, RunStatus::Passed); + let summary = get_outcome_summary(&conn, &contract.id).unwrap(); + assert_eq!(summary.overall, OutcomeState::VerifiedFix); + assert_eq!(summary.checks[0].before, Some(RunStatus::Failed)); + assert_eq!(summary.checks[0].after, Some(RunStatus::Passed)); } #[test] fn before_passed_means_reproduction_not_proven() { let (_tmp, mut conn, contract, check) = setup(); - complete( - &mut conn, - &contract, - &check, - RunPhase::Before, - RunStatus::Passed, - ); - complete( - &mut conn, - &contract, - &check, - RunPhase::After, - RunStatus::Passed, - ); + complete(&mut conn, &contract, &check, RunPhase::Before, RunStatus::Passed); + complete(&mut conn, &contract, &check, RunPhase::After, RunStatus::Passed); assert_eq!( evaluate_outcome_state(&conn, &contract.id).unwrap(), OutcomeState::ReproductionNotProven @@ -631,20 +793,8 @@ mod tests { #[test] fn before_failed_after_failed_is_not_fixed() { let (_tmp, mut conn, contract, check) = setup(); - complete( - &mut conn, - &contract, - &check, - RunPhase::Before, - RunStatus::Failed, - ); - complete( - &mut conn, - &contract, - &check, - RunPhase::After, - RunStatus::Failed, - ); + complete(&mut conn, &contract, &check, RunPhase::Before, RunStatus::Failed); + complete(&mut conn, &contract, &check, RunPhase::After, RunStatus::Failed); assert_eq!( evaluate_outcome_state(&conn, &contract.id).unwrap(), OutcomeState::NotFixed @@ -654,26 +804,38 @@ mod tests { #[test] fn error_or_interruption_is_inconclusive() { let (_tmp, mut conn, contract, check) = setup(); - complete( - &mut conn, - &contract, - &check, - RunPhase::Before, - RunStatus::Error, - ); - complete( - &mut conn, - &contract, - &check, - RunPhase::After, - RunStatus::Passed, - ); + complete(&mut conn, &contract, &check, RunPhase::Before, RunStatus::Error); + complete(&mut conn, &contract, &check, RunPhase::After, RunStatus::Passed); assert_eq!( evaluate_outcome_state(&conn, &contract.id).unwrap(), OutcomeState::Inconclusive ); } + #[test] + fn optional_failure_does_not_block_required_verified_fix() { + let (_tmp, mut conn, contract, required) = setup(); + let optional = add_verification_check( + &conn, + &contract.id, + "optional", + "Optional diagnostic", + None, + None, + false, + 1, + ) + .unwrap(); + complete(&mut conn, &contract, &required, RunPhase::Before, RunStatus::Failed); + complete(&mut conn, &contract, &required, RunPhase::After, RunStatus::Passed); + complete(&mut conn, &contract, &optional, RunPhase::Before, RunStatus::Failed); + complete(&mut conn, &contract, &optional, RunPhase::After, RunStatus::Failed); + assert_eq!( + evaluate_outcome_state(&conn, &contract.id).unwrap(), + OutcomeState::VerifiedFix + ); + } + #[test] fn different_checks_cannot_prove_each_other() { let (_tmp, mut conn, contract, check_a) = setup(); @@ -688,22 +850,8 @@ mod tests { 1, ) .unwrap(); - - complete( - &mut conn, - &contract, - &check_a, - RunPhase::Before, - RunStatus::Failed, - ); - complete( - &mut conn, - &contract, - &check_b, - RunPhase::After, - RunStatus::Passed, - ); - + complete(&mut conn, &contract, &check_a, RunPhase::Before, RunStatus::Failed); + complete(&mut conn, &contract, &check_b, RunPhase::After, RunStatus::Passed); assert_eq!( evaluate_outcome_state(&conn, &contract.id).unwrap(), OutcomeState::Inconclusive @@ -711,29 +859,54 @@ mod tests { } #[test] - fn recovery_interrupts_verification_and_timeline_execution() { + fn latest_run_wins_deterministically() { let (_tmp, mut conn, contract, check) = setup(); - let run = - start_verification_check_run(&mut conn, &contract.id, &check.id, RunPhase::Before) - .unwrap(); - assert_eq!(recover_running_verifications(&mut conn).unwrap(), 1); + complete(&mut conn, &contract, &check, RunPhase::Before, RunStatus::Failed); + complete(&mut conn, &contract, &check, RunPhase::After, RunStatus::Failed); + complete(&mut conn, &contract, &check, RunPhase::After, RunStatus::Passed); + assert_eq!( + evaluate_outcome_state(&conn, &contract.id).unwrap(), + OutcomeState::VerifiedFix + ); + let runs = list_verification_runs(&conn, &contract.id).unwrap(); + assert_eq!(runs.len(), 3); + assert_eq!(runs[0].status, RunStatus::Passed); + } - let run_status: String = conn - .query_row( - "SELECT status FROM verification_runs WHERE id = ?1", - rusqlite::params![&run], - |r| r.get(0), - ) - .unwrap(); - assert_eq!(run_status, "Interrupted"); + #[test] + fn recovery_interrupts_only_verification_timeline_executions() { + let (_tmp, mut conn, contract, check) = setup(); + let run = start_verification_check_run( + &mut conn, + &contract.id, + &check.id, + RunPhase::Before, + ) + .unwrap(); - let execution_status: String = conn - .query_row( - "SELECT status FROM executions WHERE action_id = ?1", - rusqlite::params![&run], - |r| r.get(0), - ) - .unwrap(); - assert_eq!(execution_status, "Interrupted"); + let unrelated_action = timeline::Action { + id: "unrelated-action".to_string(), + session_id: "s-test".to_string(), + parent_id: None, + kind: "command".to_string(), + meta: None, + state: "Running".to_string(), + created_at: 1, + }; + timeline::create_action(&conn, &unrelated_action).unwrap(); + let unrelated_execution = timeline::start_execution(&conn, &unrelated_action.id).unwrap(); + + assert_eq!(interrupt_running_verifications(&mut conn).unwrap(), 1); + assert_eq!( + get_verification_run(&conn, &run).unwrap().unwrap().status, + RunStatus::Interrupted + ); + assert_eq!( + timeline::get_execution(&conn, &unrelated_execution) + .unwrap() + .unwrap() + .status, + "Running" + ); } } From e64efa74d6bb7c679be67efee12f012b90f75c4e Mon Sep 17 00:00:00 2001 From: t1k <154753100+t1ktakdev@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:33:39 +0600 Subject: [PATCH 19/73] fix(db): enforce verification integrity and artifact links --- crates/reprodeck-core/src/db.rs | 277 ++++++++++++++++++++++---------- 1 file changed, 194 insertions(+), 83 deletions(-) diff --git a/crates/reprodeck-core/src/db.rs b/crates/reprodeck-core/src/db.rs index 02071d0..143a44f 100644 --- a/crates/reprodeck-core/src/db.rs +++ b/crates/reprodeck-core/src/db.rs @@ -19,7 +19,6 @@ type MResult = std::result::Result; const MIGRATIONS: &[(&str, &str)] = &[ ( "1", - // migration 1: reprodeck_meta and repositories table "CREATE TABLE IF NOT EXISTS reprodeck_meta ( key TEXT PRIMARY KEY, value TEXT NOT NULL @@ -33,7 +32,6 @@ const MIGRATIONS: &[(&str, &str)] = &[ ), ( "2", - // migration 2: sessions, shadow_workspaces, command_executions, timeline_events, evidence, outcome_criteria "CREATE TABLE IF NOT EXISTS sessions ( created_seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT UNIQUE NOT NULL, @@ -87,7 +85,6 @@ const MIGRATIONS: &[(&str, &str)] = &[ ), ( "3", - // migration 3: actions, executions, receipts, artifacts "CREATE INDEX IF NOT EXISTS idx_sessions_id ON sessions(id); CREATE TABLE IF NOT EXISTS actions ( @@ -144,13 +141,11 @@ const MIGRATIONS: &[(&str, &str)] = &[ FOREIGN KEY(receipt_id) REFERENCES receipts(id) ON DELETE CASCADE ON UPDATE NO ACTION ); - CREATE INDEX IF NOT EXISTS idx_artifacts_receipt ON artifacts(receipt_id); - " + CREATE INDEX IF NOT EXISTS idx_artifacts_receipt ON artifacts(receipt_id);", ), ( "4", - "-- migration 4: outcome verification tables and evidence_links - CREATE TABLE IF NOT EXISTS outcome_contracts ( + "CREATE TABLE IF NOT EXISTS outcome_contracts ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL, title TEXT NOT NULL, @@ -207,7 +202,99 @@ const MIGRATIONS: &[(&str, &str)] = &[ role TEXT NOT NULL, FOREIGN KEY(evidence_id) REFERENCES evidence(id) ON DELETE CASCADE, FOREIGN KEY(run_id) REFERENCES verification_runs(id) ON DELETE CASCADE - );" + );", + ), + ( + "5", + "-- Tighten outcome/evidence integrity without destructively rebuilding v4 tables. + CREATE UNIQUE INDEX IF NOT EXISTS idx_checks_contract_stable + ON verification_checks(contract_id, stable_id); + CREATE INDEX IF NOT EXISTS idx_runs_check ON verification_runs(check_id); + CREATE INDEX IF NOT EXISTS idx_runs_receipt ON verification_runs(receipt_id); + + CREATE TABLE IF NOT EXISTS artifact_links ( + id TEXT PRIMARY KEY, + artifact_id TEXT NOT NULL, + run_id TEXT, + role TEXT NOT NULL CHECK(role IN ('Before','After','Verification','Diagnostic','Attachment')), + created_at INTEGER NOT NULL, + FOREIGN KEY(artifact_id) REFERENCES artifacts(id) ON DELETE CASCADE, + FOREIGN KEY(run_id) REFERENCES verification_runs(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_artifact_links_artifact ON artifact_links(artifact_id); + CREATE INDEX IF NOT EXISTS idx_artifact_links_run ON artifact_links(run_id); + + CREATE TRIGGER IF NOT EXISTS trg_outcome_contract_session_insert + BEFORE INSERT ON outcome_contracts + WHEN NOT EXISTS (SELECT 1 FROM sessions WHERE id = NEW.session_id) + BEGIN + SELECT RAISE(ABORT, 'outcome contract session does not exist'); + END; + + CREATE TRIGGER IF NOT EXISTS trg_outcome_contract_session_update + BEFORE UPDATE OF session_id ON outcome_contracts + WHEN NOT EXISTS (SELECT 1 FROM sessions WHERE id = NEW.session_id) + BEGIN + SELECT RAISE(ABORT, 'outcome contract session does not exist'); + END; + + CREATE TRIGGER IF NOT EXISTS trg_sessions_delete_outcome_contracts + AFTER DELETE ON sessions + BEGIN + DELETE FROM outcome_contracts WHERE session_id = OLD.id; + END; + + CREATE TRIGGER IF NOT EXISTS trg_verification_run_check_insert + BEFORE INSERT ON verification_runs + WHEN NEW.check_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM verification_checks + WHERE id = NEW.check_id AND contract_id = NEW.contract_id + ) + BEGIN + SELECT RAISE(ABORT, 'verification check does not belong to contract'); + END; + + CREATE TRIGGER IF NOT EXISTS trg_verification_run_check_update + BEFORE UPDATE OF check_id, contract_id ON verification_runs + WHEN NEW.check_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM verification_checks + WHERE id = NEW.check_id AND contract_id = NEW.contract_id + ) + BEGIN + SELECT RAISE(ABORT, 'verification check does not belong to contract'); + END; + + CREATE TRIGGER IF NOT EXISTS trg_verification_run_receipt_insert + BEFORE INSERT ON verification_runs + WHEN NEW.receipt_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM receipts WHERE id = NEW.receipt_id + ) + BEGIN + SELECT RAISE(ABORT, 'verification receipt does not exist'); + END; + + CREATE TRIGGER IF NOT EXISTS trg_verification_run_receipt_update + BEFORE UPDATE OF receipt_id ON verification_runs + WHEN NEW.receipt_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM receipts WHERE id = NEW.receipt_id + ) + BEGIN + SELECT RAISE(ABORT, 'verification receipt does not exist'); + END; + + CREATE TRIGGER IF NOT EXISTS trg_verification_run_state_insert + BEFORE INSERT ON verification_runs + WHEN NEW.phase NOT IN ('Before','After') OR NEW.status NOT IN ('Pending','Running','Passed','Failed','Error','Interrupted') + BEGIN + SELECT RAISE(ABORT, 'invalid verification phase or status'); + END; + + CREATE TRIGGER IF NOT EXISTS trg_verification_run_state_update + BEFORE UPDATE OF phase, status ON verification_runs + WHEN NEW.phase NOT IN ('Before','After') OR NEW.status NOT IN ('Pending','Running','Passed','Failed','Error','Interrupted') + BEGIN + SELECT RAISE(ABORT, 'invalid verification phase or status'); + END;", ), ]; @@ -216,32 +303,31 @@ fn current_migration_version() -> i64 { } fn get_db_schema_version(conn: &Connection) -> MResult { - let val: Result = conn.query_row( + let value: Result = conn.query_row( "SELECT value FROM reprodeck_meta WHERE key = 'schema_version'", [], - |r| r.get(0), + |row| row.get(0), ); - match val { - Ok(v) => v + match value { + Ok(value) => value .parse::() .map_err(|_| MigrationError::MigrationFailed("schema_version corrupted".to_string())), Err(rusqlite::Error::QueryReturnedNoRows) => Ok(0), - Err(e) => Err(MigrationError::Db(e)), + Err(error) => Err(MigrationError::Db(error)), } } #[allow(dead_code)] -fn set_db_schema_version(conn: &Connection, v: i64) -> MResult<()> { +fn set_db_schema_version(conn: &Connection, version: i64) -> MResult<()> { conn.execute( "INSERT INTO reprodeck_meta(key,value) VALUES('schema_version',?1) ON CONFLICT(key) DO UPDATE SET value = ?1", - params![v.to_string()], + params![version.to_string()], )?; Ok(()) } -/// Apply pending migrations in order. Transaction-safe per migration using BEGIN/COMMIT inside SQL. fn apply_migrations(conn: &mut Connection) -> MResult<()> { let current = get_db_schema_version(conn)?; let latest = current_migration_version(); @@ -249,14 +335,13 @@ fn apply_migrations(conn: &mut Connection) -> MResult<()> { return Err(MigrationError::UnknownSchemaVersion(current)); } - for i in (current as usize + 1)..=MIGRATIONS.len() { - let (ver, sql) = MIGRATIONS.get(i - 1).unwrap(); - // execute migration inside a Rust-owned transaction + for index in (current as usize + 1)..=MIGRATIONS.len() { + let (version, sql) = MIGRATIONS.get(index - 1).expect("migration index is valid"); let tx = conn.transaction()?; tx.execute_batch(sql)?; tx.execute( "INSERT INTO reprodeck_meta(key,value) VALUES('schema_version',?1) ON CONFLICT(key) DO UPDATE SET value = ?1", - params![ver.parse::().unwrap().to_string()], + params![version.parse::().expect("static migration version").to_string()], )?; tx.commit()?; } @@ -264,25 +349,15 @@ fn apply_migrations(conn: &mut Connection) -> MResult<()> { Ok(()) } -/// Initialise or open the SQLite database at `path` and ensure required schema via migrations. pub fn init_db(path: &Path) -> MResult { let mut conn = Connection::open(path)?; - - // pragmas and connection-level settings - // enable foreign keys conn.pragma_update(None, "foreign_keys", true)?; - // journal mode and synchronous for WAL durability/performance tradeoff conn.execute_batch("PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;")?; - // busy timeout (ms) conn.busy_timeout(std::time::Duration::from_millis(5000))?; - - // ensure reprodeck_meta exists so we can store schema_version conn.execute_batch( "CREATE TABLE IF NOT EXISTS reprodeck_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);", )?; - apply_migrations(&mut conn)?; - Ok(conn) } @@ -294,40 +369,25 @@ mod tests { #[test] fn fresh_db_applies_all_migrations() { let tmp = NamedTempFile::new().unwrap(); - let path = tmp.path(); - - let conn = init_db(path).expect("init db"); - - let ver = get_db_schema_version(&conn).unwrap(); - assert_eq!(ver, current_migration_version()); + let conn = init_db(tmp.path()).unwrap(); + assert_eq!(get_db_schema_version(&conn).unwrap(), current_migration_version()); } #[test] fn pragmas_and_foreign_keys_enabled() { let tmp = NamedTempFile::new().unwrap(); - let path = tmp.path(); - let conn = init_db(path).expect("init db"); - // check foreign_keys pragma - let fk: i64 = conn - .query_row("PRAGMA foreign_keys;", [], |r| r.get(0)) - .unwrap(); - assert_eq!(fk, 1); - // check journal_mode (string) - let jm: String = conn - .query_row("PRAGMA journal_mode;", [], |r| r.get(0)) - .unwrap(); - assert!(jm.eq_ignore_ascii_case("wal")); + let conn = init_db(tmp.path()).unwrap(); + let foreign_keys: i64 = conn.query_row("PRAGMA foreign_keys;", [], |row| row.get(0)).unwrap(); + assert_eq!(foreign_keys, 1); + let journal_mode: String = conn.query_row("PRAGMA journal_mode;", [], |row| row.get(0)).unwrap(); + assert!(journal_mode.eq_ignore_ascii_case("wal")); } #[test] fn upgrade_from_previous_schema() { let tmp = NamedTempFile::new().unwrap(); - let path = tmp.path(); - - // create DB with only migration 1 applied - let mut conn = Connection::open(path).unwrap(); + let mut conn = Connection::open(tmp.path()).unwrap(); conn.pragma_update(None, "foreign_keys", true).unwrap(); - // apply migration 1 inside transaction let tx = conn.transaction().unwrap(); tx.execute_batch(MIGRATIONS[0].1).unwrap(); tx.execute( @@ -337,74 +397,125 @@ mod tests { .unwrap(); tx.commit().unwrap(); - // now init_db should apply migration 2 - let conn2 = init_db(path).expect("migrate"); - let ver = get_db_schema_version(&conn2).unwrap(); - assert_eq!(ver, current_migration_version()); + let conn = init_db(tmp.path()).unwrap(); + assert_eq!(get_db_schema_version(&conn).unwrap(), current_migration_version()); } #[test] fn init_db_idempotent() { let tmp = NamedTempFile::new().unwrap(); - let path = tmp.path(); - let _ = init_db(path).unwrap(); - let _ = init_db(path).unwrap(); + init_db(tmp.path()).unwrap(); + init_db(tmp.path()).unwrap(); } #[test] fn unsupported_newer_schema_is_rejected() { let tmp = NamedTempFile::new().unwrap(); - let path = tmp.path(); - let conn = Connection::open(path).unwrap(); - conn.pragma_update(None, "foreign_keys", true).unwrap(); + let conn = Connection::open(tmp.path()).unwrap(); conn.execute_batch("CREATE TABLE IF NOT EXISTS reprodeck_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);").unwrap(); conn.execute( "INSERT INTO reprodeck_meta(key,value) VALUES('schema_version',?1)", params!["999"], ) .unwrap(); - let res = init_db(path); - assert!(matches!(res, Err(MigrationError::UnknownSchemaVersion(_)))); + assert!(matches!(init_db(tmp.path()), Err(MigrationError::UnknownSchemaVersion(_)))); } #[test] fn corrupted_schema_version_returns_error() { let tmp = NamedTempFile::new().unwrap(); - let path = tmp.path(); - let conn = Connection::open(path).unwrap(); - conn.pragma_update(None, "foreign_keys", true).unwrap(); + let conn = Connection::open(tmp.path()).unwrap(); conn.execute_batch("CREATE TABLE IF NOT EXISTS reprodeck_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);").unwrap(); conn.execute( "INSERT INTO reprodeck_meta(key,value) VALUES('schema_version',?1)", params!["not-a-number"], ) .unwrap(); - let res = init_db(path); - assert!(matches!(res, Err(MigrationError::MigrationFailed(_)))); + assert!(matches!(init_db(tmp.path()), Err(MigrationError::MigrationFailed(_)))); } #[test] fn failing_migration_rolls_back() { let tmp = NamedTempFile::new().unwrap(); - let path = tmp.path(); - let conn = Connection::open(path).unwrap(); - conn.pragma_update(None, "foreign_keys", true).unwrap(); + let conn = Connection::open(tmp.path()).unwrap(); conn.execute_batch("CREATE TABLE IF NOT EXISTS reprodeck_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);").unwrap(); conn.execute( "INSERT INTO reprodeck_meta(key,value) VALUES('schema_version',?1)", params!["1"], ) .unwrap(); - - // apply a failing migration inside a transaction and ensure rollback - let mut conn2 = Connection::open(path).unwrap(); - let tx = conn2.transaction().unwrap(); - // a migration that creates a table then fails - let res = tx.execute_batch("CREATE TABLE test_temp(id INTEGER);; INVALID SQL;"); - assert!(res.is_err()); + let mut conn = Connection::open(tmp.path()).unwrap(); + let tx = conn.transaction().unwrap(); + assert!(tx.execute_batch("CREATE TABLE test_temp(id INTEGER); INVALID SQL;").is_err()); drop(tx); - // ensure schema_version still 1 - let ver = get_db_schema_version(&conn2).unwrap(); - assert_eq!(ver, 1); + assert_eq!(get_db_schema_version(&conn).unwrap(), 1); + } + + fn seed_session_and_contract(conn: &Connection) { + conn.execute( + "INSERT INTO sessions(id, repo_id, created_at, updated_at, state) VALUES ('s1','r',1,1,'Active')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO outcome_contracts(id, session_id, title, state, version, created_at) VALUES ('c1','s1','contract','Draft',1,1)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO outcome_contracts(id, session_id, title, state, version, created_at) VALUES ('c2','s1','contract2','Draft',1,1)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO verification_checks(id, contract_id, stable_id, description, required, ordering) VALUES ('check1','c1','stable','check',1,0)", + [], + ) + .unwrap(); + } + + #[test] + fn outcome_contract_requires_existing_session() { + let tmp = NamedTempFile::new().unwrap(); + let conn = init_db(tmp.path()).unwrap(); + let result = conn.execute( + "INSERT INTO outcome_contracts(id, session_id, title, state, version, created_at) VALUES ('bad','missing','x','Draft',1,1)", + [], + ); + assert!(result.is_err()); + } + + #[test] + fn verification_check_must_belong_to_run_contract() { + let tmp = NamedTempFile::new().unwrap(); + let conn = init_db(tmp.path()).unwrap(); + seed_session_and_contract(&conn); + let result = conn.execute( + "INSERT INTO verification_runs(id, contract_id, check_id, phase, status, started_at) VALUES ('run','c2','check1','Before','Running',1)", + [], + ); + assert!(result.is_err()); + } + + #[test] + fn verification_state_values_are_enforced() { + let tmp = NamedTempFile::new().unwrap(); + let conn = init_db(tmp.path()).unwrap(); + seed_session_and_contract(&conn); + let result = conn.execute( + "INSERT INTO verification_runs(id, contract_id, check_id, phase, status, started_at) VALUES ('run','c1','check1','Maybe','Magic',1)", + [], + ); + assert!(result.is_err()); + } + + #[test] + fn deleting_session_cascades_outcome_contracts_via_trigger() { + let tmp = NamedTempFile::new().unwrap(); + let conn = init_db(tmp.path()).unwrap(); + seed_session_and_contract(&conn); + conn.execute("DELETE FROM sessions WHERE id = 's1'", []).unwrap(); + let count: i64 = conn.query_row("SELECT COUNT(*) FROM outcome_contracts", [], |row| row.get(0)).unwrap(); + assert_eq!(count, 0); } } From 709451093ea0169d3398169b2d43d6751d7c545a Mon Sep 17 00:00:00 2001 From: t1k <154753100+t1ktakdev@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:34:12 +0600 Subject: [PATCH 20/73] ci: temporarily auto-format repair branch --- .github/workflows/format-repair.yml | 38 +++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/format-repair.yml diff --git a/.github/workflows/format-repair.yml b/.github/workflows/format-repair.yml new file mode 100644 index 0000000..75fe28a --- /dev/null +++ b/.github/workflows/format-repair.yml @@ -0,0 +1,38 @@ +name: Temporary repair formatter + +on: + push: + branches: + - chatgpt/repair-foundations + +permissions: + contents: write + +jobs: + rustfmt: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: chatgpt/repair-foundations + fetch-depth: 0 + + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + + - name: Format Rust workspace + run: cargo fmt --all + + - name: Commit formatting if needed + shell: bash + run: | + if git diff --quiet; then + echo "Already formatted" + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "style: apply rustfmt" + git push origin HEAD:chatgpt/repair-foundations From 4df04dff7500d0e3aedcda02613f35b9d6ae5ee7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:34:28 +0000 Subject: [PATCH 21/73] style: apply rustfmt --- crates/reprodeck-core/src/db.rs | 41 +++- crates/reprodeck-core/src/timeline.rs | 67 +++++-- crates/reprodeck-core/src/verification.rs | 230 +++++++++++++++++----- 3 files changed, 261 insertions(+), 77 deletions(-) diff --git a/crates/reprodeck-core/src/db.rs b/crates/reprodeck-core/src/db.rs index 143a44f..9378cf0 100644 --- a/crates/reprodeck-core/src/db.rs +++ b/crates/reprodeck-core/src/db.rs @@ -370,16 +370,23 @@ mod tests { fn fresh_db_applies_all_migrations() { let tmp = NamedTempFile::new().unwrap(); let conn = init_db(tmp.path()).unwrap(); - assert_eq!(get_db_schema_version(&conn).unwrap(), current_migration_version()); + assert_eq!( + get_db_schema_version(&conn).unwrap(), + current_migration_version() + ); } #[test] fn pragmas_and_foreign_keys_enabled() { let tmp = NamedTempFile::new().unwrap(); let conn = init_db(tmp.path()).unwrap(); - let foreign_keys: i64 = conn.query_row("PRAGMA foreign_keys;", [], |row| row.get(0)).unwrap(); + let foreign_keys: i64 = conn + .query_row("PRAGMA foreign_keys;", [], |row| row.get(0)) + .unwrap(); assert_eq!(foreign_keys, 1); - let journal_mode: String = conn.query_row("PRAGMA journal_mode;", [], |row| row.get(0)).unwrap(); + let journal_mode: String = conn + .query_row("PRAGMA journal_mode;", [], |row| row.get(0)) + .unwrap(); assert!(journal_mode.eq_ignore_ascii_case("wal")); } @@ -398,7 +405,10 @@ mod tests { tx.commit().unwrap(); let conn = init_db(tmp.path()).unwrap(); - assert_eq!(get_db_schema_version(&conn).unwrap(), current_migration_version()); + assert_eq!( + get_db_schema_version(&conn).unwrap(), + current_migration_version() + ); } #[test] @@ -418,7 +428,10 @@ mod tests { params!["999"], ) .unwrap(); - assert!(matches!(init_db(tmp.path()), Err(MigrationError::UnknownSchemaVersion(_)))); + assert!(matches!( + init_db(tmp.path()), + Err(MigrationError::UnknownSchemaVersion(_)) + )); } #[test] @@ -431,7 +444,10 @@ mod tests { params!["not-a-number"], ) .unwrap(); - assert!(matches!(init_db(tmp.path()), Err(MigrationError::MigrationFailed(_)))); + assert!(matches!( + init_db(tmp.path()), + Err(MigrationError::MigrationFailed(_)) + )); } #[test] @@ -446,7 +462,9 @@ mod tests { .unwrap(); let mut conn = Connection::open(tmp.path()).unwrap(); let tx = conn.transaction().unwrap(); - assert!(tx.execute_batch("CREATE TABLE test_temp(id INTEGER); INVALID SQL;").is_err()); + assert!(tx + .execute_batch("CREATE TABLE test_temp(id INTEGER); INVALID SQL;") + .is_err()); drop(tx); assert_eq!(get_db_schema_version(&conn).unwrap(), 1); } @@ -514,8 +532,13 @@ mod tests { let tmp = NamedTempFile::new().unwrap(); let conn = init_db(tmp.path()).unwrap(); seed_session_and_contract(&conn); - conn.execute("DELETE FROM sessions WHERE id = 's1'", []).unwrap(); - let count: i64 = conn.query_row("SELECT COUNT(*) FROM outcome_contracts", [], |row| row.get(0)).unwrap(); + conn.execute("DELETE FROM sessions WHERE id = 's1'", []) + .unwrap(); + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM outcome_contracts", [], |row| { + row.get(0) + }) + .unwrap(); assert_eq!(count, 0); } } diff --git a/crates/reprodeck-core/src/timeline.rs b/crates/reprodeck-core/src/timeline.rs index 3e61e58..366f81e 100644 --- a/crates/reprodeck-core/src/timeline.rs +++ b/crates/reprodeck-core/src/timeline.rs @@ -133,9 +133,7 @@ fn long_token_regex() -> &'static Regex { } fn sanitize_preview(input: &str) -> String { - let mut value = bearer_regex() - .replace_all(input, "[REDACTED]") - .into_owned(); + let mut value = bearer_regex().replace_all(input, "[REDACTED]").into_owned(); value = key_value_regex() .replace_all(&value, "$1=[REDACTED]") .into_owned(); @@ -409,7 +407,8 @@ pub fn finish_execution( stderr_preview: Option<&str>, ) -> Result { let tx = conn.transaction()?; - let receipt_id = finish_execution_in_transaction(&tx, execution_id, status, stdout_preview, stderr_preview)?; + let receipt_id = + finish_execution_in_transaction(&tx, execution_id, status, stdout_preview, stderr_preview)?; tx.commit()?; Ok(receipt_id) } @@ -527,7 +526,8 @@ mod tests { create_action(&conn, &action(&format!("a-{i}"), "s-pag")).unwrap(); } let first = list_actions(&conn, "s-pag", None, 2).unwrap(); - let second = list_actions(&conn, "s-pag", Some(first.last().unwrap().created_seq), 10).unwrap(); + let second = + list_actions(&conn, "s-pag", Some(first.last().unwrap().created_seq), 10).unwrap(); assert_eq!(first.len(), 2); assert_eq!(second.len(), 3); assert!(first.iter().all(|a| second.iter().all(|b| a.id != b.id))); @@ -537,8 +537,14 @@ mod tests { fn invalid_pagination_limit_rejected() { let tmp = NamedTempFile::new().unwrap(); let conn = init_db(tmp.path()).unwrap(); - assert!(matches!(list_sessions(&conn, None, 0), Err(TimelineError::InvalidLimit))); - assert!(matches!(list_sessions(&conn, None, 501), Err(TimelineError::InvalidLimit))); + assert!(matches!( + list_sessions(&conn, None, 0), + Err(TimelineError::InvalidLimit) + )); + assert!(matches!( + list_sessions(&conn, None, 501), + Err(TimelineError::InvalidLimit) + )); } #[test] @@ -548,7 +554,14 @@ mod tests { setup_session(&conn, "s-roundtrip"); create_action(&conn, &action("a-roundtrip", "s-roundtrip")).unwrap(); let execution_id = start_execution(&conn, "a-roundtrip").unwrap(); - let receipt_id = finish_execution(&mut conn, &execution_id, "Succeeded", Some("hello"), Some("warning")).unwrap(); + let receipt_id = finish_execution( + &mut conn, + &execution_id, + "Succeeded", + Some("hello"), + Some("warning"), + ) + .unwrap(); let execution = get_execution(&conn, &execution_id).unwrap().unwrap(); let receipt = get_receipt(&conn, &receipt_id).unwrap().unwrap(); assert_eq!(execution.status, "Succeeded"); @@ -565,7 +578,10 @@ mod tests { create_action(&conn, &action("a-double", "s-double")).unwrap(); let execution_id = start_execution(&conn, "a-double").unwrap(); finish_execution(&mut conn, &execution_id, "Succeeded", None, None).unwrap(); - assert!(matches!(finish_execution(&mut conn, &execution_id, "Succeeded", None, None), Err(TimelineError::ExecutionNotFound(_)))); + assert!(matches!( + finish_execution(&mut conn, &execution_id, "Succeeded", None, None), + Err(TimelineError::ExecutionNotFound(_)) + )); } #[test] @@ -575,8 +591,19 @@ mod tests { create_session(&conn, "s-sec", "Active", None).unwrap(); create_action(&conn, &action("asec", "s-sec")).unwrap(); let execution_id = start_execution(&conn, "asec").unwrap(); - let receipt_id = finish_execution(&mut conn, &execution_id, "Succeeded", Some("Bearer abcdef12345== password=hunter2"), None).unwrap(); - let preview = get_receipt(&conn, &receipt_id).unwrap().unwrap().stdout_preview.unwrap(); + let receipt_id = finish_execution( + &mut conn, + &execution_id, + "Succeeded", + Some("Bearer abcdef12345== password=hunter2"), + None, + ) + .unwrap(); + let preview = get_receipt(&conn, &receipt_id) + .unwrap() + .unwrap() + .stdout_preview + .unwrap(); assert!(!preview.contains("abcdef12345")); assert!(!preview.contains("hunter2")); assert!(preview.contains("REDACTED")); @@ -592,8 +619,13 @@ mod tests { let jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signature"; let aws = format!("AKIA{}", "A".repeat(16)); let input = format!("start {jwt} middle {aws} end"); - let receipt_id = finish_execution(&mut conn, &execution_id, "Succeeded", Some(&input), None).unwrap(); - let preview = get_receipt(&conn, &receipt_id).unwrap().unwrap().stdout_preview.unwrap(); + let receipt_id = + finish_execution(&mut conn, &execution_id, "Succeeded", Some(&input), None).unwrap(); + let preview = get_receipt(&conn, &receipt_id) + .unwrap() + .unwrap() + .stdout_preview + .unwrap(); assert!(!preview.contains("eyJhbGci")); assert!(!preview.contains("AKIA")); } @@ -606,7 +638,8 @@ mod tests { create_action(&conn, &action("a-unicode", "s-unicode")).unwrap(); let execution_id = start_execution(&conn, "a-unicode").unwrap(); let output = format!("{}{}", "😀".repeat(300), "русский-текст".repeat(50)); - let receipt_id = finish_execution(&mut conn, &execution_id, "Succeeded", Some(&output), None).unwrap(); + let receipt_id = + finish_execution(&mut conn, &execution_id, "Succeeded", Some(&output), None).unwrap(); let receipt = get_receipt(&conn, &receipt_id).unwrap().unwrap(); let preview = receipt.stdout_preview.unwrap(); assert!(preview.len() <= 1024); @@ -620,7 +653,11 @@ mod tests { let conn = init_db(tmp.path()).unwrap(); create_session(&conn, "s-123", "Active", Some("{}")).unwrap(); create_action(&conn, &action("act-2", "s-123")).unwrap(); - conn.execute("DELETE FROM sessions WHERE id = ?1", rusqlite::params!["s-123"]).unwrap(); + conn.execute( + "DELETE FROM sessions WHERE id = ?1", + rusqlite::params!["s-123"], + ) + .unwrap(); assert!(get_action(&conn, "act-2").unwrap().is_none()); } diff --git a/crates/reprodeck-core/src/verification.rs b/crates/reprodeck-core/src/verification.rs index 541674d..877e138 100644 --- a/crates/reprodeck-core/src/verification.rs +++ b/crates/reprodeck-core/src/verification.rs @@ -195,7 +195,19 @@ fn check_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result) -> rusqlite::Result<(String, String, Option, String, String, Option, Option, Option, Option)> { +fn run_from_row( + row: &rusqlite::Row<'_>, +) -> rusqlite::Result<( + String, + String, + Option, + String, + String, + Option, + Option, + Option, + Option, +)> { Ok(( row.get(0)?, row.get(1)?, @@ -210,7 +222,17 @@ fn run_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<(String, String, Op } fn decode_run( - raw: (String, String, Option, String, String, Option, Option, Option, Option), + raw: ( + String, + String, + Option, + String, + String, + Option, + Option, + Option, + Option, + ), ) -> Result { Ok(VerificationRun { id: raw.0, @@ -309,10 +331,7 @@ pub fn add_verification_check( }) } -pub fn update_verification_check( - conn: &Connection, - check: &VerificationCheck, -) -> Result<()> { +pub fn update_verification_check(conn: &Connection, check: &VerificationCheck) -> Result<()> { let changed = conn.execute( "UPDATE verification_checks SET stable_id = ?1, description = ?2, command_ref = ?3, expected_condition = ?4, required = ?5, ordering = ?6 WHERE id = ?7 AND contract_id = ?8", rusqlite::params![ @@ -415,10 +434,7 @@ pub fn start_verification_check_run( start_run(conn, contract_id, Some(check_id), phase) } -pub fn get_verification_run( - conn: &Connection, - run_id: &str, -) -> Result> { +pub fn get_verification_run(conn: &Connection, run_id: &str) -> Result> { let raw = conn .query_row( "SELECT id, contract_id, check_id, phase, status, started_at, finished_at, duration_ms, receipt_id FROM verification_runs WHERE id = ?1", @@ -455,7 +471,9 @@ fn execution_id_for_run(conn: &Connection, run_id: &str) -> Result { fn validate_finish_status(status: RunStatus) -> Result<()> { match status { RunStatus::Passed | RunStatus::Failed | RunStatus::Error | RunStatus::Interrupted => Ok(()), - RunStatus::Pending | RunStatus::Running => Err(VerificationError::InvalidFinishStatus(status)), + RunStatus::Pending | RunStatus::Running => { + Err(VerificationError::InvalidFinishStatus(status)) + } } } @@ -594,7 +612,9 @@ fn evaluate_check(before: Option, after: Option) -> Outcom Some(RunStatus::Failed) => OutcomeState::NotFixed, _ => OutcomeState::Inconclusive, }, - Some(RunStatus::Pending | RunStatus::Running | RunStatus::Error | RunStatus::Interrupted) + Some( + RunStatus::Pending | RunStatus::Running | RunStatus::Error | RunStatus::Interrupted, + ) | None => OutcomeState::Inconclusive, } } @@ -614,8 +634,10 @@ pub fn get_outcome_summary(conn: &Connection, contract_id: &str) -> Result = - summaries.iter().filter(|item| item.check.required).collect(); + let required: Vec<&VerificationCheckSummary> = summaries + .iter() + .filter(|item| item.check.required) + .collect(); let overall = if required.is_empty() { OutcomeState::Inconclusive } else if required @@ -658,7 +680,12 @@ mod tests { use crate::db::init_db; use tempfile::NamedTempFile; - fn setup() -> (NamedTempFile, Connection, OutcomeContract, VerificationCheck) { + fn setup() -> ( + NamedTempFile, + Connection, + OutcomeContract, + VerificationCheck, + ) { let tmp = NamedTempFile::new().unwrap(); let conn = init_db(tmp.path()).unwrap(); conn.execute( @@ -701,7 +728,9 @@ mod tests { assert_eq!(got.title, "T"); let contracts = list_outcome_contracts(&conn, Some("s-test")).unwrap(); assert_eq!(contracts, vec![contract]); - assert!(list_outcome_contracts(&conn, Some("other")).unwrap().is_empty()); + assert!(list_outcome_contracts(&conn, Some("other")) + .unwrap() + .is_empty()); } #[test] @@ -729,13 +758,9 @@ mod tests { #[test] fn start_and_finish_run_lifecycle_uses_real_receipt() { let (_tmp, mut conn, contract, check) = setup(); - let run_id = start_verification_check_run( - &mut conn, - &contract.id, - &check.id, - RunPhase::Before, - ) - .unwrap(); + let run_id = + start_verification_check_run(&mut conn, &contract.id, &check.id, RunPhase::Before) + .unwrap(); let running = get_verification_run(&conn, &run_id).unwrap().unwrap(); assert_eq!(running.status, RunStatus::Running); assert!(running.receipt_id.is_none()); @@ -754,12 +779,13 @@ mod tests { assert_eq!( timeline::get_execution( &conn, - &conn.query_row( - "SELECT id FROM executions WHERE action_id = ?1", - rusqlite::params![&run_id], - |row| row.get::<_, String>(0), - ) - .unwrap(), + &conn + .query_row( + "SELECT id FROM executions WHERE action_id = ?1", + rusqlite::params![&run_id], + |row| row.get::<_, String>(0), + ) + .unwrap(), ) .unwrap() .unwrap() @@ -771,8 +797,20 @@ mod tests { #[test] fn before_failed_after_passed_is_verified_fix() { let (_tmp, mut conn, contract, check) = setup(); - complete(&mut conn, &contract, &check, RunPhase::Before, RunStatus::Failed); - complete(&mut conn, &contract, &check, RunPhase::After, RunStatus::Passed); + complete( + &mut conn, + &contract, + &check, + RunPhase::Before, + RunStatus::Failed, + ); + complete( + &mut conn, + &contract, + &check, + RunPhase::After, + RunStatus::Passed, + ); let summary = get_outcome_summary(&conn, &contract.id).unwrap(); assert_eq!(summary.overall, OutcomeState::VerifiedFix); assert_eq!(summary.checks[0].before, Some(RunStatus::Failed)); @@ -782,8 +820,20 @@ mod tests { #[test] fn before_passed_means_reproduction_not_proven() { let (_tmp, mut conn, contract, check) = setup(); - complete(&mut conn, &contract, &check, RunPhase::Before, RunStatus::Passed); - complete(&mut conn, &contract, &check, RunPhase::After, RunStatus::Passed); + complete( + &mut conn, + &contract, + &check, + RunPhase::Before, + RunStatus::Passed, + ); + complete( + &mut conn, + &contract, + &check, + RunPhase::After, + RunStatus::Passed, + ); assert_eq!( evaluate_outcome_state(&conn, &contract.id).unwrap(), OutcomeState::ReproductionNotProven @@ -793,8 +843,20 @@ mod tests { #[test] fn before_failed_after_failed_is_not_fixed() { let (_tmp, mut conn, contract, check) = setup(); - complete(&mut conn, &contract, &check, RunPhase::Before, RunStatus::Failed); - complete(&mut conn, &contract, &check, RunPhase::After, RunStatus::Failed); + complete( + &mut conn, + &contract, + &check, + RunPhase::Before, + RunStatus::Failed, + ); + complete( + &mut conn, + &contract, + &check, + RunPhase::After, + RunStatus::Failed, + ); assert_eq!( evaluate_outcome_state(&conn, &contract.id).unwrap(), OutcomeState::NotFixed @@ -804,8 +866,20 @@ mod tests { #[test] fn error_or_interruption_is_inconclusive() { let (_tmp, mut conn, contract, check) = setup(); - complete(&mut conn, &contract, &check, RunPhase::Before, RunStatus::Error); - complete(&mut conn, &contract, &check, RunPhase::After, RunStatus::Passed); + complete( + &mut conn, + &contract, + &check, + RunPhase::Before, + RunStatus::Error, + ); + complete( + &mut conn, + &contract, + &check, + RunPhase::After, + RunStatus::Passed, + ); assert_eq!( evaluate_outcome_state(&conn, &contract.id).unwrap(), OutcomeState::Inconclusive @@ -826,10 +900,34 @@ mod tests { 1, ) .unwrap(); - complete(&mut conn, &contract, &required, RunPhase::Before, RunStatus::Failed); - complete(&mut conn, &contract, &required, RunPhase::After, RunStatus::Passed); - complete(&mut conn, &contract, &optional, RunPhase::Before, RunStatus::Failed); - complete(&mut conn, &contract, &optional, RunPhase::After, RunStatus::Failed); + complete( + &mut conn, + &contract, + &required, + RunPhase::Before, + RunStatus::Failed, + ); + complete( + &mut conn, + &contract, + &required, + RunPhase::After, + RunStatus::Passed, + ); + complete( + &mut conn, + &contract, + &optional, + RunPhase::Before, + RunStatus::Failed, + ); + complete( + &mut conn, + &contract, + &optional, + RunPhase::After, + RunStatus::Failed, + ); assert_eq!( evaluate_outcome_state(&conn, &contract.id).unwrap(), OutcomeState::VerifiedFix @@ -850,8 +948,20 @@ mod tests { 1, ) .unwrap(); - complete(&mut conn, &contract, &check_a, RunPhase::Before, RunStatus::Failed); - complete(&mut conn, &contract, &check_b, RunPhase::After, RunStatus::Passed); + complete( + &mut conn, + &contract, + &check_a, + RunPhase::Before, + RunStatus::Failed, + ); + complete( + &mut conn, + &contract, + &check_b, + RunPhase::After, + RunStatus::Passed, + ); assert_eq!( evaluate_outcome_state(&conn, &contract.id).unwrap(), OutcomeState::Inconclusive @@ -861,9 +971,27 @@ mod tests { #[test] fn latest_run_wins_deterministically() { let (_tmp, mut conn, contract, check) = setup(); - complete(&mut conn, &contract, &check, RunPhase::Before, RunStatus::Failed); - complete(&mut conn, &contract, &check, RunPhase::After, RunStatus::Failed); - complete(&mut conn, &contract, &check, RunPhase::After, RunStatus::Passed); + complete( + &mut conn, + &contract, + &check, + RunPhase::Before, + RunStatus::Failed, + ); + complete( + &mut conn, + &contract, + &check, + RunPhase::After, + RunStatus::Failed, + ); + complete( + &mut conn, + &contract, + &check, + RunPhase::After, + RunStatus::Passed, + ); assert_eq!( evaluate_outcome_state(&conn, &contract.id).unwrap(), OutcomeState::VerifiedFix @@ -876,13 +1004,9 @@ mod tests { #[test] fn recovery_interrupts_only_verification_timeline_executions() { let (_tmp, mut conn, contract, check) = setup(); - let run = start_verification_check_run( - &mut conn, - &contract.id, - &check.id, - RunPhase::Before, - ) - .unwrap(); + let run = + start_verification_check_run(&mut conn, &contract.id, &check.id, RunPhase::Before) + .unwrap(); let unrelated_action = timeline::Action { id: "unrelated-action".to_string(), From f9516ad0b8860c8792915a867bae7d30badbaefa Mon Sep 17 00:00:00 2001 From: t1k <154753100+t1ktakdev@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:34:42 +0600 Subject: [PATCH 22/73] security(redaction): centralize text secret scrubbing --- crates/reprodeck-core/src/redaction.rs | 189 ++++++++++++++++++++----- 1 file changed, 154 insertions(+), 35 deletions(-) diff --git a/crates/reprodeck-core/src/redaction.rs b/crates/reprodeck-core/src/redaction.rs index 7ffbc45..9492be5 100644 --- a/crates/reprodeck-core/src/redaction.rs +++ b/crates/reprodeck-core/src/redaction.rs @@ -1,64 +1,154 @@ use regex::Regex; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::path::Path; +use std::sync::OnceLock; -#[derive(Debug, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum RedactionResult { Included(String), Redacted { reason: String }, Excluded { reason: String }, } +fn bearer_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r"(?i)bearer\s+[A-Za-z0-9\-\._~\+\/]+=*") + .expect("static bearer regex must compile") + }) +} + +fn key_value_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r"(?i)(password|passwd|token|secret|api[_-]?key|authorization|cookie)\s*[=:]\s*[^\s,;]+") + .expect("static key/value regex must compile") + }) +} + +fn jwt_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r"\b[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b") + .expect("static JWT regex must compile") + }) +} + +fn aws_key_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b") + .expect("static AWS access-key regex must compile") + }) +} + +fn github_token_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r"\bgh(?:p|o|u|s|r)_[A-Za-z0-9]{20,}\b") + .expect("static GitHub token regex must compile") + }) +} + +fn long_hex_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r"\b[0-9a-fA-F]{40,128}\b").expect("static long-hex regex must compile") + }) +} + +fn long_token_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r"\b[A-Za-z0-9_\-]{48,}\b").expect("static long-token regex must compile") + }) +} + +/// Redact common credential forms before text is persisted in receipts or +/// text evidence. The raw input is never returned on a matching secret span. +pub fn redact_text(input: &str) -> String { + let mut value = bearer_regex() + .replace_all(input, "Bearer [REDACTED]") + .into_owned(); + value = key_value_regex() + .replace_all(&value, "$1=[REDACTED]") + .into_owned(); + value = jwt_regex() + .replace_all(&value, "[REDACTED_JWT]") + .into_owned(); + value = aws_key_regex() + .replace_all(&value, "[REDACTED_AWS_KEY]") + .into_owned(); + value = github_token_regex() + .replace_all(&value, "[REDACTED_GITHUB_TOKEN]") + .into_owned(); + value = long_hex_regex() + .replace_all(&value, "[REDACTED_TOKEN]") + .into_owned(); + long_token_regex() + .replace_all(&value, "[REDACTED_TOKEN]") + .into_owned() +} + pub fn redact_path(path: &Path) -> RedactionResult { - let s = path.to_string_lossy(); + let display = path.to_string_lossy(); let filename = path .file_name() - .map(|v| v.to_string_lossy().to_lowercase()) + .map(|value| value.to_string_lossy().to_ascii_lowercase()) .unwrap_or_default(); - let secret_patterns = [".env", ".pem", ".key", "id_rsa", "credentials", "secrets"]; - for p in &secret_patterns { - if filename.contains(p) { + let secret_patterns = [ + ".env", + ".pem", + ".key", + ".pfx", + ".p12", + "id_rsa", + "id_ed25519", + "credentials", + "secrets", + ]; + for pattern in secret_patterns { + if filename.contains(pattern) { return RedactionResult::Redacted { - reason: format!("filename matches secret pattern: {}", p), + reason: format!("filename matches secret pattern: {pattern}"), }; } } - RedactionResult::Included(s.to_string()) + RedactionResult::Included(display.into_owned()) } pub fn redact_env(key: &str, value: &str) -> RedactionResult { - let k = key.to_uppercase(); + let upper = key.to_ascii_uppercase(); let sensitive = [ "TOKEN", "SECRET", "PASSWORD", + "PASSWD", "API_KEY", + "APIKEY", "AUTHORIZATION", "COOKIE", + "PRIVATE_KEY", ]; - for s in &sensitive { - if k.contains(s) { + for marker in sensitive { + if upper.contains(marker) { return RedactionResult::Redacted { - reason: format!("env name contains sensitive token: {}", s), + reason: format!("environment name contains sensitive marker: {marker}"), }; } } - // additional detection for authorization bearer - let auth_re = Regex::new(r"(?i)bearer\s+[A-Za-z0-9\-\._~\+\/]+=*").unwrap(); - if auth_re.is_match(value) { + if redact_text(value) != value { return RedactionResult::Redacted { - reason: "authorization bearer token".to_string(), + reason: "environment value resembles a credential".to_string(), }; } RedactionResult::Included(value.to_string()) } pub fn artifact_store_path(session: &str, data: &[u8]) -> String { - let mut hasher = Sha256::new(); - hasher.update(data); - let hash = hex::encode(hasher.finalize()); - format!("artifacts/{}/{}", session, hash) + let hash = hex::encode(Sha256::digest(data)); + format!("artifacts/{session}/{hash}") } #[cfg(test)] @@ -66,22 +156,51 @@ mod tests { use super::*; #[test] - fn redact_paths_detects_env() { - let r = redact_path(Path::new(".env")); - assert!(matches!(r, RedactionResult::Redacted { .. })); - let r2 = redact_path(Path::new("config.pem")); - assert!(matches!(r2, RedactionResult::Redacted { .. })); - let r3 = redact_path(Path::new("README.md")); - assert!(matches!(r3, RedactionResult::Included(_))); + fn redact_paths_detect_secret_files() { + assert!(matches!( + redact_path(Path::new(".env")), + RedactionResult::Redacted { .. } + )); + assert!(matches!( + redact_path(Path::new("config.pem")), + RedactionResult::Redacted { .. } + )); + assert!(matches!( + redact_path(Path::new("README.md")), + RedactionResult::Included(_) + )); + } + + #[test] + fn redact_env_detects_name_and_value_secrets() { + assert!(matches!( + redact_env("TOKEN", "secret"), + RedactionResult::Redacted { .. } + )); + assert!(matches!( + redact_env("MY_VAR", "hello"), + RedactionResult::Included(_) + )); + assert!(matches!( + redact_env("MY_VAR", "Bearer abcdef123456"), + RedactionResult::Redacted { .. } + )); } #[test] - fn redact_env_detects_tokens() { - let r = redact_env("TOKEN", "secret"); - assert!(matches!(r, RedactionResult::Redacted { .. })); - let r2 = redact_env("MY_VAR", "hello"); - assert!(matches!(r2, RedactionResult::Included(_))); - let r3 = redact_env("Authorization", "Bearer abcdef"); - assert!(matches!(r3, RedactionResult::Redacted { .. })); + fn redact_text_covers_common_secret_shapes() { + let github = "ghp_abcdefghijklmnopqrstuvwxyz123456"; + let jwt = "abcdefgh.ijklmnop.qrstuvwx"; + let aws = "AKIAABCDEFGHIJKLMNOP"; + let input = format!( + "Authorization=Bearer secret-token password=hunter2 {github} {jwt} {aws}" + ); + let redacted = redact_text(&input); + assert!(!redacted.contains("secret-token")); + assert!(!redacted.contains("hunter2")); + assert!(!redacted.contains(github)); + assert!(!redacted.contains(jwt)); + assert!(!redacted.contains(aws)); + assert!(redacted.contains("REDACTED")); } } From f6ad8d4fa93e882f438ab8906f37147819453bb8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:34:51 +0000 Subject: [PATCH 23/73] style: apply rustfmt --- crates/reprodeck-core/src/redaction.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/reprodeck-core/src/redaction.rs b/crates/reprodeck-core/src/redaction.rs index 9492be5..5acf31a 100644 --- a/crates/reprodeck-core/src/redaction.rs +++ b/crates/reprodeck-core/src/redaction.rs @@ -192,9 +192,8 @@ mod tests { let github = "ghp_abcdefghijklmnopqrstuvwxyz123456"; let jwt = "abcdefgh.ijklmnop.qrstuvwx"; let aws = "AKIAABCDEFGHIJKLMNOP"; - let input = format!( - "Authorization=Bearer secret-token password=hunter2 {github} {jwt} {aws}" - ); + let input = + format!("Authorization=Bearer secret-token password=hunter2 {github} {jwt} {aws}"); let redacted = redact_text(&input); assert!(!redacted.contains("secret-token")); assert!(!redacted.contains("hunter2")); From c1ca3a512792a5aa747d7e120619e12ceec36f82 Mon Sep 17 00:00:00 2001 From: t1k <154753100+t1ktakdev@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:35:51 +0600 Subject: [PATCH 24/73] feat(evidence): add typed artifact persistence and safe retrieval --- crates/reprodeck-core/src/evidence.rs | 474 +++++++++++++++++++++++--- 1 file changed, 422 insertions(+), 52 deletions(-) diff --git a/crates/reprodeck-core/src/evidence.rs b/crates/reprodeck-core/src/evidence.rs index 1ebc6c0..47ef102 100644 --- a/crates/reprodeck-core/src/evidence.rs +++ b/crates/reprodeck-core/src/evidence.rs @@ -1,8 +1,90 @@ +use crate::redaction; +use rusqlite::{Connection, OptionalExtension}; +use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; +use std::time::{SystemTime, SystemTimeError, UNIX_EPOCH}; +use thiserror::Error; use uuid::Uuid; +#[derive(Debug, Error)] +pub enum EvidenceError { + #[error(transparent)] + Io(#[from] std::io::Error), + #[error(transparent)] + Db(#[from] rusqlite::Error), + #[error(transparent)] + Clock(#[from] SystemTimeError), + #[error("artifact not found: {0}")] + ArtifactNotFound(String), + #[error("artifact store key is invalid")] + InvalidStoreKey, + #[error("artifact integrity check failed: {0}")] + Integrity(String), +} + +pub type Result = std::result::Result; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ArtifactRecord { + pub created_seq: i64, + pub id: String, + pub receipt_id: String, + pub store_key: String, + pub checksum: String, + pub size: i64, + pub media_type: Option, + pub created_at: i64, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum ArtifactRole { + Before, + After, + Verification, + Diagnostic, + Attachment, +} + +impl std::fmt::Display for ArtifactRole { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ArtifactRole::Before => write!(f, "Before"), + ArtifactRole::After => write!(f, "After"), + ArtifactRole::Verification => write!(f, "Verification"), + ArtifactRole::Diagnostic => write!(f, "Diagnostic"), + ArtifactRole::Attachment => write!(f, "Attachment"), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ArtifactLink { + pub id: String, + pub artifact_id: String, + pub run_id: Option, + pub role: ArtifactRole, + pub created_at: i64, +} + +fn unix_time_secs() -> Result { + Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as i64) +} + +fn parse_role(value: &str) -> Result { + match value { + "Before" => Ok(ArtifactRole::Before), + "After" => Ok(ArtifactRole::After), + "Verification" => Ok(ArtifactRole::Verification), + "Diagnostic" => Ok(ArtifactRole::Diagnostic), + "Attachment" => Ok(ArtifactRole::Attachment), + _ => Err(EvidenceError::Integrity(format!( + "unknown artifact role {value}" + ))), + } +} + fn is_symlink_or_reparse(path: &Path) -> std::io::Result { let meta = fs::symlink_metadata(path)?; let is_symlink = meta.file_type().is_symlink(); @@ -47,6 +129,9 @@ fn verify_existing_artifact( Ok(()) } +/// Store bytes by content hash. This function only manages the content store; +/// use `persist_text_artifact` for command/log text so redaction happens before +/// bytes reach disk. pub fn store_artifact(storage_dir: &Path, data: &[u8]) -> std::io::Result<(String, PathBuf)> { fs::create_dir_all(storage_dir)?; let base = storage_dir.canonicalize()?; @@ -71,70 +156,267 @@ pub fn store_artifact(storage_dir: &Path, data: &[u8]) -> std::io::Result<(Strin )); } - let finalp = dir.join(&checksum); - if finalp.exists() { - verify_existing_artifact(&finalp, &checksum, data.len())?; - return Ok((checksum, finalp)); + let final_path = dir.join(&checksum); + if final_path.exists() { + verify_existing_artifact(&final_path, &checksum, data.len())?; + return Ok((checksum, final_path)); } - // A unique temp name avoids concurrent writers clobbering each other's - // temporary file before the final content-addressed rename. - let tmp = dir.join(format!("{}.{}.tmp", checksum, Uuid::new_v4())); - if let Err(e) = fs::write(&tmp, data) { - let _ = fs::remove_file(&tmp); - return Err(e); + let temp_path = dir.join(format!("{}.{}.tmp", checksum, Uuid::new_v4())); + if let Err(error) = fs::write(&temp_path, data) { + let _ = fs::remove_file(&temp_path); + return Err(error); } - // Re-check containment after the temporary write and before rename. let current_dir_canon = dir.canonicalize()?; if current_dir_canon != dir_canon || !current_dir_canon.starts_with(&base) { - let _ = fs::remove_file(&tmp); + let _ = fs::remove_file(&temp_path); return Err(std::io::Error::other( "artifact directory changed or escaped storage root", )); } - match fs::rename(&tmp, &finalp) { + match fs::rename(&temp_path, &final_path) { Ok(()) => {} - Err(_e) if finalp.exists() => { - // Another writer may have won the race. Accept it only if the - // existing content matches the content-addressed identity. - let _ = fs::remove_file(&tmp); - verify_existing_artifact(&finalp, &checksum, data.len())?; + Err(_) if final_path.exists() => { + let _ = fs::remove_file(&temp_path); + verify_existing_artifact(&final_path, &checksum, data.len())?; } - Err(e) => { - let _ = fs::remove_file(&tmp); - return Err(e); + Err(error) => { + let _ = fs::remove_file(&temp_path); + return Err(error); } } - let final_canon = finalp.canonicalize()?; + let final_canon = final_path.canonicalize()?; if !final_canon.starts_with(&base) { - let _ = fs::remove_file(&finalp); + let _ = fs::remove_file(&final_path); return Err(std::io::Error::other( "artifact stored outside storage root", )); } - verify_existing_artifact(&finalp, &checksum, data.len())?; + verify_existing_artifact(&final_path, &checksum, data.len())?; + Ok((checksum, final_path)) +} - Ok((checksum, finalp)) +/// Ensure a candidate existing path is inside the content store. +pub fn path_within_storage(storage_dir: &Path, path: &Path) -> bool { + match (path.canonicalize(), storage_dir.canonicalize()) { + (Ok(candidate), Ok(base)) => candidate.starts_with(base), + _ => false, + } } -/// Ensure a candidate path is contained within storage_dir and not a symlink escape. -pub fn path_within_storage(storage_dir: &Path, p: &Path) -> bool { - match p.canonicalize() { - Ok(c) => match storage_dir.canonicalize() { - Ok(base) => c.starts_with(base), - Err(_) => false, - }, - Err(_) => false, +fn validate_store_key(key: &str) -> Result { + let path = Path::new(key); + if path.is_absolute() || path.as_os_str().is_empty() { + return Err(EvidenceError::InvalidStoreKey); } + for component in path.components() { + if !matches!(component, Component::Normal(_)) { + return Err(EvidenceError::InvalidStoreKey); + } + } + Ok(path.to_path_buf()) +} + +fn artifact_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(ArtifactRecord { + created_seq: row.get(0)?, + id: row.get(1)?, + receipt_id: row.get(2)?, + store_key: row.get(3)?, + checksum: row.get(4)?, + size: row.get(5)?, + media_type: row.get(6)?, + created_at: row.get(7)?, + }) +} + +fn persist_artifact_bytes( + conn: &Connection, + storage_dir: &Path, + receipt_id: &str, + data: &[u8], + media_type: Option<&str>, +) -> Result { + let (checksum, final_path) = store_artifact(storage_dir, data)?; + let relative = final_path + .strip_prefix(storage_dir) + .map_err(|_| EvidenceError::InvalidStoreKey)?; + let store_key = relative + .to_str() + .ok_or(EvidenceError::InvalidStoreKey)? + .replace('\\', "/"); + validate_store_key(&store_key)?; + + let id = Uuid::new_v4().to_string(); + let created_at = unix_time_secs()?; + conn.execute( + "INSERT INTO artifacts(id, receipt_id, store_key, checksum, size, media_type, created_at) VALUES (?1,?2,?3,?4,?5,?6,?7)", + rusqlite::params![ + id, + receipt_id, + store_key, + checksum, + data.len() as i64, + media_type, + created_at + ], + )?; + get_artifact(conn, &id)?.ok_or(EvidenceError::ArtifactNotFound(id)) +} + +/// Persist text evidence after central secret redaction. This is the preferred +/// API for stdout, stderr, logs, command output and generated diagnostic text. +pub fn persist_text_artifact( + conn: &Connection, + storage_dir: &Path, + receipt_id: &str, + text: &str, + media_type: Option<&str>, +) -> Result { + let redacted = redaction::redact_text(text); + persist_artifact_bytes( + conn, + storage_dir, + receipt_id, + redacted.as_bytes(), + media_type.or(Some("text/plain; charset=utf-8")), + ) +} + +/// Persist a binary/user attachment. Callers must only use this for content +/// where text secret redaction is not applicable (for example an image). +pub fn persist_binary_attachment( + conn: &Connection, + storage_dir: &Path, + receipt_id: &str, + data: &[u8], + media_type: Option<&str>, +) -> Result { + persist_artifact_bytes(conn, storage_dir, receipt_id, data, media_type) +} + +pub fn get_artifact(conn: &Connection, artifact_id: &str) -> Result> { + Ok(conn + .query_row( + "SELECT created_seq, id, receipt_id, store_key, checksum, size, media_type, created_at FROM artifacts WHERE id = ?1", + rusqlite::params![artifact_id], + artifact_from_row, + ) + .optional()?) +} + +pub fn list_artifacts_for_receipt( + conn: &Connection, + receipt_id: &str, +) -> Result> { + let mut stmt = conn.prepare( + "SELECT created_seq, id, receipt_id, store_key, checksum, size, media_type, created_at FROM artifacts WHERE receipt_id = ?1 ORDER BY created_seq ASC", + )?; + Ok(stmt + .query_map(rusqlite::params![receipt_id], artifact_from_row)? + .collect::, _>>()?) +} + +/// Read an artifact only by its database identity. No arbitrary filesystem path +/// from the frontend is accepted here. +pub fn read_artifact( + conn: &Connection, + storage_dir: &Path, + artifact_id: &str, +) -> Result> { + let artifact = get_artifact(conn, artifact_id)? + .ok_or_else(|| EvidenceError::ArtifactNotFound(artifact_id.to_owned()))?; + let relative = validate_store_key(&artifact.store_key)?; + let path = storage_dir.join(relative); + if !path_within_storage(storage_dir, &path) { + return Err(EvidenceError::InvalidStoreKey); + } + let bytes = fs::read(&path)?; + if bytes.len() as i64 != artifact.size { + return Err(EvidenceError::Integrity("stored size differs".to_string())); + } + let checksum = hex::encode(Sha256::digest(&bytes)); + if checksum != artifact.checksum { + return Err(EvidenceError::Integrity( + "stored checksum differs".to_string(), + )); + } + Ok(bytes) +} + +pub fn link_artifact( + conn: &Connection, + artifact_id: &str, + run_id: Option<&str>, + role: ArtifactRole, +) -> Result { + let id = Uuid::new_v4().to_string(); + let created_at = unix_time_secs()?; + conn.execute( + "INSERT INTO artifact_links(id, artifact_id, run_id, role, created_at) VALUES (?1,?2,?3,?4,?5)", + rusqlite::params![id, artifact_id, run_id, role.to_string(), created_at], + )?; + Ok(ArtifactLink { + id, + artifact_id: artifact_id.to_owned(), + run_id: run_id.map(str::to_owned), + role, + created_at, + }) +} + +pub fn list_artifact_links_for_run(conn: &Connection, run_id: &str) -> Result> { + let mut stmt = conn.prepare( + "SELECT id, artifact_id, run_id, role, created_at FROM artifact_links WHERE run_id = ?1 ORDER BY rowid ASC", + )?; + let raw = stmt + .query_map(rusqlite::params![run_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, String>(3)?, + row.get::<_, i64>(4)?, + )) + })? + .collect::, _>>()?; + raw.into_iter() + .map(|(id, artifact_id, run_id, role, created_at)| { + Ok(ArtifactLink { + id, + artifact_id, + run_id, + role: parse_role(&role)?, + created_at, + }) + }) + .collect() } #[cfg(test)] mod tests { use super::*; - use tempfile::tempdir; + use crate::{db::init_db, timeline, verification}; + use tempfile::{tempdir, NamedTempFile}; + + fn receipt(conn: &mut Connection) -> String { + timeline::create_session(conn, "session", "Active", None).unwrap(); + let action = timeline::Action { + id: "action".to_string(), + session_id: "session".to_string(), + parent_id: None, + kind: "command".to_string(), + meta: None, + state: "Running".to_string(), + created_at: 1, + }; + timeline::create_action(conn, &action).unwrap(); + let execution = timeline::start_execution(conn, &action.id).unwrap(); + timeline::finish_execution(conn, &execution, "Succeeded", None, None).unwrap() + } #[test] fn store_and_check_artifact() { @@ -150,11 +432,11 @@ mod tests { fn duplicate_artifact_idempotent() { let dir = tempdir().unwrap(); let data = b"same content"; - let (c1, p1) = store_artifact(dir.path(), data).unwrap(); - let (c2, p2) = store_artifact(dir.path(), data).unwrap(); - assert_eq!(c1, c2); - assert_eq!(p1, p2); - assert_eq!(fs::read(p1).unwrap(), data); + let (first_checksum, first_path) = store_artifact(dir.path(), data).unwrap(); + let (second_checksum, second_path) = store_artifact(dir.path(), data).unwrap(); + assert_eq!(first_checksum, second_checksum); + assert_eq!(first_path, second_path); + assert_eq!(fs::read(first_path).unwrap(), data); } #[test] @@ -162,13 +444,10 @@ mod tests { let dir = tempdir().unwrap(); let data = b"expected content"; let checksum = hex::encode(Sha256::digest(data)); - let prefix = &checksum[0..2]; - let prefix_dir = dir.path().join(prefix); + let prefix_dir = dir.path().join(&checksum[0..2]); fs::create_dir_all(&prefix_dir).unwrap(); fs::write(prefix_dir.join(&checksum), b"corrupt").unwrap(); - - let res = store_artifact(dir.path(), data); - assert!(res.is_err()); + assert!(store_artifact(dir.path(), data).is_err()); } #[test] @@ -188,13 +467,104 @@ mod tests { let outside = tempdir().unwrap(); let data = b"symlink test"; let checksum = hex::encode(Sha256::digest(data)); - let prefix = &checksum[0..2]; - let prefix_path = dir.path().join(prefix); + let prefix_path = dir.path().join(&checksum[0..2]); unixfs::symlink(outside.path(), &prefix_path).unwrap(); - assert!(prefix_path.exists()); - let res = store_artifact(dir.path(), data); - assert!(res.is_err()); - let outside_file = outside.path().join(&checksum); - assert!(!outside_file.exists()); + assert!(store_artifact(dir.path(), data).is_err()); + assert!(!outside.path().join(&checksum).exists()); + } + + #[test] + fn text_is_redacted_before_artifact_storage() { + let db_file = NamedTempFile::new().unwrap(); + let mut conn = init_db(db_file.path()).unwrap(); + let receipt_id = receipt(&mut conn); + let storage = tempdir().unwrap(); + let secret = "password=hunter2 Bearer secret-token"; + let artifact = persist_text_artifact( + &conn, + storage.path(), + &receipt_id, + secret, + Some("text/plain"), + ) + .unwrap(); + let bytes = read_artifact(&conn, storage.path(), &artifact.id).unwrap(); + let text = String::from_utf8(bytes).unwrap(); + assert!(!text.contains("hunter2")); + assert!(!text.contains("secret-token")); + assert!(text.contains("REDACTED")); + } + + #[test] + fn artifact_read_uses_database_identity_and_verifies_integrity() { + let db_file = NamedTempFile::new().unwrap(); + let mut conn = init_db(db_file.path()).unwrap(); + let receipt_id = receipt(&mut conn); + let storage = tempdir().unwrap(); + let artifact = persist_binary_attachment( + &conn, + storage.path(), + &receipt_id, + b"binary\0data", + Some("application/octet-stream"), + ) + .unwrap(); + assert_eq!( + read_artifact(&conn, storage.path(), &artifact.id).unwrap(), + b"binary\0data" + ); + let path = storage.path().join(&artifact.store_key); + fs::write(path, b"tampered").unwrap(); + assert!(matches!( + read_artifact(&conn, storage.path(), &artifact.id), + Err(EvidenceError::Integrity(_)) + )); + } + + #[test] + fn artifact_links_use_verification_run_and_role() { + let db_file = NamedTempFile::new().unwrap(); + let mut conn = init_db(db_file.path()).unwrap(); + let receipt_id = receipt(&mut conn); + let contract = verification::create_outcome_contract(&conn, "session", "Outcome", None) + .unwrap(); + let check = verification::add_verification_check( + &conn, + &contract.id, + "check", + "Check", + None, + None, + true, + 0, + ) + .unwrap(); + let run = verification::start_verification_check_run( + &mut conn, + &contract.id, + &check.id, + verification::RunPhase::Before, + ) + .unwrap(); + let storage = tempdir().unwrap(); + let artifact = persist_text_artifact( + &conn, + storage.path(), + &receipt_id, + "evidence", + None, + ) + .unwrap(); + link_artifact(&conn, &artifact.id, Some(&run), ArtifactRole::Before).unwrap(); + let links = list_artifact_links_for_run(&conn, &run).unwrap(); + assert_eq!(links.len(), 1); + assert_eq!(links[0].role, ArtifactRole::Before); + assert_eq!(links[0].artifact_id, artifact.id); + } + + #[test] + fn invalid_store_key_is_rejected_before_read() { + assert!(validate_store_key("../outside").is_err()); + assert!(validate_store_key("/absolute").is_err()); } } From 63a8e7fe1bdfda24a8161bcae296246a7f01f781 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:36:04 +0000 Subject: [PATCH 25/73] style: apply rustfmt --- crates/reprodeck-core/src/evidence.rs | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/crates/reprodeck-core/src/evidence.rs b/crates/reprodeck-core/src/evidence.rs index 47ef102..99f2710 100644 --- a/crates/reprodeck-core/src/evidence.rs +++ b/crates/reprodeck-core/src/evidence.rs @@ -322,11 +322,7 @@ pub fn list_artifacts_for_receipt( /// Read an artifact only by its database identity. No arbitrary filesystem path /// from the frontend is accepted here. -pub fn read_artifact( - conn: &Connection, - storage_dir: &Path, - artifact_id: &str, -) -> Result> { +pub fn read_artifact(conn: &Connection, storage_dir: &Path, artifact_id: &str) -> Result> { let artifact = get_artifact(conn, artifact_id)? .ok_or_else(|| EvidenceError::ArtifactNotFound(artifact_id.to_owned()))?; let relative = validate_store_key(&artifact.store_key)?; @@ -526,8 +522,8 @@ mod tests { let db_file = NamedTempFile::new().unwrap(); let mut conn = init_db(db_file.path()).unwrap(); let receipt_id = receipt(&mut conn); - let contract = verification::create_outcome_contract(&conn, "session", "Outcome", None) - .unwrap(); + let contract = + verification::create_outcome_contract(&conn, "session", "Outcome", None).unwrap(); let check = verification::add_verification_check( &conn, &contract.id, @@ -547,14 +543,8 @@ mod tests { ) .unwrap(); let storage = tempdir().unwrap(); - let artifact = persist_text_artifact( - &conn, - storage.path(), - &receipt_id, - "evidence", - None, - ) - .unwrap(); + let artifact = + persist_text_artifact(&conn, storage.path(), &receipt_id, "evidence", None).unwrap(); link_artifact(&conn, &artifact.id, Some(&run), ArtifactRole::Before).unwrap(); let links = list_artifact_links_for_run(&conn, &run).unwrap(); assert_eq!(links.len(), 1); From ea30fd309213f5d1434daa53ff47561f26c40d25 Mon Sep 17 00:00:00 2001 From: t1k <154753100+t1ktakdev@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:36:58 +0600 Subject: [PATCH 26/73] ci: remove temporary repair formatter --- .github/workflows/format-repair.yml | 38 ----------------------------- 1 file changed, 38 deletions(-) delete mode 100644 .github/workflows/format-repair.yml diff --git a/.github/workflows/format-repair.yml b/.github/workflows/format-repair.yml deleted file mode 100644 index 75fe28a..0000000 --- a/.github/workflows/format-repair.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Temporary repair formatter - -on: - push: - branches: - - chatgpt/repair-foundations - -permissions: - contents: write - -jobs: - rustfmt: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: chatgpt/repair-foundations - fetch-depth: 0 - - - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt - - - name: Format Rust workspace - run: cargo fmt --all - - - name: Commit formatting if needed - shell: bash - run: | - if git diff --quiet; then - echo "Already formatted" - exit 0 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "style: apply rustfmt" - git push origin HEAD:chatgpt/repair-foundations From e4fd1517ca0dc73c32df1e98c50d9f8bdeffd9a3 Mon Sep 17 00:00:00 2001 From: t1k <154753100+t1ktakdev@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:37:47 +0600 Subject: [PATCH 27/73] refactor(bridge): expose core outcome summary without SQL duplication --- src-tauri/src/lib.rs | 185 ++++++++++++++++++++++++++++--------------- 1 file changed, 121 insertions(+), 64 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b7486b3..c8e90e0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -80,6 +80,24 @@ pub struct VerificationRunDto { pub receipt_id: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct OutcomeCheckSummaryDto { + pub check_id: String, + pub stable_id: String, + pub description: String, + pub required: bool, + pub before: Option, + pub after: Option, + pub outcome: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct OutcomeSummaryDto { + pub contract_id: String, + pub overall: String, + pub checks: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct VerdictDto { pub verdict: String, @@ -122,6 +140,56 @@ impl From for ReceiptDto { } } +impl From for ContractDto { + fn from(value: verification::OutcomeContract) -> Self { + Self { + id: value.id, + session_id: value.session_id, + title: value.title, + description: value.description, + state: value.state, + version: value.version, + created_at: value.created_at, + } + } +} + +impl From for VerificationRunDto { + fn from(value: verification::VerificationRun) -> Self { + Self { + id: value.id, + check_id: value.check_id, + phase: value.phase.to_string(), + status: value.status.to_string(), + started_at: value.started_at, + finished_at: value.finished_at, + receipt_id: value.receipt_id, + } + } +} + +impl From for OutcomeSummaryDto { + fn from(value: verification::OutcomeSummary) -> Self { + Self { + contract_id: value.contract_id, + overall: value.overall.to_string(), + checks: value + .checks + .into_iter() + .map(|item| OutcomeCheckSummaryDto { + check_id: item.check.id, + stable_id: item.check.stable_id, + description: item.check.description, + required: item.check.required, + before: item.before.map(|status| status.to_string()), + after: item.after.map(|status| status.to_string()), + outcome: item.outcome.to_string(), + }) + .collect(), + } + } +} + fn app_db_path() -> PathBuf { if let Some(base) = directories::BaseDirs::new() { let mut path = base.data_local_dir().to_path_buf(); @@ -181,80 +249,36 @@ pub fn get_receipt_service(receipt_id: &str) -> Result pub fn list_contracts_service(session_id: Option<&str>) -> Result, BridgeError> { let conn = open_conn()?; - let sql = match session_id { - Some(_) => { - "SELECT id, session_id, title, description, state, version, created_at FROM outcome_contracts WHERE session_id = ?1 ORDER BY created_at DESC, id DESC" - } - None => { - "SELECT id, session_id, title, description, state, version, created_at FROM outcome_contracts ORDER BY created_at DESC, id DESC" - } - }; - let mut stmt = conn - .prepare(sql) - .map_err(|_| BridgeError::database("Unable to prepare the outcome query."))?; - - let map_row = |row: &rusqlite::Row<'_>| -> rusqlite::Result { - Ok(ContractDto { - id: row.get(0)?, - session_id: row.get(1)?, - title: row.get(2)?, - description: row.get(3)?, - state: row.get(4)?, - version: row.get(5)?, - created_at: row.get(6)?, - }) - }; - - let contracts = if let Some(session_id) = session_id { - stmt.query_map(rusqlite::params![session_id], map_row) - .map_err(|_| BridgeError::database("Unable to query outcome contracts."))? - .collect::, _>>() - } else { - stmt.query_map([], map_row) - .map_err(|_| BridgeError::database("Unable to query outcome contracts."))? - .collect::, _>>() - }; - - contracts.map_err(|_| BridgeError::database("Unable to decode outcome contracts.")) + verification::list_outcome_contracts(&conn, session_id) + .map(|values| values.into_iter().map(ContractDto::from).collect()) + .map_err(|_| BridgeError::database("Unable to list outcome contracts.")) } pub fn list_verification_runs_service( contract_id: &str, ) -> Result, BridgeError> { let conn = open_conn()?; - let mut stmt = conn - .prepare( - "SELECT id, check_id, phase, status, started_at, finished_at, receipt_id FROM verification_runs WHERE contract_id = ?1 ORDER BY started_at DESC, id DESC", - ) - .map_err(|_| BridgeError::database("Unable to prepare the verification query."))?; - - let rows = stmt - .query_map(rusqlite::params![contract_id], |row| { - Ok(VerificationRunDto { - id: row.get(0)?, - check_id: row.get(1)?, - phase: row.get(2)?, - status: row.get(3)?, - started_at: row.get(4)?, - finished_at: row.get(5)?, - receipt_id: row.get(6)?, - }) - }) - .map_err(|_| BridgeError::database("Unable to query verification runs."))?; + verification::list_verification_runs(&conn, contract_id) + .map(|values| values.into_iter().map(VerificationRunDto::from).collect()) + .map_err(|_| BridgeError::database("Unable to list verification runs.")) +} - rows.collect::, _>>() - .map_err(|_| BridgeError::database("Unable to decode verification runs.")) +pub fn get_outcome_summary_service(contract_id: &str) -> Result { + let conn = open_conn()?; + verification::get_outcome_summary(&conn, contract_id) + .map(OutcomeSummaryDto::from) + .map_err(|_| { + BridgeError::new( + "evaluation_failed", + "Unable to evaluate this outcome contract.", + ) + }) } pub fn evaluate_contract_service(contract_id: &str) -> Result { - let conn = open_conn()?; - let verdict = verification::evaluate_outcome(&conn, contract_id).map_err(|_| { - BridgeError::new( - "evaluation_failed", - "Unable to evaluate this outcome contract.", - ) - })?; - Ok(VerdictDto { verdict }) + get_outcome_summary_service(contract_id).map(|summary| VerdictDto { + verdict: summary.overall, + }) } #[tauri::command] @@ -287,6 +311,11 @@ fn list_verification_runs(contract_id: String) -> Result list_verification_runs_service(&contract_id) } +#[tauri::command] +fn get_outcome_summary(contract_id: String) -> Result { + get_outcome_summary_service(&contract_id) +} + #[tauri::command] fn evaluate_contract(contract_id: String) -> Result { evaluate_contract_service(&contract_id) @@ -303,6 +332,7 @@ pub fn run() { get_receipt, list_contracts, list_verification_runs, + get_outcome_summary, evaluate_contract, ]) .run(tauri::generate_context!()) @@ -330,6 +360,33 @@ mod tests { assert_eq!(value["verdict"], "VerifiedFix"); } + #[test] + fn outcome_summary_dto_keeps_business_logic_result() { + let summary = verification::OutcomeSummary { + contract_id: "contract".to_string(), + overall: verification::OutcomeState::VerifiedFix, + checks: vec![verification::VerificationCheckSummary { + check: verification::VerificationCheck { + id: "check".to_string(), + contract_id: "contract".to_string(), + stable_id: "regression".to_string(), + description: "Regression".to_string(), + command_ref: None, + expected_condition: None, + required: true, + ordering: 0, + }, + before: Some(verification::RunStatus::Failed), + after: Some(verification::RunStatus::Passed), + outcome: verification::OutcomeState::VerifiedFix, + }], + }; + let dto = OutcomeSummaryDto::from(summary); + assert_eq!(dto.overall, "VerifiedFix"); + assert_eq!(dto.checks[0].before.as_deref(), Some("Failed")); + assert_eq!(dto.checks[0].after.as_deref(), Some("Passed")); + } + #[test] fn bridge_error_does_not_require_internal_error_text() { let error = BridgeError::database("Unable to load timeline."); From 7946f7a66c59aa8d558d88cbe9f54bdc3e6e0df7 Mon Sep 17 00:00:00 2001 From: t1k <154753100+t1ktakdev@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:41:39 +0600 Subject: [PATCH 28/73] fix(git): make shadow apply transactional and path-safe --- crates/reprodeck-core/src/git_shadow.rs | 1909 ++++++++++------------- 1 file changed, 799 insertions(+), 1110 deletions(-) diff --git a/crates/reprodeck-core/src/git_shadow.rs b/crates/reprodeck-core/src/git_shadow.rs index 71e7cc9..e317318 100644 --- a/crates/reprodeck-core/src/git_shadow.rs +++ b/crates/reprodeck-core/src/git_shadow.rs @@ -1,91 +1,580 @@ -use std::fs; +use git2::{Delta, DiffFile, DiffOptions, FileMode, Oid, Repository}; +use std::collections::{BTreeMap, BTreeSet}; +use std::ffi::OsString; +use std::fs::{self, OpenOptions, Permissions}; +use std::io::{self, Write}; use std::path::{Component, Path, PathBuf}; -use std::process::{Command, Stdio}; +use std::process::Command; use thiserror::Error; use uuid::Uuid; -// test-only failure injection moved to per-Shadow instance; no global Lazy/AtomicI32 needed - #[derive(Debug, Error)] pub enum GitShadowError { #[error("git failed: {0} -- {1}")] GitFailed(String, String), - + #[error("git output was not valid UTF-8 for command: {0}")] + GitOutputNotUtf8(String), + #[error("git repository error: {0}")] + Git2(#[from] git2::Error), #[error("repository has no commits (unborn) for path {0}")] UnbornRepository(String), - #[error("patch could not be applied cleanly: {0}")] PatchApplyFailed(String), - #[error("submodule/gitlink changes are not supported")] SubmoduleNotSupported, - + #[error("unsupported Git file type for {0}")] + UnsupportedFileType(String), + #[error("Git path cannot be represented safely on this platform")] + UnsupportedPathEncoding, #[error("IO error: {0}")] - Io(#[from] std::io::Error), - + Io(#[from] io::Error), + #[error("apply failed and rollback also failed; apply={apply_error}; rollback={rollback_error}")] + RollbackFailed { + apply_error: String, + rollback_error: String, + }, #[error("apply succeeded but cleanup failed; pending cleanup marker at {0}")] AppliedCleanupPending(PathBuf), } type Result = std::result::Result; -fn run_git(cwd: &Path, args: &[&str]) -> Result { - let out = Command::new("git") +fn run_git_bytes(cwd: &Path, args: &[&str]) -> Result> { + let output = Command::new("git") .current_dir(cwd) .args(args) .output() .map_err(GitShadowError::Io)?; - if out.status.success() { - Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) + if output.status.success() { + Ok(output.stdout) } else { - let stderr = String::from_utf8_lossy(&out.stderr).to_string(); - // detect unborn repository when asking rev-parse HEAD - if args == ["rev-parse", "HEAD"] { - return Err(GitShadowError::UnbornRepository(stderr)); - } - Err(GitShadowError::GitFailed(args.join(" "), stderr)) + Err(GitShadowError::GitFailed( + args.join(" "), + String::from_utf8_lossy(&output.stderr).into_owned(), + )) } } -#[allow(dead_code)] -fn run_git_with_input(cwd: &Path, args: &[&str], input: &str) -> Result { - let mut cmd = Command::new("git"); - let mut child = cmd - .current_dir(cwd) - .args(args) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() +fn run_git(cwd: &Path, args: &[&str]) -> Result { + let bytes = run_git_bytes(cwd, args)?; + String::from_utf8(bytes) + .map(|value| value.trim().to_string()) + .map_err(|_| GitShadowError::GitOutputNotUtf8(args.join(" "))) +} + +fn run_worktree_add(repo: &Path, branch: &str, worktree: &Path, base: &str) -> Result<()> { + let output = Command::new("git") + .current_dir(repo) + .arg("worktree") + .arg("add") + .arg("-b") + .arg(branch) + .arg(worktree) + .arg(base) + .output() .map_err(GitShadowError::Io)?; - if let Some(mut stdin) = child.stdin.take() { - use std::io::Write; - stdin - .write_all(input.as_bytes()) - .map_err(GitShadowError::Io)?; - } - let out = child.wait_with_output().map_err(GitShadowError::Io)?; - if out.status.success() { - Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) + if output.status.success() { + Ok(()) } else { Err(GitShadowError::GitFailed( - args.join(" "), - String::from_utf8_lossy(&out.stderr).to_string(), + "worktree add".to_string(), + String::from_utf8_lossy(&output.stderr).into_owned(), )) } } -fn run_git_bytes(cwd: &Path, args: &[&str]) -> Result> { - let out = Command::new("git") - .current_dir(cwd) - .args(args) - .output() - .map_err(GitShadowError::Io)?; - if out.status.success() { - Ok(out.stdout) +#[cfg(unix)] +fn git_path(bytes: &[u8]) -> Result { + use std::os::unix::ffi::OsStringExt; + Ok(PathBuf::from(OsString::from_vec(bytes.to_vec()))) +} + +#[cfg(windows)] +fn git_path(bytes: &[u8]) -> Result { + let value = std::str::from_utf8(bytes).map_err(|_| GitShadowError::UnsupportedPathEncoding)?; + Ok(PathBuf::from(value)) +} + +fn diff_path(file: DiffFile<'_>) -> Result { + file.path_bytes() + .ok_or(GitShadowError::UnsupportedPathEncoding) + .and_then(git_path) +} + +fn mode_is_executable(mode: FileMode, path: &Path) -> Result { + match mode { + FileMode::BlobExecutable => Ok(true), + FileMode::Blob | FileMode::BlobGroupWritable => Ok(false), + FileMode::Commit => Err(GitShadowError::SubmoduleNotSupported), + FileMode::Link | FileMode::Tree | FileMode::Unreadable => { + Err(GitShadowError::UnsupportedFileType(format!("{:?}", path))) + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum DesiredState { + Missing, + File { data: Vec, executable: bool }, +} + +#[derive(Debug, Clone)] +struct Mutation { + path: PathBuf, + expected: DesiredState, + desired: DesiredState, +} + +fn file_state_from_diff(repo: &Repository, file: DiffFile<'_>, path: &Path) -> Result { + let executable = mode_is_executable(file.mode(), path)?; + let blob = repo.find_blob(file.id())?; + Ok(DesiredState::File { + data: blob.content().to_vec(), + executable, + }) +} + +fn insert_mutation(mutations: &mut BTreeMap, mutation: Mutation) -> Result<()> { + if mutations.contains_key(&mutation.path) { + return Err(GitShadowError::PatchApplyFailed(format!( + "ambiguous multiple changes for {:?}", + mutation.path + ))); + } + mutations.insert(mutation.path.clone(), mutation); + Ok(()) +} + +fn build_mutations(repo: &Repository, base: Oid, target: Oid) -> Result> { + let base_tree = repo.find_commit(base)?.tree()?; + let target_tree = repo.find_commit(target)?.tree()?; + let mut options = DiffOptions::new(); + options.include_typechange(true); + let diff = repo.diff_tree_to_tree(Some(&base_tree), Some(&target_tree), Some(&mut options))?; + let mut mutations = BTreeMap::::new(); + + for delta in diff.deltas() { + match delta.status() { + Delta::Added => { + let new_file = delta.new_file(); + let path = diff_path(new_file)?; + let desired = file_state_from_diff(repo, new_file, &path)?; + insert_mutation( + &mut mutations, + Mutation { + path, + expected: DesiredState::Missing, + desired, + }, + )?; + } + Delta::Deleted => { + let old_file = delta.old_file(); + let path = diff_path(old_file)?; + let expected = file_state_from_diff(repo, old_file, &path)?; + insert_mutation( + &mut mutations, + Mutation { + path, + expected, + desired: DesiredState::Missing, + }, + )?; + } + Delta::Modified => { + let old_file = delta.old_file(); + let new_file = delta.new_file(); + let old_path = diff_path(old_file)?; + let new_path = diff_path(new_file)?; + if old_path != new_path { + return Err(GitShadowError::PatchApplyFailed(format!( + "unexpected path change in modified delta: {:?} -> {:?}", + old_path, new_path + ))); + } + let expected = file_state_from_diff(repo, old_file, &old_path)?; + let desired = file_state_from_diff(repo, new_file, &new_path)?; + insert_mutation( + &mut mutations, + Mutation { + path: new_path, + expected, + desired, + }, + )?; + } + Delta::Renamed => { + let old_file = delta.old_file(); + let new_file = delta.new_file(); + let old_path = diff_path(old_file)?; + let new_path = diff_path(new_file)?; + let expected = file_state_from_diff(repo, old_file, &old_path)?; + let desired = file_state_from_diff(repo, new_file, &new_path)?; + insert_mutation( + &mut mutations, + Mutation { + path: old_path, + expected, + desired: DesiredState::Missing, + }, + )?; + insert_mutation( + &mut mutations, + Mutation { + path: new_path, + expected: DesiredState::Missing, + desired, + }, + )?; + } + Delta::Copied => { + let new_file = delta.new_file(); + let path = diff_path(new_file)?; + let desired = file_state_from_diff(repo, new_file, &path)?; + insert_mutation( + &mut mutations, + Mutation { + path, + expected: DesiredState::Missing, + desired, + }, + )?; + } + Delta::Typechange => { + return Err(GitShadowError::UnsupportedFileType(format!( + "type change {:?} -> {:?}", + delta.old_file().path_bytes(), + delta.new_file().path_bytes() + ))); + } + Delta::Unmodified => {} + Delta::Ignored | Delta::Untracked | Delta::Unreadable | Delta::Conflicted => { + return Err(GitShadowError::PatchApplyFailed(format!( + "unsupported diff status {:?}", + delta.status() + ))); + } + } + } + + #[cfg(windows)] + { + let mut folded = BTreeSet::new(); + for path in mutations.keys() { + let value = path + .to_str() + .ok_or(GitShadowError::UnsupportedPathEncoding)? + .replace('\\', "/") + .to_lowercase(); + if !folded.insert(value) { + return Err(GitShadowError::PatchApplyFailed( + "case-only or case-colliding path change is not safe on Windows".to_string(), + )); + } + } + } + + Ok(mutations.into_values().collect()) +} + +fn is_symlink_or_reparse(metadata: &fs::Metadata) -> bool { + if metadata.file_type().is_symlink() { + return true; + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + return metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0; + } + #[cfg(not(windows))] + { + false + } +} + +fn ensure_path_within_repo(repo: &Path, relative: &Path) -> Result<()> { + if relative.as_os_str().is_empty() || relative.is_absolute() { + return Err(GitShadowError::PatchApplyFailed(format!( + "unsafe path {:?}", + relative + ))); + } + for component in relative.components() { + if !matches!(component, Component::Normal(_)) { + return Err(GitShadowError::PatchApplyFailed(format!( + "unsafe path component in {:?}", + relative + ))); + } + } + + let repo_root = repo.canonicalize()?; + let mut current = repo_root.clone(); + for component in relative.components() { + if let Component::Normal(name) = component { + current.push(name); + match fs::symlink_metadata(¤t) { + Ok(metadata) => { + if is_symlink_or_reparse(&metadata) { + return Err(GitShadowError::PatchApplyFailed(format!( + "symlink or reparse point is not allowed in apply path {:?}", + relative + ))); + } + let canonical = current.canonicalize()?; + if !canonical.starts_with(&repo_root) { + return Err(GitShadowError::PatchApplyFailed(format!( + "path escapes repository: {:?}", + relative + ))); + } + } + Err(error) if error.kind() == io::ErrorKind::NotFound => break, + Err(error) => return Err(error.into()), + } + } + } + Ok(()) +} + +#[cfg(unix)] +fn executable(metadata: &fs::Metadata) -> bool { + use std::os::unix::fs::PermissionsExt; + metadata.permissions().mode() & 0o111 != 0 +} + +#[cfg(windows)] +fn executable(_metadata: &fs::Metadata) -> bool { + false +} + +#[derive(Debug, Clone)] +enum SnapshotState { + Missing, + File { + data: Vec, + permissions: Permissions, + executable: bool, + }, +} + +fn snapshot_path(repo: &Path, relative: &Path) -> Result { + ensure_path_within_repo(repo, relative)?; + let absolute = repo.join(relative); + let metadata = match fs::symlink_metadata(&absolute) { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(SnapshotState::Missing), + Err(error) => return Err(error.into()), + }; + if is_symlink_or_reparse(&metadata) || !metadata.file_type().is_file() { + return Err(GitShadowError::PatchApplyFailed(format!( + "apply target is not a regular file: {:?}", + relative + ))); + } + Ok(SnapshotState::File { + data: fs::read(&absolute)?, + permissions: metadata.permissions(), + executable: executable(&metadata), + }) +} + +fn snapshot_matches_expected(snapshot: &SnapshotState, expected: &DesiredState) -> bool { + match (snapshot, expected) { + (SnapshotState::Missing, DesiredState::Missing) => true, + ( + SnapshotState::File { + data, executable, .. + }, + DesiredState::File { + data: expected_data, + executable: expected_executable, + }, + ) => { + if data != expected_data { + return false; + } + #[cfg(unix)] + { + executable == expected_executable + } + #[cfg(windows)] + { + let _ = expected_executable; + true + } + } + _ => false, + } +} + +fn collect_missing_parent_dirs(repo: &Path, relative: &Path, output: &mut BTreeSet) { + let Some(parent) = relative.parent() else { + return; + }; + let mut current = repo.to_path_buf(); + for component in parent.components() { + if let Component::Normal(name) = component { + current.push(name); + if !current.exists() { + output.insert(current.clone()); + } + } + } +} + +#[cfg(unix)] +fn desired_permissions(snapshot: &SnapshotState, executable: bool) -> Option { + use std::os::unix::fs::PermissionsExt; + let mut permissions = match snapshot { + SnapshotState::File { permissions, .. } => permissions.clone(), + SnapshotState::Missing => Permissions::from_mode(if executable { 0o755 } else { 0o644 }), + }; + let mode = permissions.mode(); + permissions.set_mode(if executable { + mode | 0o111 } else { - let stderr = String::from_utf8_lossy(&out.stderr).to_string(); - Err(GitShadowError::GitFailed(args.join(" "), stderr)) + mode & !0o111 + }); + Some(permissions) +} + +#[cfg(windows)] +fn desired_permissions(snapshot: &SnapshotState, _executable: bool) -> Option { + match snapshot { + SnapshotState::File { permissions, .. } => Some(permissions.clone()), + SnapshotState::Missing => None, + } +} + +#[cfg(windows)] +fn make_removable(path: &Path) -> io::Result<()> { + if let Ok(metadata) = fs::metadata(path) { + let mut permissions = metadata.permissions(); + if permissions.readonly() { + permissions.set_readonly(false); + fs::set_permissions(path, permissions)?; + } + } + Ok(()) +} + +#[cfg(not(windows))] +fn make_removable(_path: &Path) -> io::Result<()> { + Ok(()) +} + +fn atomic_write(path: &Path, data: &[u8], permissions: Option) -> io::Result<()> { + let parent = path + .parent() + .ok_or_else(|| io::Error::other("file path has no parent"))?; + fs::create_dir_all(parent)?; + let temp = parent.join(format!(".reprodeck-write-{}.tmp", Uuid::new_v4())); + + let result = (|| -> io::Result<()> { + let mut file = OpenOptions::new().write(true).create_new(true).open(&temp)?; + file.write_all(data)?; + file.sync_all()?; + drop(file); + if let Some(permissions) = permissions { + fs::set_permissions(&temp, permissions)?; + } + + #[cfg(windows)] + if path.exists() { + make_removable(path)?; + fs::remove_file(path)?; + } + + fs::rename(&temp, path)?; + Ok(()) + })(); + + if result.is_err() { + let _ = fs::remove_file(&temp); + } + result +} + +fn apply_mutation(repo: &Path, mutation: &Mutation, snapshot: &SnapshotState) -> Result<()> { + ensure_path_within_repo(repo, &mutation.path)?; + let absolute = repo.join(&mutation.path); + match &mutation.desired { + DesiredState::Missing => { + if absolute.exists() { + make_removable(&absolute)?; + fs::remove_file(&absolute)?; + } + } + DesiredState::File { data, executable } => { + if let Some(parent) = absolute.parent() { + fs::create_dir_all(parent)?; + } + ensure_path_within_repo(repo, &mutation.path)?; + atomic_write( + &absolute, + data, + desired_permissions(snapshot, *executable), + )?; + } + } + Ok(()) +} + +fn restore_snapshot(repo: &Path, relative: &Path, snapshot: &SnapshotState) -> io::Result<()> { + let absolute = repo.join(relative); + match snapshot { + SnapshotState::Missing => match fs::symlink_metadata(&absolute) { + Ok(metadata) if metadata.file_type().is_file() => { + make_removable(&absolute)?; + fs::remove_file(&absolute) + } + Ok(_) => Err(io::Error::other("rollback target became a non-file")), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + }, + SnapshotState::File { + data, permissions, .. + } => atomic_write(&absolute, data, Some(permissions.clone())), + } +} + +fn rollback( + repo: &Path, + snapshots: &[(PathBuf, SnapshotState)], + created_dirs: &BTreeSet, +) -> io::Result<()> { + let mut first_error: Option = None; + for (path, snapshot) in snapshots.iter().rev() { + if let Err(error) = restore_snapshot(repo, path, snapshot) { + if first_error.is_none() { + first_error = Some(error); + } + } + } + + let mut dirs: Vec<&PathBuf> = created_dirs.iter().collect(); + dirs.sort_by_key(|path| std::cmp::Reverse(path.components().count())); + for dir in dirs { + match fs::remove_dir(dir) { + Ok(()) => {} + Err(error) + if matches!( + error.kind(), + io::ErrorKind::NotFound | io::ErrorKind::DirectoryNotEmpty + ) => {} + Err(error) => { + if first_error.is_none() { + first_error = Some(error); + } + } + } + } + + match first_error { + Some(error) => Err(error), + None => Ok(()), } } @@ -98,52 +587,44 @@ pub struct Shadow { pub original_head: String, pub original_branch: String, #[cfg(test)] - pub apply_fail_after: std::sync::atomic::AtomicI32, + apply_fail_after: std::sync::atomic::AtomicI32, } impl Shadow { - /// Create a new shadow worktree based on `base_commit` (or HEAD if None). - /// The shadow is implemented using `git worktree add -b `. pub fn create(repo: &Path, base_commit: Option<&str>) -> Result { - // Resolve repository root - let repo_root = PathBuf::from(run_git(repo, &["rev-parse", "--show-toplevel"])?); - - // ensure repository has an initial commit - if run_git(&repo_root, &["rev-parse", "--verify", "HEAD"]).is_err() { - return Err(GitShadowError::UnbornRepository(repo.display().to_string())); - } - let original_branch = run_git(&repo_root, &["rev-parse", "--abbrev-ref", "HEAD"])?; - let original_head = run_git(&repo_root, &["rev-parse", "HEAD"])?; - - let base = match base_commit { - Some(b) => b.to_string(), - None => original_head.clone(), + let discovered = Repository::discover(repo)?; + let repo_root = discovered + .workdir() + .ok_or_else(|| GitShadowError::PatchApplyFailed("bare repositories are not supported".to_string()))? + .canonicalize()?; + let head = discovered + .head() + .map_err(|_| GitShadowError::UnbornRepository(repo.display().to_string()))?; + let original_oid = head + .target() + .ok_or_else(|| GitShadowError::UnbornRepository(repo.display().to_string()))?; + let original_head = original_oid.to_string(); + let original_branch = head.shorthand().unwrap_or("HEAD").to_string(); + let base_oid = match base_commit { + Some(revision) => discovered.revparse_single(revision)?.peel_to_commit()?.id(), + None => original_oid, }; + drop(head); + drop(discovered); - // create a temporary directory for the worktree - let tmp_dir = std::env::temp_dir().join(format!("reprodeck-shadow-{}", Uuid::new_v4())); - std::fs::create_dir_all(&tmp_dir)?; - + let worktree = std::env::temp_dir().join(format!("reprodeck-shadow-{}", Uuid::new_v4())); + fs::create_dir_all(&worktree)?; let branch = format!("reprodeck-shadow-{}", Uuid::new_v4()); + if let Err(error) = run_worktree_add(&repo_root, &branch, &worktree, &base_oid.to_string()) { + let _ = fs::remove_dir_all(&worktree); + return Err(error); + } - // git worktree add -b - run_git( - &repo_root, - &[ - "worktree", - "add", - "-b", - &branch, - tmp_dir.to_str().unwrap(), - &base, - ], - )?; - - Ok(Shadow { + Ok(Self { repo: repo_root, - worktree: tmp_dir, + worktree, branch, - base_commit: base, + base_commit: base_oid.to_string(), original_head, original_branch, #[cfg(test)] @@ -151,11 +632,9 @@ impl Shadow { }) } - /// Commit all changes in the shadow worktree with given message pub fn commit_all(&self, message: &str) -> Result { run_git(&self.worktree, &["add", "-A"])?; run_git(&self.worktree, &["commit", "-m", message])?; - // return new head of shadow branch run_git( &self.repo, &["rev-parse", &format!("refs/heads/{}", self.branch)], @@ -163,14 +642,14 @@ impl Shadow { } #[cfg(test)] - pub fn set_apply_fail_after(&self, v: i32) { + pub fn set_apply_fail_after(&self, value: i32) { self.apply_fail_after - .store(v, std::sync::atomic::Ordering::SeqCst); + .store(value, std::sync::atomic::Ordering::SeqCst); } - /// Get name-status diff between original head and shadow branch + /// Human/display form. Apply itself never parses this string; machine path + /// handling uses libgit2 byte paths instead. pub fn diff_name_status(&self) -> Result { - // machine-parsable name-status (NUL-delimited) run_git( &self.repo, &[ @@ -182,7 +661,6 @@ impl Shadow { ) } - /// Prepare the patch (git diff --binary base..branch) pub fn prepare_patch(&self) -> Result { let patch = run_git( &self.repo, @@ -192,607 +670,123 @@ impl Shadow { &format!("{}..{}", self.base_commit, self.branch), ], )?; - - if patch.contains("new mode 160000") - || patch.contains("old mode 160000") - || patch.contains("GITLINK") - { + if patch.contains("new mode 160000") || patch.contains("old mode 160000") { return Err(GitShadowError::SubmoduleNotSupported); } - Ok(patch) } - /// Apply the shadow patch into the original working tree WITHOUT committing. - /// This will: - /// - verify the repo still exists and HEAD didn't move since creation - /// - perform a dry-run check that the patch can be applied cleanly - /// - apply the patch to the working tree (no commit, no index changes) - /// - /// If the patch cannot be applied cleanly, returns an error and does not - /// mutate the original working tree. + /// Apply the shadow commit to the original working tree without touching the + /// Git index or creating a commit. All affected paths are preflighted and + /// snapshotted before the first mutation; every apply error goes through the + /// same rollback path. pub fn apply(self) -> Result<()> { - // ensure original repo still exists if !self.repo.exists() { - return Err(GitShadowError::Io(std::io::Error::new( - std::io::ErrorKind::NotFound, - "original repository no longer exists", - ))); + return Err(io::Error::new(io::ErrorKind::NotFound, "original repository no longer exists").into()); } - - // ensure original hasn't moved - let current_head = run_git(&self.repo, &["rev-parse", "HEAD"])?; - if current_head != self.original_head { + let repo = Repository::open(&self.repo)?; + let current_head = repo + .head()? + .target() + .ok_or_else(|| GitShadowError::UnbornRepository(self.repo.display().to_string()))?; + if current_head.to_string() != self.original_head { return Err(GitShadowError::GitFailed( - "HEAD moved".into(), - "original HEAD changed since shadow creation".into(), + "HEAD moved".to_string(), + "original HEAD changed since shadow creation".to_string(), )); } - - // Snapshot index (staged entries) so we can restore it after apply. - // We capture `git ls-files -s` output (mode sha stage\tpath) and convert it to - // the format expected by `git update-index --index-info` (mode sha\tpath). - let index_snapshot_bytes = run_git_bytes(&self.repo, &["ls-files", "-s"]).ok(); - let mut index_info_input: Option = None; - let mut index_snapshot_raw: Option = None; - if let Some(b) = index_snapshot_bytes { - let s = String::from_utf8_lossy(&b).to_string(); - index_snapshot_raw = Some(s.clone()); - let mut lines = Vec::new(); - for line in s.lines() { - if line.trim().is_empty() { - continue; - } - if let Some(tabpos) = line.find('\t') { - let left = &line[..tabpos]; - let path = &line[tabpos + 1..]; - let mut parts = left.split_whitespace(); - let mode = parts.next().unwrap_or(""); - let sha = parts.next().unwrap_or(""); - lines.push(format!("{} {}\t{}", mode, sha, path)); - } - } - if !lines.is_empty() { - index_info_input = Some(lines.join("\n") + "\n"); - } - } - - // enumerate name-status to understand operations in a machine-safe way (-z output) - let name_status_z = run_git( - &self.repo, - &[ - "diff", - "-z", - "--name-status", - &format!("{}..{}", self.base_commit, self.branch), - ], - )?; - - #[derive(Debug, Clone)] - enum Change { - Add(PathBuf), - Modify(PathBuf), - Delete(PathBuf), - Rename(PathBuf, PathBuf), - } - - let mut changes: Vec = Vec::new(); - // parse NUL-delimited tokens: status\0path\0 or status\0old\0new\0 for renames - let mut parts: Vec<&str> = name_status_z.split('\u{0}').collect(); - // last element after trailing NUL may be empty; remove it - if let Some(last) = parts.last() { - if last.is_empty() { - parts.pop(); - } - } - let mut i = 0usize; - while i < parts.len() { - let status = parts[i]; - i += 1; - match status.chars().next() { - Some('A') => { - if i < parts.len() { - changes.push(Change::Add(PathBuf::from(parts[i]))); - } - i += 1; - } - Some('M') => { - if i < parts.len() { - changes.push(Change::Modify(PathBuf::from(parts[i]))); - } - i += 1; - } - Some('D') => { - if i < parts.len() { - changes.push(Change::Delete(PathBuf::from(parts[i]))); - } - i += 1; - } - Some('R') => { - // rename uses two paths - if i + 1 < parts.len() { - let old = PathBuf::from(parts[i]); - let new = PathBuf::from(parts[i + 1]); - changes.push(Change::Rename(old, new)); - } - i += 2; - } - _ => { - // unknown status; skip one token to avoid infinite loop - i += 1; - } - } - } - - // prepare conflict detection: for every changed path, compare working tree content to base_commit content - for ch in &changes { - match ch { - Change::Add(p) | Change::Modify(p) | Change::Delete(p) => { - let base_blob = run_git_bytes( - &self.repo, - &["show", &format!("{}:{}", self.base_commit, p.display())], - ) - .ok(); - let work_bytes = std::fs::read(self.repo.join(p)).ok(); - // If working tree differs from base, and shadow modifies it, that's a conflict - if let (Some(b), Some(w)) = (base_blob.as_ref(), work_bytes.as_ref()) { - if b != w { - return Err(GitShadowError::PatchApplyFailed(format!( - "conflict on {}", - p.display() - ))); - } - } - if base_blob.is_none() && work_bytes.is_some() && matches!(ch, Change::Add(_)) { - // file exists locally but wasn't in base; treat as conflict - return Err(GitShadowError::PatchApplyFailed(format!( - "conflict on {} (local addition)", - p.display() - ))); - } - } - Change::Rename(old, _new) => { - let base_blob = run_git_bytes( - &self.repo, - &["show", &format!("{}:{}", self.base_commit, old.display())], - ) - .ok(); - let work_bytes = std::fs::read(self.repo.join(old)).ok(); - if let (Some(b), Some(w)) = (base_blob.as_ref(), work_bytes.as_ref()) { - if b != w { - return Err(GitShadowError::PatchApplyFailed(format!( - "conflict on {} for rename", - old.display() - ))); - } - } - } + let base = Oid::from_str(&self.base_commit)?; + let target = repo.refname_to_id(&format!("refs/heads/{}", self.branch))?; + let mutations = build_mutations(&repo, base, target)?; + drop(repo); + + let mut snapshots = Vec::with_capacity(mutations.len()); + let mut created_dirs = BTreeSet::new(); + for mutation in &mutations { + ensure_path_within_repo(&self.repo, &mutation.path)?; + let snapshot = snapshot_path(&self.repo, &mutation.path)?; + if !snapshot_matches_expected(&snapshot, &mutation.expected) { + return Err(GitShadowError::PatchApplyFailed(format!( + "working tree changed since shadow base at {:?}", + mutation.path + ))); } + collect_missing_parent_dirs(&self.repo, &mutation.path, &mut created_dirs); + snapshots.push((mutation.path.clone(), snapshot)); } - // Build an ApplyPlan: prefetch blobs and metadata for atomic-like apply - #[derive(Debug, Clone)] - enum Op { - Write { - path: PathBuf, - blob: Vec, - executable: bool, - }, - Delete { - path: PathBuf, - }, - } - - let mut plan: Vec = Vec::new(); - for ch in &changes { - match ch { - Change::Add(p) | Change::Modify(p) => { - ensure_path_within_repo(&self.repo, p)?; - let blob = run_git_bytes( - &self.repo, - &["show", &format!("{}:{}", self.branch, p.display())], - )?; - let mut executable = false; - if let Ok(ls) = - run_git(&self.repo, &["ls-tree", &self.branch, &p.to_string_lossy()]) - { - if ls.starts_with("100755") { - executable = true; - } - if ls.starts_with("160000") { - return Err(GitShadowError::SubmoduleNotSupported); - } - } - plan.push(Op::Write { - path: p.clone(), - blob, - executable, - }); - } - Change::Delete(p) => { - ensure_path_within_repo(&self.repo, p)?; - plan.push(Op::Delete { path: p.clone() }); - } - Change::Rename(old, new) => { - if cfg!(windows) - && old.to_string_lossy().to_lowercase() - == new.to_string_lossy().to_lowercase() - && old != new - { - return Err(GitShadowError::PatchApplyFailed(format!( - "case-only rename unsupported on Windows: {} -> {}", - old.display(), - new.display() - ))); - } - ensure_path_within_repo(&self.repo, old)?; - ensure_path_within_repo(&self.repo, new)?; - // fetch blob for new path from shadow branch - let blob = run_git_bytes( - &self.repo, - &["show", &format!("{}:{}", self.branch, new.display())], - )?; - let mut executable = false; - if let Ok(ls) = run_git( - &self.repo, - &["ls-tree", &self.branch, &new.to_string_lossy()], - ) { - if ls.starts_with("100755") { - executable = true; - } - if ls.starts_with("160000") { - return Err(GitShadowError::SubmoduleNotSupported); - } - } - plan.push(Op::Write { - path: new.clone(), - blob, - executable, - }); - plan.push(Op::Delete { path: old.clone() }); - } - } - } - - // Validate plan against working tree (conflicts) - for op in &plan { - match op { - Op::Write { path, .. } => { - let base_blob = run_git_bytes( - &self.repo, - &["show", &format!("{}:{}", self.base_commit, path.display())], - ) - .ok(); - let work_bytes = std::fs::read(self.repo.join(path)).ok(); - if let (Some(b), Some(w)) = (base_blob.as_ref(), work_bytes.as_ref()) { - if b != w { - return Err(GitShadowError::PatchApplyFailed(format!( - "conflict on {}", - path.display() - ))); - } - } - if base_blob.is_none() && work_bytes.is_some() { - return Err(GitShadowError::PatchApplyFailed(format!( - "conflict on {} (local addition)", - path.display() - ))); - } - } - Op::Delete { path } => { - let target = self.repo.join(path); - if target.exists() && target.is_dir() { - return Err(GitShadowError::PatchApplyFailed(format!( - "delete would remove directory: {}", - path.display() - ))); - } - } - } - } - - // Prepare rollback journal (backups) in tempdir - let journal_dir = std::env::temp_dir().join(format!("reprodeck-apply-{}", Uuid::new_v4())); - fs::create_dir_all(&journal_dir)?; - let mut backups: Vec<(PathBuf, Option)> = Vec::new(); - let mut applied_ops: Vec = Vec::new(); - - for (idx, op) in plan.into_iter().enumerate() { - // backup pre-existing file if any - match &op { - Op::Write { path, .. } => { - let target = self.repo.join(path); - if target.exists() { - if target.is_file() { - let bp = journal_dir.join(format!("backup-{}", idx)); - fs::copy(&target, &bp)?; - backups.push((path.clone(), Some(bp))); - } else { - return Err(GitShadowError::PatchApplyFailed(format!( - "unexpected non-file at {}", - path.display() - ))); - } - } else { - backups.push((path.clone(), None)); - } - } - Op::Delete { path } => { - let target = self.repo.join(path); - if target.exists() { - if target.is_file() { - let bp = journal_dir.join(format!("backup-{}", idx)); - fs::copy(&target, &bp)?; - backups.push((path.clone(), Some(bp))); - } else { - return Err(GitShadowError::PatchApplyFailed(format!( - "refuse to remove non-file {}", - path.display() - ))); - } - } else { - backups.push((path.clone(), None)); - } - } - } - - // test injection (per-Shadow, test-only) - #[cfg(test)] - { - let v = self + let apply_result = (|| -> Result<()> { + for (index, mutation) in mutations.iter().enumerate() { + #[cfg(test)] + if self .apply_fail_after - .load(std::sync::atomic::Ordering::SeqCst); - if v >= 0 && (idx as i32) == v { - // simulate failure: rollback from backups - for (p, b) in backups.iter().rev() { - let targ = self.repo.join(p); - if let Some(bp) = b { - let _ = fs::copy(bp, &targ); - } else { - let _ = fs::remove_file(&targ); - } - } - let _ = fs::remove_dir_all(&journal_dir); - return Err(GitShadowError::PatchApplyFailed( - "injected failure".to_string(), - )); - } - } - - // apply op - match &op { - Op::Write { - path, - blob, - executable, - } => { - let target = self.repo.join(path); - if let Some(parent) = target.parent() { - // parent_rel is the relative path within the repo for validation - if let Some(parent_rel) = path.parent() { - // ensure parent exists and still within repo before mutation - ensure_path_within_repo(&self.repo, parent_rel)?; - } - fs::create_dir_all(parent)?; - // double-check parent is still within repo after creation - if let Some(parent_rel) = path.parent() { - ensure_path_within_repo(&self.repo, parent_rel)?; - } - } - - // Platform-specific safe write: - // - On Unix: use openat with O_NOFOLLOW to avoid symlink races - // - On other platforms: perform an additional ensure_path_within_repo check and then write - #[cfg(unix)] - { - use libc::{ - close, fchmod, mode_t, openat, write as libc_write, O_CREAT, - O_DIRECTORY, O_EXCL, O_NOFOLLOW, O_WRONLY, - }; - use std::ffi::CString; - use std::os::unix::ffi::OsStrExt; - - let parent = target.parent().expect("parent exists"); - // open parent dir FD with O_DIRECTORY|O_RDONLY|O_NOFOLLOW - let parent_c = CString::new(parent.as_os_str().as_bytes()).unwrap(); - let dirfd = unsafe { - libc::open(parent_c.as_ptr(), libc::O_RDONLY | O_DIRECTORY | O_NOFOLLOW) - }; - if dirfd < 0 { - return Err(GitShadowError::Io(std::io::Error::last_os_error())); - } - - let name = target.file_name().unwrap().to_string_lossy(); - let name_c = CString::new(name.as_bytes()).unwrap(); - - let fd = unsafe { - openat( - dirfd, - name_c.as_ptr(), - O_CREAT | O_EXCL | O_WRONLY, - 0o644 as mode_t, - ) - }; - if fd < 0 { - unsafe { close(dirfd) }; - return Err(GitShadowError::Io(std::io::Error::last_os_error())); - } - - // write blob fully - let mut written = 0usize; - while written < blob.len() { - let res = unsafe { - libc_write( - fd, - blob[written..].as_ptr() as *const _, - blob.len() - written, - ) - }; - if res < 0 { - unsafe { - close(fd); - close(dirfd) - }; - return Err(GitShadowError::Io(std::io::Error::last_os_error())); - } - written += res as usize; - } - - if *executable { - let r = unsafe { fchmod(fd, 0o755 as mode_t) }; - if r != 0 { - unsafe { - close(fd); - close(dirfd) - }; - return Err(GitShadowError::Io(std::io::Error::last_os_error())); - } - } - - unsafe { - close(fd); - close(dirfd) - }; - } - - #[cfg(not(unix))] - { - // conservative fallback: re-check path and then write - ensure_path_within_repo(&self.repo, path)?; - fs::write(&target, blob)?; - if *executable { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perm = fs::metadata(&target)?.permissions(); - perm.set_mode(0o755); - fs::set_permissions(&target, perm)?; - } - } - } - } - Op::Delete { path } => { - let target = self.repo.join(path); - // validate before delete (path is relative) - ensure_path_within_repo(&self.repo, path)?; - if target.exists() { - fs::remove_file(&target)?; - } + .load(std::sync::atomic::Ordering::SeqCst) + == index as i32 + { + return Err(io::Error::other("injected apply IO failure").into()); } + apply_mutation(&self.repo, mutation, &snapshots[index].1)?; } - - applied_ops.push(op); - } - - // applied successfully; cleanup journal - let _ = fs::remove_dir_all(&journal_dir); - - // verify HEAD unchanged - let after_head = run_git(&self.repo, &["rev-parse", "HEAD"])?; - if after_head != self.original_head { - return Err(GitShadowError::GitFailed( - "HEAD changed".into(), - "unexpected HEAD change after apply".into(), - )); - } - - // restore index snapshot (if any) so pre-existing staged entries are preserved - if let Some(input) = index_info_input { - // update-index --index-info reads lines of: " \t" - let _ = run_git_with_input(&self.repo, &["update-index", "--index-info"], &input) - .map_err(|e| GitShadowError::GitFailed("update-index".into(), format!("{}", e)))?; - // verify index matches snapshot by comparing ls-files -s - if let Ok(after) = run_git(&self.repo, &["ls-files", "-s"]) { - let before_raw = index_snapshot_raw.unwrap_or_default(); - if before_raw.trim_end() != after.trim_end() { - return Err(GitShadowError::GitFailed( - "index_restore_mismatch".into(), - format!( - "index mismatch after restore\nbefore:\n{}\nafter:\n{}", - before_raw, after - ), - )); - } + Ok(()) + })(); + + if let Err(apply_error) = apply_result { + if let Err(rollback_error) = rollback(&self.repo, &snapshots, &created_dirs) { + return Err(GitShadowError::RollbackFailed { + apply_error: apply_error.to_string(), + rollback_error: rollback_error.to_string(), + }); } + return Err(apply_error); } - // attempt cleanup; if cleanup fails, record pending marker and return AppliedCleanupPending - if let Err(_e) = self.discard() { - // record recovery state in ReproDeck-managed storage (not in user repo) - let id = crate::recovery::create_pending( - &self.repo, - &self.base_commit, - &self.worktree, - &self.branch, - ) - .map_err(|e| { - GitShadowError::Io(std::io::Error::other(format!( - "recovery store failed: {}", - e - ))) - })?; - return Err(GitShadowError::AppliedCleanupPending( - std::path::PathBuf::from(id), + if let Err(cleanup_error) = self.discard() { + let marker = std::env::temp_dir().join(format!( + "reprodeck-recovery-{}.txt", + Uuid::new_v4() )); + let message = format!( + "apply succeeded; cleanup pending\nrepo={:?}\nworktree={:?}\nbranch={}\nerror={}\n", + self.repo, self.worktree, self.branch, cleanup_error + ); + fs::write(&marker, message)?; + return Err(GitShadowError::AppliedCleanupPending(marker)); } - Ok(()) } - /// Discard shadow (remove worktree and delete branch). If force is true, - /// force removal of worktree. pub fn discard(&self) -> Result<()> { - // remove worktree - let wt = self.worktree.to_str().ok_or_else(|| { - GitShadowError::Io(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "invalid worktree path", - )) - })?; - // Inspect `git worktree list` and remove any worktree entries that reference this branch - if let Ok(list) = run_git(&self.repo, &["worktree", "list"]) { - for line in list.lines() { - // line format: [branch] - if line.contains(&format!("[{}]", self.branch)) || line.contains(wt) { - if let Some(path_tok) = line.split_whitespace().next() { - let _ = run_git(&self.repo, &["worktree", "remove", path_tok, "--force"]); - } - } + if self.worktree.exists() { + let output = Command::new("git") + .current_dir(&self.repo) + .arg("worktree") + .arg("remove") + .arg("--force") + .arg(&self.worktree) + .output()?; + if !output.status.success() && self.worktree.exists() { + return Err(GitShadowError::GitFailed( + "worktree remove --force".to_string(), + String::from_utf8_lossy(&output.stderr).into_owned(), + )); } } - // Delete branch only if it exists - if run_git( - &self.repo, - &[ - "show-ref", - "--verify", - &format!("refs/heads/{}", self.branch), - ], - ) - .is_ok() - { - // attempt to delete branch; if it fails because some worktree still references it, try to remove referencing entries and retry once - if let Err(_e) = run_git(&self.repo, &["branch", "-D", &self.branch]) { - // try removing any worktree entries that reference this branch and retry - if let Ok(list) = run_git(&self.repo, &["worktree", "list"]) { - for line in list.lines() { - if line.contains(&format!("[{}]", self.branch)) { - if let Some(path_tok) = line.split_whitespace().next() { - let _ = run_git( - &self.repo, - &["worktree", "remove", path_tok, "--force"], - ); - } - } - } - } - // retry delete - run_git(&self.repo, &["branch", "-D", &self.branch])?; - } + let _ = Command::new("git") + .current_dir(&self.repo) + .args(["worktree", "prune"]) + .status(); + + let reference = format!("refs/heads/{}", self.branch); + let branch_exists = Command::new("git") + .current_dir(&self.repo) + .args(["show-ref", "--verify", "--quiet", &reference]) + .status() + .map(|status| status.success()) + .unwrap_or(false); + if branch_exists { + run_git(&self.repo, &["branch", "-D", &self.branch])?; } - - // remove filesystem dir if still exists if self.worktree.exists() { fs::remove_dir_all(&self.worktree)?; } @@ -800,556 +794,251 @@ impl Shadow { } } -/// Snapshot the working tree content (byte-for-byte) for comparison. -#[cfg(test)] -fn snapshot_working_tree(repo: &Path) -> Result)>> { - let mut out = Vec::new(); - for entry in walkdir::WalkDir::new(repo) - .into_iter() - .filter_map(|e| e.ok()) - .filter(|e| { - // skip .git and .reprodeck metadata directory - let p = e.path(); - if p.is_dir() && (p.ends_with(".git") || p.ends_with(".reprodeck")) { - return false; - } - true - }) - { - let p = entry.path(); - if p.is_file() { - let rel = p.strip_prefix(repo).unwrap().to_path_buf(); - let data = std::fs::read(p)?; - out.push((rel, data)); - } - } - // sort for deterministic order - out.sort_by(|a, b| a.0.cmp(&b.0)); - Ok(out) -} - -/// Ensure that a user-provided path (from git diff) is safe to operate on inside `repo`. -fn ensure_path_within_repo(repo: &Path, rel: &Path) -> Result<()> { - // Reject absolute paths - if rel.is_absolute() { - return Err(GitShadowError::PatchApplyFailed(format!( - "absolute path not allowed: {}", - rel.display() - ))); - } - - // Reject parent traversal - for comp in rel.components() { - if matches!(comp, Component::ParentDir) { - return Err(GitShadowError::PatchApplyFailed(format!( - "parent traversal not allowed: {}", - rel.display() - ))); - } - } - - let repo_canon = repo.canonicalize().map_err(GitShadowError::Io)?; - - // find nearest existing ancestor of repo.join(rel) - let target = repo.join(rel); - let mut anc = target.clone(); - while !anc.exists() { - if !anc.pop() { - break; - } - } - let anc_canon = anc.canonicalize().map_err(GitShadowError::Io)?; - if !anc_canon.starts_with(&repo_canon) { - return Err(GitShadowError::PatchApplyFailed(format!( - "path escapes repo: {}", - rel.display() - ))); - } - - // Walk each component from repo root to the nearest ancestor and ensure no symlink points outside - let mut p = repo.to_path_buf(); - for comp in rel.components() { - p.push(comp.as_os_str()); - if p.exists() { - let md = p.symlink_metadata().map_err(GitShadowError::Io)?; - if md.file_type().is_symlink() { - // resolve symlink target - let link = fs::read_link(&p).map_err(GitShadowError::Io)?; - let abs = if link.is_absolute() { - link - } else { - p.parent().unwrap().join(link) - }; - let abs_canon = abs.canonicalize().map_err(GitShadowError::Io)?; - if !abs_canon.starts_with(&repo_canon) { - return Err(GitShadowError::PatchApplyFailed(format!( - "symlink escapes repo at {}", - p.display() - ))); - } - } - } - } - - Ok(()) -} - #[cfg(test)] mod tests { use super::*; use std::fs::{read_to_string, write}; use tempfile::tempdir; - fn init_repo_with_file(dir: &Path, filename: &str, content: &str) -> Result<()> { - run_git(dir, &["init"])?; - // ensure local commit identity so tests don't depend on global git config - run_git(dir, &["config", "user.name", "Tester"])?; - run_git(dir, &["config", "user.email", "tester@example.com"])?; - write(dir.join(filename), content)?; - run_git(dir, &["add", filename])?; - run_git(dir, &["commit", "-m", "initial"])?; - Ok(()) + fn init_repo_with_file(repo: &Path, name: &str, content: &str) { + run_git(repo, &["init"]).unwrap(); + run_git(repo, &["config", "user.email", "tests@reprodeck.local"]).unwrap(); + run_git(repo, &["config", "user.name", "ReproDeck Tests"]).unwrap(); + write(repo.join(name), content).unwrap(); + run_git(repo, &["add", "-A"]).unwrap(); + run_git(repo, &["commit", "-m", "initial"]).unwrap(); } #[test] - fn shadow_does_not_modify_original_until_apply() { + fn original_untouched_until_apply() { let td = tempdir().unwrap(); let repo = td.path(); - - init_repo_with_file(repo, "foo.txt", "base").unwrap(); - + init_repo_with_file(repo, "a.txt", "one"); let shadow = Shadow::create(repo, None).unwrap(); - - // modify file in shadow worktree - let shadow_file = shadow.worktree.join("foo.txt"); - write(&shadow_file, "modified in shadow").unwrap(); - // commit in shadow - shadow.commit_all("shadow change").unwrap(); - - // ensure original file remains unchanged - let orig = read_to_string(repo.join("foo.txt")).unwrap(); - assert_eq!(orig, "base"); - - // check diff reports change (machine-safe NUL-delimited output) - let diff = shadow.diff_name_status().unwrap(); - let parts: Vec<&str> = diff.split('\u{0}').filter(|s| !s.is_empty()).collect(); - let mut found = false; - let mut i = 0usize; - while i + 1 < parts.len() { - let status = parts[i]; - let path = parts[i + 1]; - if status.starts_with('M') && path == "foo.txt" { - found = true; - break; - } - i += 2; - } - assert!(found, "expected modified foo.txt in diff"); - - // ensure original file remains unchanged until apply - let new_orig = read_to_string(repo.join("foo.txt")).unwrap(); - assert_eq!(new_orig, "base"); - - // apply shadow (no commit) + write(shadow.worktree.join("a.txt"), "two").unwrap(); + shadow.commit_all("shadow").unwrap(); + assert_eq!(read_to_string(repo.join("a.txt")).unwrap(), "one"); shadow.apply().unwrap(); - - // now original file should be updated in working tree - let new_orig = read_to_string(repo.join("foo.txt")).unwrap(); - assert_eq!(new_orig, "modified in shadow"); + assert_eq!(read_to_string(repo.join("a.txt")).unwrap(), "two"); } #[test] - fn apply_detects_conflict_when_original_changed() { + fn apply_does_not_move_head_or_commit() { let td = tempdir().unwrap(); let repo = td.path(); - - init_repo_with_file(repo, "bar.txt", "base").unwrap(); - + init_repo_with_file(repo, "a.txt", "one"); + let before = run_git(repo, &["rev-parse", "HEAD"]).unwrap(); let shadow = Shadow::create(repo, None).unwrap(); - - // modify file in shadow - let shadow_file = shadow.worktree.join("bar.txt"); - write(&shadow_file, "changed in shadow").unwrap(); - shadow.commit_all("shadow change").unwrap(); - - // now change original and commit - write(repo.join("bar.txt"), "changed in original").unwrap(); - run_git(repo, &["add", "bar.txt"]).unwrap(); - run_git( - repo, - &[ - "commit", - "-m", - "orig change", - "--author=Orig ", - ], - ) - .unwrap(); - - // applying shadow should fail due to original HEAD mismatch - let res = shadow.apply(); - assert!(res.is_err()); + write(shadow.worktree.join("a.txt"), "two").unwrap(); + shadow.commit_all("shadow").unwrap(); + shadow.apply().unwrap(); + assert_eq!(run_git(repo, &["rev-parse", "HEAD"]).unwrap(), before); + assert_eq!(read_to_string(repo.join("a.txt")).unwrap(), "two"); } #[test] - fn original_unchanged_before_apply() { + fn apply_rejects_moved_head() { let td = tempdir().unwrap(); let repo = td.path(); - - init_repo_with_file(repo, "a.txt", "hello").unwrap(); + init_repo_with_file(repo, "a.txt", "one"); let shadow = Shadow::create(repo, None).unwrap(); - - let shadow_file = shadow.worktree.join("a.txt"); - write(&shadow_file, "shadowed").unwrap(); - shadow.commit_all("shadow change").unwrap(); - - // original must still have original content until apply - let orig = read_to_string(repo.join("a.txt")).unwrap(); - assert_eq!(orig, "hello"); + write(shadow.worktree.join("a.txt"), "shadow").unwrap(); + shadow.commit_all("shadow").unwrap(); + write(repo.join("b.txt"), "new").unwrap(); + run_git(repo, &["add", "-A"]).unwrap(); + run_git(repo, &["commit", "-m", "move head"]).unwrap(); + assert!(shadow.apply().is_err()); + assert_eq!(read_to_string(repo.join("a.txt")).unwrap(), "one"); } #[test] - fn apply_updates_worktree_without_commit() { + fn dirty_conflicting_change_is_rejected() { let td = tempdir().unwrap(); let repo = td.path(); - - init_repo_with_file(repo, "b.txt", "one").unwrap(); - let before_head = run_git(repo, &["rev-parse", "HEAD"]).unwrap(); - + init_repo_with_file(repo, "a.txt", "one"); let shadow = Shadow::create(repo, None).unwrap(); - let shadow_file = shadow.worktree.join("b.txt"); - write(&shadow_file, "two").unwrap(); - shadow.commit_all("shadow change").unwrap(); - - // apply - shadow.apply().unwrap(); - - // working tree updated - let content = read_to_string(repo.join("b.txt")).unwrap(); - assert_eq!(content, "two"); - - // HEAD unchanged - let after_head = run_git(repo, &["rev-parse", "HEAD"]).unwrap(); - assert_eq!(before_head, after_head); + write(shadow.worktree.join("a.txt"), "shadow").unwrap(); + shadow.commit_all("shadow").unwrap(); + write(repo.join("a.txt"), "local").unwrap(); + assert!(shadow.apply().is_err()); + assert_eq!(read_to_string(repo.join("a.txt")).unwrap(), "local"); } #[test] - fn apply_refuses_when_conflicting_user_change_exists() { + fn unrelated_dirty_change_is_preserved() { let td = tempdir().unwrap(); let repo = td.path(); - - init_repo_with_file(repo, "c.txt", "base").unwrap(); + init_repo_with_file(repo, "a.txt", "one"); + write(repo.join("b.txt"), "base").unwrap(); + run_git(repo, &["add", "-A"]).unwrap(); + run_git(repo, &["commit", "-m", "b"]).unwrap(); let shadow = Shadow::create(repo, None).unwrap(); - - // modify in shadow and commit - let shadow_file = shadow.worktree.join("c.txt"); - write(&shadow_file, "shadow").unwrap(); - shadow.commit_all("shadow change").unwrap(); - - // create conflicting dirty change in original (not committed) - write(repo.join("c.txt"), "local-dirty").unwrap(); - - let res = shadow.apply(); - assert!(res.is_err()); - - // original working tree content preserved - let orig = read_to_string(repo.join("c.txt")).unwrap(); - assert_eq!(orig, "local-dirty"); + write(shadow.worktree.join("a.txt"), "shadow").unwrap(); + shadow.commit_all("shadow").unwrap(); + write(repo.join("b.txt"), "local dirty").unwrap(); + shadow.apply().unwrap(); + assert_eq!(read_to_string(repo.join("a.txt")).unwrap(), "shadow"); + assert_eq!(read_to_string(repo.join("b.txt")).unwrap(), "local dirty"); } #[test] - fn apply_preserves_unrelated_dirty_user_changes() { + fn staged_index_is_preserved() { let td = tempdir().unwrap(); let repo = td.path(); - - init_repo_with_file(repo, "d.txt", "base").unwrap(); - write(repo.join("unrelated.txt"), "me").unwrap(); - + init_repo_with_file(repo, "a.txt", "one"); + write(repo.join("staged.txt"), "base").unwrap(); + run_git(repo, &["add", "-A"]).unwrap(); + run_git(repo, &["commit", "-m", "staged base"]).unwrap(); let shadow = Shadow::create(repo, None).unwrap(); - let shadow_file = shadow.worktree.join("d.txt"); - write(&shadow_file, "shadowed").unwrap(); - shadow.commit_all("shadow change").unwrap(); - - // unrelated file is dirty locally - write(repo.join("unrelated.txt"), "me-mod").unwrap(); + write(shadow.worktree.join("a.txt"), "shadow").unwrap(); + shadow.commit_all("shadow").unwrap(); + write(repo.join("staged.txt"), "staged change").unwrap(); + run_git(repo, &["add", "staged.txt"]).unwrap(); + let before = run_git_bytes(repo, &["ls-files", "-s"]).unwrap(); shadow.apply().unwrap(); - - // unrelated preserved - let u = read_to_string(repo.join("unrelated.txt")).unwrap(); - assert_eq!(u, "me-mod"); - - // applied change present - let d = read_to_string(repo.join("d.txt")).unwrap(); - assert_eq!(d, "shadowed"); + let after = run_git_bytes(repo, &["ls-files", "-s"]).unwrap(); + assert_eq!(before, after); } #[test] - fn apply_preserves_preexisting_staged_index() { + fn supports_add_delete_and_rename_as_final_tree_changes() { let td = tempdir().unwrap(); let repo = td.path(); - init_repo_with_file(repo, "a.txt", "one").unwrap(); - - // create and stage an unrelated file in the original repo - std::fs::write(repo.join("staged.txt"), "staged").unwrap(); - run_git(repo, &["add", "staged.txt"]).unwrap(); - let before_index = run_git(repo, &["ls-files", "-s"]).unwrap(); - + init_repo_with_file(repo, "a.txt", "one"); + write(repo.join("b.txt"), "two").unwrap(); + run_git(repo, &["add", "-A"]).unwrap(); + run_git(repo, &["commit", "-m", "b"]).unwrap(); let shadow = Shadow::create(repo, None).unwrap(); - // make a change in shadow and commit - let shadow_file = shadow.worktree.join("a.txt"); - write(&shadow_file, "two").unwrap(); - run_git(&shadow.worktree, &["add", "a.txt"]).unwrap(); - shadow.commit_all("shadow change").unwrap(); - - // apply shadow + run_git(&shadow.worktree, &["mv", "a.txt", "a2.txt"]).unwrap(); + fs::remove_file(shadow.worktree.join("b.txt")).unwrap(); + write(shadow.worktree.join("c.txt"), "three").unwrap(); + shadow.commit_all("tree changes").unwrap(); shadow.apply().unwrap(); - - // index must be preserved exactly - let after_index = run_git(repo, &["ls-files", "-s"]).unwrap(); - assert_eq!(before_index, after_index); + assert!(!repo.join("a.txt").exists()); + assert_eq!(read_to_string(repo.join("a2.txt")).unwrap(), "one"); + assert!(!repo.join("b.txt").exists()); + assert_eq!(read_to_string(repo.join("c.txt")).unwrap(), "three"); } #[test] - fn discard_never_changes_original() { + fn supports_binary_modification() { let td = tempdir().unwrap(); let repo = td.path(); - init_repo_with_file(repo, "e.txt", "base").unwrap(); + init_repo_with_file(repo, "bin.dat", "base"); let shadow = Shadow::create(repo, None).unwrap(); - - // modify in shadow and commit - let shadow_file = shadow.worktree.join("e.txt"); - write(&shadow_file, "shadow").unwrap(); - shadow.commit_all("shadow change").unwrap(); - - // discard shadow resources - shadow.discard().unwrap(); - - // original untouched - let orig = read_to_string(repo.join("e.txt")).unwrap(); - assert_eq!(orig, "base"); + let binary = vec![0, 1, 2, 3, 255, 0, 42]; + fs::write(shadow.worktree.join("bin.dat"), &binary).unwrap(); + shadow.commit_all("binary").unwrap(); + shadow.apply().unwrap(); + assert_eq!(fs::read(repo.join("bin.dat")).unwrap(), binary); } #[test] - fn cleanup_is_idempotent() { + fn generic_apply_error_rolls_back_all_previous_mutations() { let td = tempdir().unwrap(); let repo = td.path(); - init_repo_with_file(repo, "f.txt", "base").unwrap(); + init_repo_with_file(repo, "f1.txt", "one"); + write(repo.join("f2.txt"), "two").unwrap(); + write(repo.join("f3.txt"), "three").unwrap(); + run_git(repo, &["add", "-A"]).unwrap(); + run_git(repo, &["commit", "-m", "files"]).unwrap(); let shadow = Shadow::create(repo, None).unwrap(); - - // discard twice - shadow.discard().unwrap(); - shadow.discard().unwrap(); - - // repo unchanged - let orig = read_to_string(repo.join("f.txt")).unwrap(); - assert_eq!(orig, "base"); - } - - #[test] - fn path_traversal_rejected() { - let td = tempdir().unwrap(); - let repo = td.path(); - init_repo_with_file(repo, "h.txt", "x").unwrap(); - - let bad = Path::new("../evil.txt"); - assert!(ensure_path_within_repo(repo, bad).is_err()); - } - - #[test] - fn absolute_path_rejected() { - let td = tempdir().unwrap(); - let repo = td.path(); - init_repo_with_file(repo, "i.txt", "x").unwrap(); - let bad = if cfg!(windows) { - Path::new("C:\\Windows\\system.ini") - } else { - Path::new("/etc/passwd") - }; - assert!(ensure_path_within_repo(repo, bad).is_err()); - } - - #[test] - #[cfg(unix)] - fn symlink_escape_rejected() { - use std::os::unix::fs::symlink; - let td = tempdir().unwrap(); - let repo = td.path(); - init_repo_with_file(repo, "j.txt", "x").unwrap(); - - let outside = td.path().join("outside.txt"); - std::fs::write(&outside, "secret").unwrap(); - - let link = repo.join("link_out"); - symlink(&outside, &link).unwrap(); - - let rel = Path::new("link_out"); - let res = ensure_path_within_repo(repo, rel); - assert!(res.is_err()); + write(shadow.worktree.join("f1.txt"), "ONE").unwrap(); + write(shadow.worktree.join("f2.txt"), "TWO").unwrap(); + write(shadow.worktree.join("f3.txt"), "THREE").unwrap(); + shadow.commit_all("changes").unwrap(); + shadow.set_apply_fail_after(1); + assert!(shadow.apply().is_err()); + assert_eq!(read_to_string(repo.join("f1.txt")).unwrap(), "one"); + assert_eq!(read_to_string(repo.join("f2.txt")).unwrap(), "two"); + assert_eq!(read_to_string(repo.join("f3.txt")).unwrap(), "three"); } #[test] - fn rename_over_existing_conflict_rejected() { + fn discard_is_idempotent() { let td = tempdir().unwrap(); let repo = td.path(); - init_repo_with_file(repo, "k1.txt", "one").unwrap(); - init_repo_with_file(repo, "k2.txt", "two").unwrap(); - + init_repo_with_file(repo, "a.txt", "one"); let shadow = Shadow::create(repo, None).unwrap(); - // in shadow, remove existing k2 and rename k1 -> k2 - run_git(&shadow.worktree, &["rm", "k2.txt"]).unwrap(); - run_git(&shadow.worktree, &["mv", "k1.txt", "k2.txt"]).unwrap(); - shadow.commit_all("shadow rename to k2").unwrap(); - - // create conflicting dirty change in original (not committed) - write(repo.join("k2.txt"), "local-mod").unwrap(); - - // applying should fail because target exists and is locally modified - let res = shadow.apply(); - assert!(res.is_err()); + shadow.discard().unwrap(); + shadow.discard().unwrap(); + assert!(!shadow.worktree.exists()); } #[test] - fn mid_apply_failure_rolls_back_all_previous_file_changes() { - // Ordering not needed here - // prepare repo + fn unborn_repository_is_rejected_cleanly() { let td = tempdir().unwrap(); - let repo = td.path(); - init_repo_with_file(repo, "f1.txt", "one").unwrap(); - init_repo_with_file(repo, "f2.txt", "two").unwrap(); - init_repo_with_file(repo, "f3.txt", "three").unwrap(); - - let shadow = Shadow::create(repo, None).unwrap(); - // modify files in shadow - write(shadow.worktree.join("f1.txt"), "ONE").unwrap(); - write(shadow.worktree.join("f2.txt"), "TWO").unwrap(); - write(shadow.worktree.join("f3.txt"), "THREE").unwrap(); - run_git(&shadow.worktree, &["add", "f1.txt", "f2.txt", "f3.txt"]).unwrap(); - shadow.commit_all("shadow changes").unwrap(); - - // set failure after first apply op (index 1) - shadow.set_apply_fail_after(1); - - let orig1 = std::fs::read_to_string(repo.join("f1.txt")).unwrap(); - let orig2 = std::fs::read_to_string(repo.join("f2.txt")).unwrap(); - let orig3 = std::fs::read_to_string(repo.join("f3.txt")).unwrap(); - - let res = shadow.apply(); - assert!(res.is_err()); - - // ensure original restored - let now1 = std::fs::read_to_string(repo.join("f1.txt")).unwrap(); - let now2 = std::fs::read_to_string(repo.join("f2.txt")).unwrap(); - let now3 = std::fs::read_to_string(repo.join("f3.txt")).unwrap(); - assert_eq!(orig1, now1); - assert_eq!(orig2, now2); - assert_eq!(orig3, now3); - - // No global reset necessary; the failure injection was per-Shadow and the shadow was consumed. + run_git(td.path(), &["init"]).unwrap(); + assert!(matches!( + Shadow::create(td.path(), None), + Err(GitShadowError::UnbornRepository(_)) + )); } #[test] - fn unborn_repository_is_rejected_cleanly() { + fn path_traversal_is_rejected() { let td = tempdir().unwrap(); let repo = td.path(); - // init repository but do not commit - run_git(repo, &["init"]).unwrap(); - // attempt to create shadow should yield UnbornRepository - let res = Shadow::create(repo, None); - assert!(matches!(res, Err(GitShadowError::UnbornRepository(_)))); + init_repo_with_file(repo, "a.txt", "one"); + assert!(ensure_path_within_repo(repo, Path::new("../outside")).is_err()); + assert!(ensure_path_within_repo(repo, Path::new("./a.txt")).is_err()); } + #[cfg(unix)] #[test] - fn failed_apply_leaves_original_unchanged() { + fn symlink_component_is_rejected() { + use std::os::unix::fs::symlink; let td = tempdir().unwrap(); + let outside = tempdir().unwrap(); let repo = td.path(); - init_repo_with_file(repo, "g.txt", "orig").unwrap(); - - let shadow = Shadow::create(repo, None).unwrap(); - let shadow_file = shadow.worktree.join("g.txt"); - write(&shadow_file, "shadow").unwrap(); - shadow.commit_all("shadow change").unwrap(); - - // create conflicting dirty change in original (not committed) - write(repo.join("g.txt"), "local-dirty").unwrap(); - - let before = snapshot_working_tree(repo).unwrap(); - let res = shadow.apply(); - assert!(res.is_err()); - let after = snapshot_working_tree(repo).unwrap(); - assert_eq!( - before, after, - "working tree must be byte-for-byte identical after failed apply" - ); + init_repo_with_file(repo, "a.txt", "one"); + symlink(outside.path(), repo.join("escape")).unwrap(); + assert!(ensure_path_within_repo(repo, Path::new("escape/file.txt")).is_err()); } + #[cfg(unix)] #[test] - fn apply_supports_new_deleted_and_renamed_files() { + fn modifying_existing_file_works_on_unix() { let td = tempdir().unwrap(); let repo = td.path(); - // initial files - init_repo_with_file(repo, "a.txt", "one").unwrap(); - write(repo.join("b.txt"), "two").unwrap(); - run_git(repo, &["add", "b.txt"]).unwrap(); - run_git(repo, &["commit", "-m", "add b"]).unwrap(); - + init_repo_with_file(repo, "existing.txt", "old"); let shadow = Shadow::create(repo, None).unwrap(); - // rename a.txt -> a2.txt - run_git(&shadow.worktree, &["mv", "a.txt", "a2.txt"]).unwrap(); - // delete b.txt - run_git(&shadow.worktree, &["rm", "b.txt"]).unwrap(); - // new file c.txt - write(shadow.worktree.join("c.txt"), "three").unwrap(); - run_git(&shadow.worktree, &["add", "c.txt"]).unwrap(); - shadow.commit_all("shadow changes").unwrap(); - + write(shadow.worktree.join("existing.txt"), "new").unwrap(); + shadow.commit_all("modify").unwrap(); shadow.apply().unwrap(); - - // checks - assert!(repo.join("a2.txt").exists()); - assert!(!repo.join("b.txt").exists()); - let c = read_to_string(repo.join("c.txt")).unwrap(); - assert_eq!(c, "three"); + assert_eq!(read_to_string(repo.join("existing.txt")).unwrap(), "new"); } + #[cfg(unix)] #[test] - fn apply_supports_binary_file_changes() { + fn non_utf8_git_path_is_applied_without_lossy_conversion() { + use std::os::unix::ffi::OsStringExt; let td = tempdir().unwrap(); let repo = td.path(); - init_repo_with_file(repo, "bin.dat", "").unwrap(); - // write binary data in shadow + init_repo_with_file(repo, "base.txt", "base"); let shadow = Shadow::create(repo, None).unwrap(); - let bin = vec![0u8, 1, 2, 3, 4, 255u8]; - std::fs::write(shadow.worktree.join("bin.dat"), &bin).unwrap(); - run_git(&shadow.worktree, &["add", "bin.dat"]).unwrap(); - shadow.commit_all("binary").unwrap(); - + let name = OsString::from_vec(b"nonutf8-\xff.txt".to_vec()); + fs::write(shadow.worktree.join(&name), b"bytes").unwrap(); + shadow.commit_all("non utf8").unwrap(); shadow.apply().unwrap(); - - let got = std::fs::read(repo.join("bin.dat")).unwrap(); - assert_eq!(got, bin); + assert_eq!(fs::read(repo.join(name)).unwrap(), b"bytes"); } - #[test] #[cfg(unix)] - fn file_mode_executable_bit_behavior() { + #[test] + fn executable_bit_is_applied() { use std::os::unix::fs::PermissionsExt; let td = tempdir().unwrap(); let repo = td.path(); - init_repo_with_file(repo, "ex.sh", "echo hi").unwrap(); + init_repo_with_file(repo, "run.sh", "echo hi\n"); let shadow = Shadow::create(repo, None).unwrap(); - let p = shadow.worktree.join("ex.sh"); - let mut perm = std::fs::metadata(&p).unwrap().permissions(); - perm.set_mode(0o755); - std::fs::set_permissions(&p, perm).unwrap(); - run_git(&shadow.worktree, &["add", "ex.sh"]).unwrap(); - shadow.commit_all("make exec").unwrap(); - + let path = shadow.worktree.join("run.sh"); + let mut permissions = fs::metadata(&path).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&path, permissions).unwrap(); + shadow.commit_all("executable").unwrap(); shadow.apply().unwrap(); - let meta = std::fs::metadata(repo.join("ex.sh")).unwrap(); - assert!( - meta.permissions().mode() & 0o111 != 0, - "executable bit should be set on unix" - ); + assert_ne!(fs::metadata(repo.join("run.sh")).unwrap().permissions().mode() & 0o111, 0); } } From 41f7d4a7a0c1274c82b5c455649edd30777a78e4 Mon Sep 17 00:00:00 2001 From: t1k <154753100+t1ktakdev@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:43:46 +0600 Subject: [PATCH 29/73] ci: temporarily format repair branch --- .github/workflows/format-repair.yml | 38 +++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/format-repair.yml diff --git a/.github/workflows/format-repair.yml b/.github/workflows/format-repair.yml new file mode 100644 index 0000000..75fe28a --- /dev/null +++ b/.github/workflows/format-repair.yml @@ -0,0 +1,38 @@ +name: Temporary repair formatter + +on: + push: + branches: + - chatgpt/repair-foundations + +permissions: + contents: write + +jobs: + rustfmt: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: chatgpt/repair-foundations + fetch-depth: 0 + + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + + - name: Format Rust workspace + run: cargo fmt --all + + - name: Commit formatting if needed + shell: bash + run: | + if git diff --quiet; then + echo "Already formatted" + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "style: apply rustfmt" + git push origin HEAD:chatgpt/repair-foundations From c1af140c6984ced1ab24c4fa5435f09ade6e1fd5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:43:58 +0000 Subject: [PATCH 30/73] style: apply rustfmt --- crates/reprodeck-core/src/git_shadow.rs | 49 +++++++++++++++++-------- 1 file changed, 33 insertions(+), 16 deletions(-) diff --git a/crates/reprodeck-core/src/git_shadow.rs b/crates/reprodeck-core/src/git_shadow.rs index e317318..074faab 100644 --- a/crates/reprodeck-core/src/git_shadow.rs +++ b/crates/reprodeck-core/src/git_shadow.rs @@ -28,7 +28,9 @@ pub enum GitShadowError { UnsupportedPathEncoding, #[error("IO error: {0}")] Io(#[from] io::Error), - #[error("apply failed and rollback also failed; apply={apply_error}; rollback={rollback_error}")] + #[error( + "apply failed and rollback also failed; apply={apply_error}; rollback={rollback_error}" + )] RollbackFailed { apply_error: String, rollback_error: String, @@ -125,7 +127,11 @@ struct Mutation { desired: DesiredState, } -fn file_state_from_diff(repo: &Repository, file: DiffFile<'_>, path: &Path) -> Result { +fn file_state_from_diff( + repo: &Repository, + file: DiffFile<'_>, + path: &Path, +) -> Result { let executable = mode_is_executable(file.mode(), path)?; let blob = repo.find_blob(file.id())?; Ok(DesiredState::File { @@ -473,7 +479,10 @@ fn atomic_write(path: &Path, data: &[u8], permissions: Option) -> i let temp = parent.join(format!(".reprodeck-write-{}.tmp", Uuid::new_v4())); let result = (|| -> io::Result<()> { - let mut file = OpenOptions::new().write(true).create_new(true).open(&temp)?; + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp)?; file.write_all(data)?; file.sync_all()?; drop(file); @@ -512,11 +521,7 @@ fn apply_mutation(repo: &Path, mutation: &Mutation, snapshot: &SnapshotState) -> fs::create_dir_all(parent)?; } ensure_path_within_repo(repo, &mutation.path)?; - atomic_write( - &absolute, - data, - desired_permissions(snapshot, *executable), - )?; + atomic_write(&absolute, data, desired_permissions(snapshot, *executable))?; } } Ok(()) @@ -595,7 +600,9 @@ impl Shadow { let discovered = Repository::discover(repo)?; let repo_root = discovered .workdir() - .ok_or_else(|| GitShadowError::PatchApplyFailed("bare repositories are not supported".to_string()))? + .ok_or_else(|| { + GitShadowError::PatchApplyFailed("bare repositories are not supported".to_string()) + })? .canonicalize()?; let head = discovered .head() @@ -615,7 +622,8 @@ impl Shadow { let worktree = std::env::temp_dir().join(format!("reprodeck-shadow-{}", Uuid::new_v4())); fs::create_dir_all(&worktree)?; let branch = format!("reprodeck-shadow-{}", Uuid::new_v4()); - if let Err(error) = run_worktree_add(&repo_root, &branch, &worktree, &base_oid.to_string()) { + if let Err(error) = run_worktree_add(&repo_root, &branch, &worktree, &base_oid.to_string()) + { let _ = fs::remove_dir_all(&worktree); return Err(error); } @@ -682,7 +690,11 @@ impl Shadow { /// same rollback path. pub fn apply(self) -> Result<()> { if !self.repo.exists() { - return Err(io::Error::new(io::ErrorKind::NotFound, "original repository no longer exists").into()); + return Err(io::Error::new( + io::ErrorKind::NotFound, + "original repository no longer exists", + ) + .into()); } let repo = Repository::open(&self.repo)?; let current_head = repo @@ -741,10 +753,8 @@ impl Shadow { } if let Err(cleanup_error) = self.discard() { - let marker = std::env::temp_dir().join(format!( - "reprodeck-recovery-{}.txt", - Uuid::new_v4() - )); + let marker = + std::env::temp_dir().join(format!("reprodeck-recovery-{}.txt", Uuid::new_v4())); let message = format!( "apply succeeded; cleanup pending\nrepo={:?}\nworktree={:?}\nbranch={}\nerror={}\n", self.repo, self.worktree, self.branch, cleanup_error @@ -1039,6 +1049,13 @@ mod tests { fs::set_permissions(&path, permissions).unwrap(); shadow.commit_all("executable").unwrap(); shadow.apply().unwrap(); - assert_ne!(fs::metadata(repo.join("run.sh")).unwrap().permissions().mode() & 0o111, 0); + assert_ne!( + fs::metadata(repo.join("run.sh")) + .unwrap() + .permissions() + .mode() + & 0o111, + 0 + ); } } From 7a4d27b5b1c2a7f23a9d7d86e9f6f325c4091dd6 Mon Sep 17 00:00:00 2001 From: t1k <154753100+t1ktakdev@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:44:23 +0600 Subject: [PATCH 31/73] ci: remove temporary formatter after rustfmt --- .github/workflows/format-repair.yml | 38 ----------------------------- 1 file changed, 38 deletions(-) delete mode 100644 .github/workflows/format-repair.yml diff --git a/.github/workflows/format-repair.yml b/.github/workflows/format-repair.yml deleted file mode 100644 index 75fe28a..0000000 --- a/.github/workflows/format-repair.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Temporary repair formatter - -on: - push: - branches: - - chatgpt/repair-foundations - -permissions: - contents: write - -jobs: - rustfmt: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: chatgpt/repair-foundations - fetch-depth: 0 - - - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt - - - name: Format Rust workspace - run: cargo fmt --all - - - name: Commit formatting if needed - shell: bash - run: | - if git diff --quiet; then - echo "Already formatted" - exit 0 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "style: apply rustfmt" - git push origin HEAD:chatgpt/repair-foundations From fa3ca3392b02c3c084b50d19d5e661fab0b0dc9a Mon Sep 17 00:00:00 2001 From: t1k <154753100+t1ktakdev@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:48:25 +0600 Subject: [PATCH 32/73] security(permissions): add verification command policy --- crates/reprodeck-core/src/permissions.rs | 203 ++++++++++++++++++++++- 1 file changed, 200 insertions(+), 3 deletions(-) diff --git a/crates/reprodeck-core/src/permissions.rs b/crates/reprodeck-core/src/permissions.rs index 5182e84..9287c0c 100644 --- a/crates/reprodeck-core/src/permissions.rs +++ b/crates/reprodeck-core/src/permissions.rs @@ -1,13 +1,210 @@ use serde::{Deserialize, Serialize}; +use std::path::Path; /// Permission level for potentially unsafe actions (commands, file access). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] pub enum Permission { - /// Always allow without prompting + /// Always allow without prompting. Allow, - /// Ask the user (requires UI), treated as Deny in non-interactive contexts + /// Ask the user before the action. This is the safe default. #[default] Ask, - /// Always deny + /// Always deny. Deny, } + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PermissionReason { + Configured, + HardDeniedPrivilegeEscalation, + UnsafeVerificationCommand, + OpaqueShellCommand, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PermissionDecision { + pub permission: Permission, + pub reason: PermissionReason, + pub explanation: String, +} + +impl PermissionDecision { + fn configured(permission: Permission) -> Self { + Self { + permission, + reason: PermissionReason::Configured, + explanation: "Configured command permission applies.".to_string(), + } + } +} + +fn executable_name(executable: &str) -> String { + let path = Path::new(executable); + let name = path + .file_stem() + .or_else(|| path.file_name()) + .and_then(|value| value.to_str()) + .unwrap_or(executable); + name.to_ascii_lowercase() +} + +fn first_arg(args: &[String]) -> Option<&str> { + args.iter() + .map(String::as_str) + .find(|arg| !arg.trim().is_empty() && !arg.starts_with('-')) +} + +/// Apply the additional policy used specifically for BEFORE/AFTER verification. +/// Verification is evidence-gathering, so it must not silently mutate Git +/// history, publish changes, escalate privileges, or hide arbitrary commands in +/// an opaque shell string. +/// +/// The configured permission is still authoritative for ordinary safe commands: +/// `Ask` remains Ask and `Deny` remains Deny. Only a configured `Allow` can be +/// reduced by the verification safety policy below. +pub fn verification_command_permission( + executable: &str, + args: &[String], + configured: Permission, +) -> PermissionDecision { + if configured != Permission::Allow { + return PermissionDecision::configured(configured); + } + + let executable = executable_name(executable); + + if matches!(executable.as_str(), "sudo" | "doas" | "pkexec" | "runas") { + return PermissionDecision { + permission: Permission::Deny, + reason: PermissionReason::HardDeniedPrivilegeEscalation, + explanation: "Privilege escalation is not allowed from verification.".to_string(), + }; + } + + if matches!( + executable.as_str(), + "sh" | "bash" | "zsh" | "fish" | "cmd" | "powershell" | "pwsh" + ) { + return PermissionDecision { + permission: Permission::Ask, + reason: PermissionReason::OpaqueShellCommand, + explanation: "Shell-wrapped verification commands require explicit approval." + .to_string(), + }; + } + + if executable == "git" { + let subcommand = first_arg(args).unwrap_or("").to_ascii_lowercase(); + if matches!( + subcommand.as_str(), + "push" + | "commit" + | "reset" + | "clean" + | "checkout" + | "switch" + | "merge" + | "rebase" + | "cherry-pick" + | "revert" + | "tag" + | "branch" + | "worktree" + | "stash" + ) { + return PermissionDecision { + permission: Permission::Ask, + reason: PermissionReason::UnsafeVerificationCommand, + explanation: format!( + "`git {subcommand}` may mutate or publish repository state and requires explicit approval." + ), + }; + } + } + + PermissionDecision::configured(Permission::Allow) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_string()).collect() + } + + #[test] + fn ask_and_deny_are_never_upgraded() { + assert_eq!( + verification_command_permission("cargo", &args(&["test"]), Permission::Ask).permission, + Permission::Ask + ); + assert_eq!( + verification_command_permission("cargo", &args(&["test"]), Permission::Deny).permission, + Permission::Deny + ); + } + + #[test] + fn ordinary_test_command_can_use_configured_allow() { + let decision = verification_command_permission( + "cargo", + &args(&["test", "auth::refresh"]), + Permission::Allow, + ); + assert_eq!(decision.permission, Permission::Allow); + assert_eq!(decision.reason, PermissionReason::Configured); + } + + #[test] + fn privilege_escalation_is_hard_denied() { + for executable in ["sudo", "doas", "pkexec", "runas.exe"] { + let decision = verification_command_permission( + executable, + &args(&["anything"]), + Permission::Allow, + ); + assert_eq!(decision.permission, Permission::Deny); + assert_eq!( + decision.reason, + PermissionReason::HardDeniedPrivilegeEscalation + ); + } + } + + #[test] + fn opaque_shell_requires_approval() { + let decision = verification_command_permission( + "powershell.exe", + &args(&["-Command", "Remove-Item -Recurse ."]), + Permission::Allow, + ); + assert_eq!(decision.permission, Permission::Ask); + assert_eq!(decision.reason, PermissionReason::OpaqueShellCommand); + } + + #[test] + fn mutating_git_commands_require_approval() { + for subcommand in ["push", "commit", "reset", "clean", "rebase", "worktree"] { + let decision = verification_command_permission( + "git.exe", + &args(&[subcommand]), + Permission::Allow, + ); + assert_eq!(decision.permission, Permission::Ask, "{subcommand}"); + assert_eq!(decision.reason, PermissionReason::UnsafeVerificationCommand); + } + } + + #[test] + fn read_only_git_commands_can_run_when_allowed() { + for subcommand in ["status", "diff", "rev-parse", "show"] { + let decision = verification_command_permission( + "git", + &args(&[subcommand]), + Permission::Allow, + ); + assert_eq!(decision.permission, Permission::Allow, "{subcommand}"); + } + } +} From 5f45b7c9cce178456761dc37374d1f93c357b5cb Mon Sep 17 00:00:00 2001 From: t1k <154753100+t1ktakdev@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:51:33 +0600 Subject: [PATCH 33/73] ci: temporarily format repair branch --- .github/workflows/format-repair.yml | 35 +++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/format-repair.yml diff --git a/.github/workflows/format-repair.yml b/.github/workflows/format-repair.yml new file mode 100644 index 0000000..c2db7b9 --- /dev/null +++ b/.github/workflows/format-repair.yml @@ -0,0 +1,35 @@ +name: Temporary repair formatter + +on: + push: + branches: + - chatgpt/repair-foundations + +permissions: + contents: write + +jobs: + rustfmt: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: chatgpt/repair-foundations + fetch-depth: 0 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - name: Format Rust workspace + run: cargo fmt --all + - name: Commit formatting if needed + shell: bash + run: | + if git diff --quiet; then + echo "Already formatted" + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "style: apply rustfmt" + git push origin HEAD:chatgpt/repair-foundations From 7eeac7e6f2e6b9c58f44f39a7d025942e2941047 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:51:50 +0000 Subject: [PATCH 34/73] style: apply rustfmt --- crates/reprodeck-core/src/permissions.rs | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/crates/reprodeck-core/src/permissions.rs b/crates/reprodeck-core/src/permissions.rs index 9287c0c..0f70d27 100644 --- a/crates/reprodeck-core/src/permissions.rs +++ b/crates/reprodeck-core/src/permissions.rs @@ -186,11 +186,8 @@ mod tests { #[test] fn mutating_git_commands_require_approval() { for subcommand in ["push", "commit", "reset", "clean", "rebase", "worktree"] { - let decision = verification_command_permission( - "git.exe", - &args(&[subcommand]), - Permission::Allow, - ); + let decision = + verification_command_permission("git.exe", &args(&[subcommand]), Permission::Allow); assert_eq!(decision.permission, Permission::Ask, "{subcommand}"); assert_eq!(decision.reason, PermissionReason::UnsafeVerificationCommand); } @@ -199,11 +196,8 @@ mod tests { #[test] fn read_only_git_commands_can_run_when_allowed() { for subcommand in ["status", "diff", "rev-parse", "show"] { - let decision = verification_command_permission( - "git", - &args(&[subcommand]), - Permission::Allow, - ); + let decision = + verification_command_permission("git", &args(&[subcommand]), Permission::Allow); assert_eq!(decision.permission, Permission::Allow, "{subcommand}"); } } From 9d47319356c03ac5038dca8b0e9614bf5c65ff95 Mon Sep 17 00:00:00 2001 From: t1k <154753100+t1ktakdev@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:54:01 +0600 Subject: [PATCH 35/73] ci: remove temporary formatter workflow --- .github/workflows/format-repair.yml | 35 ----------------------------- 1 file changed, 35 deletions(-) delete mode 100644 .github/workflows/format-repair.yml diff --git a/.github/workflows/format-repair.yml b/.github/workflows/format-repair.yml deleted file mode 100644 index c2db7b9..0000000 --- a/.github/workflows/format-repair.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Temporary repair formatter - -on: - push: - branches: - - chatgpt/repair-foundations - -permissions: - contents: write - -jobs: - rustfmt: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: chatgpt/repair-foundations - fetch-depth: 0 - - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt - - name: Format Rust workspace - run: cargo fmt --all - - name: Commit formatting if needed - shell: bash - run: | - if git diff --quiet; then - echo "Already formatted" - exit 0 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "style: apply rustfmt" - git push origin HEAD:chatgpt/repair-foundations From 223b702c8bc39591efc46aa2c4b8b5113f667fa3 Mon Sep 17 00:00:00 2001 From: t1k <154753100+t1ktakdev@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:57:13 +0600 Subject: [PATCH 36/73] feat(verification): execute checks through runner and evidence pipeline --- .../reprodeck-core/src/verification_exec.rs | 427 ++++++++++++++++++ 1 file changed, 427 insertions(+) create mode 100644 crates/reprodeck-core/src/verification_exec.rs diff --git a/crates/reprodeck-core/src/verification_exec.rs b/crates/reprodeck-core/src/verification_exec.rs new file mode 100644 index 0000000..ddea218 --- /dev/null +++ b/crates/reprodeck-core/src/verification_exec.rs @@ -0,0 +1,427 @@ +use crate::evidence::{self, ArtifactRole}; +use crate::permissions::{self, Permission, PermissionDecision}; +use crate::redaction::{self, RedactionResult}; +use crate::runner::{self, CommandError, CommandSpec}; +use crate::verification::{self, RunPhase, RunStatus}; +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use std::sync::{atomic::AtomicBool, Arc}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum VerificationExecutionError { + #[error("verification command denied: {decision:?}")] + PermissionDenied { decision: PermissionDecision }, + #[error("verification command requires approval: {decision:?}")] + DecisionRequired { decision: PermissionDecision }, + #[error(transparent)] + Verification(#[from] verification::VerificationError), + #[error(transparent)] + Evidence(#[from] evidence::EvidenceError), + #[error(transparent)] + Db(#[from] rusqlite::Error), + #[error(transparent)] + Json(#[from] serde_json::Error), +} + +pub type Result = std::result::Result; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct VerificationExecutionOutcome { + pub run_id: String, + pub receipt_id: String, + pub phase: RunPhase, + pub status: RunStatus, + pub exit_code: Option, + pub stdout_artifact_id: Option, + pub stderr_artifact_id: Option, + pub runner_issue: Option, +} + +fn phase_role(phase: RunPhase) -> ArtifactRole { + match phase { + RunPhase::Before => ArtifactRole::Before, + RunPhase::After => ArtifactRole::After, + } +} + +fn redacted_command_meta( + contract_id: &str, + check_id: &str, + phase: RunPhase, + spec: &CommandSpec, +) -> serde_json::Value { + let args: Vec = spec + .args + .iter() + .map(|arg| redaction::redact_text(arg)) + .collect(); + let env = spec.env.as_ref().map(|values| { + values + .iter() + .map(|(key, value)| { + let display = match redaction::redact_env(key, value) { + RedactionResult::Included(value) => value, + RedactionResult::Redacted { reason } => format!("[REDACTED: {reason}]"), + RedactionResult::Excluded { reason } => format!("[EXCLUDED: {reason}]"), + }; + (key.clone(), display) + }) + .collect::>() + }); + + serde_json::json!({ + "contract_id": contract_id, + "check_id": check_id, + "phase": phase.to_string(), + "command": { + "executable": redaction::redact_text(&spec.executable), + "args": args, + "cwd": spec.cwd.as_ref().map(|path| path.to_string_lossy().into_owned()), + "env": env, + "timeout_ms": spec.timeout.map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64), + "output_limit": spec.output_limit, + } + }) +} + +fn runner_issue(error: &CommandError) -> (&'static str, RunStatus) { + match error { + CommandError::Cancelled => ("cancelled", RunStatus::Interrupted), + CommandError::Timeout => ("timeout", RunStatus::Error), + CommandError::SpawnFailed(_) => ("spawn_failed", RunStatus::Error), + CommandError::Io(_) => ("io_error", RunStatus::Error), + CommandError::OutputLimitExceeded => ("output_limit_exceeded", RunStatus::Error), + CommandError::PermissionDenied => ("permission_denied_after_authorization", RunStatus::Error), + CommandError::DecisionRequired => ("decision_required_after_authorization", RunStatus::Error), + } +} + +fn persist_output( + conn: &Connection, + storage_dir: &Path, + receipt_id: &str, + run_id: &str, + role: ArtifactRole, + text: &str, +) -> Result> { + if text.is_empty() { + return Ok(None); + } + let artifact = evidence::persist_text_artifact( + conn, + storage_dir, + receipt_id, + text, + Some("text/plain; charset=utf-8"), + )?; + evidence::link_artifact(conn, &artifact.id, Some(run_id), role)?; + Ok(Some(artifact.id)) +} + +/// Execute one BEFORE/AFTER verification check through the accepted runner. +/// +/// Permission is evaluated before any Timeline/Verification row is created. +/// Once a run starts, every runner termination path is persisted as a finished +/// verification run and Timeline receipt, so crashes are not represented as +/// successful proof and ordinary command failures are not confused with runner +/// failures. +pub fn execute_verification_check( + conn: &mut Connection, + storage_dir: &Path, + contract_id: &str, + check_id: &str, + phase: RunPhase, + spec: CommandSpec, + configured_permission: Permission, + cancel_token: Option>, +) -> Result { + let decision = permissions::verification_command_permission( + &spec.executable, + &spec.args, + configured_permission, + ); + match decision.permission { + Permission::Deny => { + return Err(VerificationExecutionError::PermissionDenied { decision }); + } + Permission::Ask => { + return Err(VerificationExecutionError::DecisionRequired { decision }); + } + Permission::Allow => {} + } + + let command_meta = redacted_command_meta(contract_id, check_id, phase, &spec); + let run_id = verification::start_verification_check_run(conn, contract_id, check_id, phase)?; + conn.execute( + "UPDATE actions SET meta = ?1 WHERE id = ?2", + rusqlite::params![serde_json::to_string(&command_meta)?, &run_id], + )?; + + match runner::run_command(spec, Permission::Allow, cancel_token) { + Ok(result) => { + let status = if result.exit_code == Some(0) { + RunStatus::Passed + } else { + RunStatus::Failed + }; + let stdout = String::from_utf8_lossy(&result.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&result.stderr).into_owned(); + let receipt_id = verification::finish_verification_run_with_output( + conn, + &run_id, + status, + Some(&stdout), + Some(&stderr), + )?; + let stdout_artifact_id = persist_output( + conn, + storage_dir, + &receipt_id, + &run_id, + phase_role(phase), + &stdout, + )?; + let stderr_artifact_id = persist_output( + conn, + storage_dir, + &receipt_id, + &run_id, + ArtifactRole::Diagnostic, + &stderr, + )?; + Ok(VerificationExecutionOutcome { + run_id, + receipt_id, + phase, + status, + exit_code: result.exit_code, + stdout_artifact_id, + stderr_artifact_id, + runner_issue: None, + }) + } + Err(error) => { + let (issue, status) = runner_issue(&error); + let diagnostic = format!("Verification runner ended with: {issue}"); + let receipt_id = verification::finish_verification_run_with_output( + conn, + &run_id, + status, + None, + Some(&diagnostic), + )?; + let stderr_artifact_id = persist_output( + conn, + storage_dir, + &receipt_id, + &run_id, + ArtifactRole::Diagnostic, + &diagnostic, + )?; + Ok(VerificationExecutionOutcome { + run_id, + receipt_id, + phase, + status, + exit_code: None, + stdout_artifact_id: None, + stderr_artifact_id, + runner_issue: Some(issue.to_string()), + }) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::init_db; + use tempfile::{tempdir, NamedTempFile}; + + fn setup() -> ( + NamedTempFile, + Connection, + verification::OutcomeContract, + verification::VerificationCheck, + ) { + let db_file = NamedTempFile::new().unwrap(); + let conn = init_db(db_file.path()).unwrap(); + conn.execute( + "INSERT INTO sessions(id, repo_id, created_at, updated_at, state) VALUES ('session','repo',1,1,'Active')", + [], + ) + .unwrap(); + let contract = + verification::create_outcome_contract(&conn, "session", "Regression", None).unwrap(); + let check = verification::add_verification_check( + &conn, + &contract.id, + "git-version", + "Git command completes", + Some("git --version"), + Some("exit 0"), + true, + 0, + ) + .unwrap(); + (db_file, conn, contract, check) + } + + fn git_spec(args: &[&str]) -> CommandSpec { + CommandSpec { + executable: "git".to_string(), + args: args.iter().map(|arg| (*arg).to_string()).collect(), + cwd: None, + env: None, + timeout: Some(std::time::Duration::from_secs(10)), + output_limit: Some(64 * 1024), + } + } + + fn run_count(conn: &Connection) -> i64 { + conn.query_row("SELECT COUNT(*) FROM verification_runs", [], |row| row.get(0)) + .unwrap() + } + + #[test] + fn ask_returns_decision_required_before_creating_run() { + let (_db, mut conn, contract, check) = setup(); + let storage = tempdir().unwrap(); + let result = execute_verification_check( + &mut conn, + storage.path(), + &contract.id, + &check.id, + RunPhase::Before, + git_spec(&["--version"]), + Permission::Ask, + None, + ); + assert!(matches!( + result, + Err(VerificationExecutionError::DecisionRequired { .. }) + )); + assert_eq!(run_count(&conn), 0); + } + + #[test] + fn deny_returns_permission_denied_before_creating_run() { + let (_db, mut conn, contract, check) = setup(); + let storage = tempdir().unwrap(); + let result = execute_verification_check( + &mut conn, + storage.path(), + &contract.id, + &check.id, + RunPhase::Before, + git_spec(&["--version"]), + Permission::Deny, + None, + ); + assert!(matches!( + result, + Err(VerificationExecutionError::PermissionDenied { .. }) + )); + assert_eq!(run_count(&conn), 0); + } + + #[test] + fn allowed_command_creates_real_receipt_and_phase_evidence() { + let (_db, mut conn, contract, check) = setup(); + let storage = tempdir().unwrap(); + let outcome = execute_verification_check( + &mut conn, + storage.path(), + &contract.id, + &check.id, + RunPhase::Before, + git_spec(&["--version"]), + Permission::Allow, + None, + ) + .unwrap(); + assert_eq!(outcome.status, RunStatus::Passed); + assert_eq!(outcome.exit_code, Some(0)); + let run = verification::get_verification_run(&conn, &outcome.run_id) + .unwrap() + .unwrap(); + assert_eq!(run.receipt_id.as_deref(), Some(outcome.receipt_id.as_str())); + let receipt = crate::timeline::get_receipt(&conn, &outcome.receipt_id) + .unwrap() + .unwrap(); + assert!(receipt.stdout_preview.unwrap().contains("git version")); + let artifact_id = outcome.stdout_artifact_id.expect("stdout artifact"); + let bytes = evidence::read_artifact(&conn, storage.path(), &artifact_id).unwrap(); + assert!(String::from_utf8(bytes).unwrap().contains("git version")); + let links = evidence::list_artifact_links_for_run(&conn, &outcome.run_id).unwrap(); + assert!(links.iter().any(|link| link.role == ArtifactRole::Before)); + } + + #[test] + fn nonzero_exit_is_failed_not_runner_error() { + let (_db, mut conn, contract, check) = setup(); + let storage = tempdir().unwrap(); + let outcome = execute_verification_check( + &mut conn, + storage.path(), + &contract.id, + &check.id, + RunPhase::After, + git_spec(&["rev-parse", "--verify", "refs/heads/reprodeck-definitely-missing"]), + Permission::Allow, + None, + ) + .unwrap(); + assert_eq!(outcome.status, RunStatus::Failed); + assert!(outcome.runner_issue.is_none()); + assert_ne!(outcome.exit_code, Some(0)); + } + + #[test] + fn pre_cancelled_run_is_persisted_as_interrupted() { + let (_db, mut conn, contract, check) = setup(); + let storage = tempdir().unwrap(); + let token = Arc::new(AtomicBool::new(true)); + let outcome = execute_verification_check( + &mut conn, + storage.path(), + &contract.id, + &check.id, + RunPhase::After, + git_spec(&["--version"]), + Permission::Allow, + Some(token), + ) + .unwrap(); + assert_eq!(outcome.status, RunStatus::Interrupted); + assert_eq!(outcome.runner_issue.as_deref(), Some("cancelled")); + let run = verification::get_verification_run(&conn, &outcome.run_id) + .unwrap() + .unwrap(); + assert_eq!(run.status, RunStatus::Interrupted); + assert!(run.receipt_id.is_some()); + } + + #[test] + fn dangerous_git_mutation_requires_approval_even_when_configured_allow() { + let (_db, mut conn, contract, check) = setup(); + let storage = tempdir().unwrap(); + let result = execute_verification_check( + &mut conn, + storage.path(), + &contract.id, + &check.id, + RunPhase::Before, + git_spec(&["push"]), + Permission::Allow, + None, + ); + assert!(matches!( + result, + Err(VerificationExecutionError::DecisionRequired { .. }) + )); + assert_eq!(run_count(&conn), 0); + } +} From ba7a5e51d628c5dbc8b2fae15cdf38141c26f18a Mon Sep 17 00:00:00 2001 From: t1k <154753100+t1ktakdev@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:57:26 +0600 Subject: [PATCH 37/73] feat(core): export verification execution service --- crates/reprodeck-core/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/reprodeck-core/src/lib.rs b/crates/reprodeck-core/src/lib.rs index 92638c6..759f86d 100644 --- a/crates/reprodeck-core/src/lib.rs +++ b/crates/reprodeck-core/src/lib.rs @@ -13,3 +13,4 @@ pub mod redaction; pub mod runner; pub mod timeline; pub mod verification; +pub mod verification_exec; From 098d48721c59895d5d45a73b67aedb9fd8892ce0 Mon Sep 17 00:00:00 2001 From: t1k <154753100+t1ktakdev@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:59:33 +0600 Subject: [PATCH 38/73] style(verification): match rustfmt output --- .../reprodeck-core/src/verification_exec.rs | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/crates/reprodeck-core/src/verification_exec.rs b/crates/reprodeck-core/src/verification_exec.rs index ddea218..51c08f6 100644 --- a/crates/reprodeck-core/src/verification_exec.rs +++ b/crates/reprodeck-core/src/verification_exec.rs @@ -93,8 +93,12 @@ fn runner_issue(error: &CommandError) -> (&'static str, RunStatus) { CommandError::SpawnFailed(_) => ("spawn_failed", RunStatus::Error), CommandError::Io(_) => ("io_error", RunStatus::Error), CommandError::OutputLimitExceeded => ("output_limit_exceeded", RunStatus::Error), - CommandError::PermissionDenied => ("permission_denied_after_authorization", RunStatus::Error), - CommandError::DecisionRequired => ("decision_required_after_authorization", RunStatus::Error), + CommandError::PermissionDenied => { + ("permission_denied_after_authorization", RunStatus::Error) + } + CommandError::DecisionRequired => { + ("decision_required_after_authorization", RunStatus::Error) + } } } @@ -281,8 +285,10 @@ mod tests { } fn run_count(conn: &Connection) -> i64 { - conn.query_row("SELECT COUNT(*) FROM verification_runs", [], |row| row.get(0)) - .unwrap() + conn.query_row("SELECT COUNT(*) FROM verification_runs", [], |row| { + row.get(0) + }) + .unwrap() } #[test] @@ -369,7 +375,11 @@ mod tests { &contract.id, &check.id, RunPhase::After, - git_spec(&["rev-parse", "--verify", "refs/heads/reprodeck-definitely-missing"]), + git_spec(&[ + "rev-parse", + "--verify", + "refs/heads/reprodeck-definitely-missing", + ]), Permission::Allow, None, ) From 8c34fa9808a64f87f3a6d020ac879b409add99d6 Mon Sep 17 00:00:00 2001 From: t1k <154753100+t1ktakdev@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:03:44 +0600 Subject: [PATCH 39/73] refactor(verification): use typed execution request --- .../reprodeck-core/src/verification_exec.rs | 148 +++++++++++------- 1 file changed, 91 insertions(+), 57 deletions(-) diff --git a/crates/reprodeck-core/src/verification_exec.rs b/crates/reprodeck-core/src/verification_exec.rs index 51c08f6..1a286a7 100644 --- a/crates/reprodeck-core/src/verification_exec.rs +++ b/crates/reprodeck-core/src/verification_exec.rs @@ -27,6 +27,15 @@ pub enum VerificationExecutionError { pub type Result = std::result::Result; +#[derive(Debug)] +pub struct VerificationExecutionRequest { + pub contract_id: String, + pub check_id: String, + pub phase: RunPhase, + pub spec: CommandSpec, + pub configured_permission: Permission, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct VerificationExecutionOutcome { pub run_id: String, @@ -46,12 +55,8 @@ fn phase_role(phase: RunPhase) -> ArtifactRole { } } -fn redacted_command_meta( - contract_id: &str, - check_id: &str, - phase: RunPhase, - spec: &CommandSpec, -) -> serde_json::Value { +fn redacted_command_meta(request: &VerificationExecutionRequest) -> serde_json::Value { + let spec = &request.spec; let args: Vec = spec .args .iter() @@ -72,9 +77,9 @@ fn redacted_command_meta( }); serde_json::json!({ - "contract_id": contract_id, - "check_id": check_id, - "phase": phase.to_string(), + "contract_id": request.contract_id, + "check_id": request.check_id, + "phase": request.phase.to_string(), "command": { "executable": redaction::redact_text(&spec.executable), "args": args, @@ -134,17 +139,13 @@ fn persist_output( pub fn execute_verification_check( conn: &mut Connection, storage_dir: &Path, - contract_id: &str, - check_id: &str, - phase: RunPhase, - spec: CommandSpec, - configured_permission: Permission, + request: VerificationExecutionRequest, cancel_token: Option>, ) -> Result { let decision = permissions::verification_command_permission( - &spec.executable, - &spec.args, - configured_permission, + &request.spec.executable, + &request.spec.args, + request.configured_permission, ); match decision.permission { Permission::Deny => { @@ -156,14 +157,19 @@ pub fn execute_verification_check( Permission::Allow => {} } - let command_meta = redacted_command_meta(contract_id, check_id, phase, &spec); - let run_id = verification::start_verification_check_run(conn, contract_id, check_id, phase)?; + let command_meta = redacted_command_meta(&request); + let run_id = verification::start_verification_check_run( + conn, + &request.contract_id, + &request.check_id, + request.phase, + )?; conn.execute( "UPDATE actions SET meta = ?1 WHERE id = ?2", rusqlite::params![serde_json::to_string(&command_meta)?, &run_id], )?; - match runner::run_command(spec, Permission::Allow, cancel_token) { + match runner::run_command(request.spec, Permission::Allow, cancel_token) { Ok(result) => { let status = if result.exit_code == Some(0) { RunStatus::Passed @@ -184,7 +190,7 @@ pub fn execute_verification_check( storage_dir, &receipt_id, &run_id, - phase_role(phase), + phase_role(request.phase), &stdout, )?; let stderr_artifact_id = persist_output( @@ -198,7 +204,7 @@ pub fn execute_verification_check( Ok(VerificationExecutionOutcome { run_id, receipt_id, - phase, + phase: request.phase, status, exit_code: result.exit_code, stdout_artifact_id, @@ -227,7 +233,7 @@ pub fn execute_verification_check( Ok(VerificationExecutionOutcome { run_id, receipt_id, - phase, + phase: request.phase, status, exit_code: None, stdout_artifact_id: None, @@ -284,6 +290,22 @@ mod tests { } } + fn request( + contract: &verification::OutcomeContract, + check: &verification::VerificationCheck, + phase: RunPhase, + spec: CommandSpec, + permission: Permission, + ) -> VerificationExecutionRequest { + VerificationExecutionRequest { + contract_id: contract.id.clone(), + check_id: check.id.clone(), + phase, + spec, + configured_permission: permission, + } + } + fn run_count(conn: &Connection) -> i64 { conn.query_row("SELECT COUNT(*) FROM verification_runs", [], |row| { row.get(0) @@ -298,11 +320,13 @@ mod tests { let result = execute_verification_check( &mut conn, storage.path(), - &contract.id, - &check.id, - RunPhase::Before, - git_spec(&["--version"]), - Permission::Ask, + request( + &contract, + &check, + RunPhase::Before, + git_spec(&["--version"]), + Permission::Ask, + ), None, ); assert!(matches!( @@ -319,11 +343,13 @@ mod tests { let result = execute_verification_check( &mut conn, storage.path(), - &contract.id, - &check.id, - RunPhase::Before, - git_spec(&["--version"]), - Permission::Deny, + request( + &contract, + &check, + RunPhase::Before, + git_spec(&["--version"]), + Permission::Deny, + ), None, ); assert!(matches!( @@ -340,11 +366,13 @@ mod tests { let outcome = execute_verification_check( &mut conn, storage.path(), - &contract.id, - &check.id, - RunPhase::Before, - git_spec(&["--version"]), - Permission::Allow, + request( + &contract, + &check, + RunPhase::Before, + git_spec(&["--version"]), + Permission::Allow, + ), None, ) .unwrap(); @@ -372,15 +400,17 @@ mod tests { let outcome = execute_verification_check( &mut conn, storage.path(), - &contract.id, - &check.id, - RunPhase::After, - git_spec(&[ - "rev-parse", - "--verify", - "refs/heads/reprodeck-definitely-missing", - ]), - Permission::Allow, + request( + &contract, + &check, + RunPhase::After, + git_spec(&[ + "rev-parse", + "--verify", + "refs/heads/reprodeck-definitely-missing", + ]), + Permission::Allow, + ), None, ) .unwrap(); @@ -397,11 +427,13 @@ mod tests { let outcome = execute_verification_check( &mut conn, storage.path(), - &contract.id, - &check.id, - RunPhase::After, - git_spec(&["--version"]), - Permission::Allow, + request( + &contract, + &check, + RunPhase::After, + git_spec(&["--version"]), + Permission::Allow, + ), Some(token), ) .unwrap(); @@ -421,11 +453,13 @@ mod tests { let result = execute_verification_check( &mut conn, storage.path(), - &contract.id, - &check.id, - RunPhase::Before, - git_spec(&["push"]), - Permission::Allow, + request( + &contract, + &check, + RunPhase::Before, + git_spec(&["push"]), + Permission::Allow, + ), None, ); assert!(matches!( From cc2e1c9d25f0aa6a9cb41d5226288427bf12d3b2 Mon Sep 17 00:00:00 2001 From: t1k <154753100+t1ktakdev@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:08:26 +0600 Subject: [PATCH 40/73] fix(verification): enforce declared expected exit condition --- .../reprodeck-core/src/verification_exec.rs | 108 ++++++++++++++++-- 1 file changed, 99 insertions(+), 9 deletions(-) diff --git a/crates/reprodeck-core/src/verification_exec.rs b/crates/reprodeck-core/src/verification_exec.rs index 1a286a7..fed4cb0 100644 --- a/crates/reprodeck-core/src/verification_exec.rs +++ b/crates/reprodeck-core/src/verification_exec.rs @@ -15,6 +15,8 @@ pub enum VerificationExecutionError { PermissionDenied { decision: PermissionDecision }, #[error("verification command requires approval: {decision:?}")] DecisionRequired { decision: PermissionDecision }, + #[error("unsupported verification expected condition: {0}")] + UnsupportedExpectedCondition(String), #[error(transparent)] Verification(#[from] verification::VerificationError), #[error(transparent)] @@ -55,7 +57,57 @@ fn phase_role(phase: RunPhase) -> ArtifactRole { } } -fn redacted_command_meta(request: &VerificationExecutionRequest) -> serde_json::Value { +/// Outcome checks currently support a deliberately small, deterministic +/// condition language. An absent condition means the conventional `exit 0`. +/// Anything richer is rejected instead of being silently interpreted as pass. +fn parse_expected_exit_code(condition: Option<&str>) -> Result { + let Some(condition) = condition else { + return Ok(0); + }; + let condition = condition.trim(); + if condition.is_empty() { + return Ok(0); + } + + let lower = condition.to_ascii_lowercase(); + if let Some(value) = lower.strip_prefix("exit ") { + return value + .trim() + .parse::() + .map_err(|_| VerificationExecutionError::UnsupportedExpectedCondition(condition.into())); + } + + let compact: String = lower.chars().filter(|ch| !ch.is_whitespace()).collect(); + for prefix in ["exit_code==", "exit_code=", "exit==", "exit="] { + if let Some(value) = compact.strip_prefix(prefix) { + return value.parse::().map_err(|_| { + VerificationExecutionError::UnsupportedExpectedCondition(condition.into()) + }); + } + } + + Err(VerificationExecutionError::UnsupportedExpectedCondition( + condition.into(), + )) +} + +fn get_check( + conn: &Connection, + contract_id: &str, + check_id: &str, +) -> Result { + verification::list_verification_checks(conn, contract_id)? + .into_iter() + .find(|check| check.id == check_id) + .ok_or_else(|| { + verification::VerificationError::CheckNotFound(check_id.to_owned()).into() + }) +} + +fn redacted_command_meta( + request: &VerificationExecutionRequest, + expected_exit_code: i32, +) -> serde_json::Value { let spec = &request.spec; let args: Vec = spec .args @@ -80,6 +132,7 @@ fn redacted_command_meta(request: &VerificationExecutionRequest) -> serde_json:: "contract_id": request.contract_id, "check_id": request.check_id, "phase": request.phase.to_string(), + "expected_exit_code": expected_exit_code, "command": { "executable": redaction::redact_text(&spec.executable), "args": args, @@ -130,18 +183,18 @@ fn persist_output( } /// Execute one BEFORE/AFTER verification check through the accepted runner. -/// -/// Permission is evaluated before any Timeline/Verification row is created. -/// Once a run starts, every runner termination path is persisted as a finished -/// verification run and Timeline receipt, so crashes are not represented as -/// successful proof and ordinary command failures are not confused with runner -/// failures. +/// Permission and expected-condition validation happen before any run is +/// created. Once a run starts, every runner termination path is persisted as a +/// finished verification run and Timeline receipt. pub fn execute_verification_check( conn: &mut Connection, storage_dir: &Path, request: VerificationExecutionRequest, cancel_token: Option>, ) -> Result { + let check = get_check(conn, &request.contract_id, &request.check_id)?; + let expected_exit_code = parse_expected_exit_code(check.expected_condition.as_deref())?; + let decision = permissions::verification_command_permission( &request.spec.executable, &request.spec.args, @@ -157,7 +210,7 @@ pub fn execute_verification_check( Permission::Allow => {} } - let command_meta = redacted_command_meta(&request); + let command_meta = redacted_command_meta(&request, expected_exit_code); let run_id = verification::start_verification_check_run( conn, &request.contract_id, @@ -171,7 +224,7 @@ pub fn execute_verification_check( match runner::run_command(request.spec, Permission::Allow, cancel_token) { Ok(result) => { - let status = if result.exit_code == Some(0) { + let status = if result.exit_code == Some(expected_exit_code) { RunStatus::Passed } else { RunStatus::Failed @@ -313,6 +366,43 @@ mod tests { .unwrap() } + #[test] + fn expected_condition_parser_is_deliberately_small() { + assert_eq!(parse_expected_exit_code(None).unwrap(), 0); + assert_eq!(parse_expected_exit_code(Some("exit 0")).unwrap(), 0); + assert_eq!(parse_expected_exit_code(Some("exit_code == 17")).unwrap(), 17); + assert_eq!(parse_expected_exit_code(Some("exit=-1")).unwrap(), -1); + assert!(matches!( + parse_expected_exit_code(Some("stdout contains success")), + Err(VerificationExecutionError::UnsupportedExpectedCondition(_)) + )); + } + + #[test] + fn unsupported_expectation_is_rejected_before_creating_run() { + let (_db, mut conn, contract, mut check) = setup(); + check.expected_condition = Some("stdout contains success".to_string()); + verification::update_verification_check(&conn, &check).unwrap(); + let storage = tempdir().unwrap(); + let result = execute_verification_check( + &mut conn, + storage.path(), + request( + &contract, + &check, + RunPhase::Before, + git_spec(&["--version"]), + Permission::Allow, + ), + None, + ); + assert!(matches!( + result, + Err(VerificationExecutionError::UnsupportedExpectedCondition(_)) + )); + assert_eq!(run_count(&conn), 0); + } + #[test] fn ask_returns_decision_required_before_creating_run() { let (_db, mut conn, contract, check) = setup(); From 90023445e3848ddf53d1a401789ea71e04dc0e65 Mon Sep 17 00:00:00 2001 From: t1k <154753100+t1ktakdev@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:10:09 +0600 Subject: [PATCH 41/73] security(evidence): enforce run receipt provenance for artifact links --- crates/reprodeck-core/src/evidence.rs | 167 +++++++++++++++++++++++--- 1 file changed, 148 insertions(+), 19 deletions(-) diff --git a/crates/reprodeck-core/src/evidence.rs b/crates/reprodeck-core/src/evidence.rs index 99f2710..9d7343d 100644 --- a/crates/reprodeck-core/src/evidence.rs +++ b/crates/reprodeck-core/src/evidence.rs @@ -18,6 +18,18 @@ pub enum EvidenceError { Clock(#[from] SystemTimeError), #[error("artifact not found: {0}")] ArtifactNotFound(String), + #[error("verification run not found: {0}")] + VerificationRunNotFound(String), + #[error("verification run has no receipt yet: {0}")] + RunNotFinished(String), + #[error( + "artifact {artifact_id} belongs to receipt {artifact_receipt}, not verification receipt {run_receipt}" + )] + ArtifactReceiptMismatch { + artifact_id: String, + artifact_receipt: String, + run_receipt: String, + }, #[error("artifact store key is invalid")] InvalidStoreKey, #[error("artifact integrity check failed: {0}")] @@ -86,14 +98,14 @@ fn parse_role(value: &str) -> Result { } fn is_symlink_or_reparse(path: &Path) -> std::io::Result { - let meta = fs::symlink_metadata(path)?; - let is_symlink = meta.file_type().is_symlink(); + let metadata = fs::symlink_metadata(path)?; + let is_symlink = metadata.file_type().is_symlink(); #[cfg(windows)] { use std::os::windows::fs::MetadataExt; const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; - let is_reparse = (meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT) != 0; + let is_reparse = (metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT) != 0; Ok(is_symlink || is_reparse) } @@ -343,12 +355,41 @@ pub fn read_artifact(conn: &Connection, storage_dir: &Path, artifact_id: &str) - Ok(bytes) } +fn verification_receipt(conn: &Connection, run_id: &str) -> Result { + let receipt = conn + .query_row( + "SELECT receipt_id FROM verification_runs WHERE id = ?1", + rusqlite::params![run_id], + |row| row.get::<_, Option>(0), + ) + .optional()? + .ok_or_else(|| EvidenceError::VerificationRunNotFound(run_id.to_owned()))?; + receipt.ok_or_else(|| EvidenceError::RunNotFinished(run_id.to_owned())) +} + +/// Link an artifact to a verification run only when the artifact was produced by +/// the very same receipt that completed that run. This prevents a valid artifact +/// from another command/run being relabelled as BEFORE/AFTER proof. pub fn link_artifact( conn: &Connection, artifact_id: &str, run_id: Option<&str>, role: ArtifactRole, ) -> Result { + let artifact = get_artifact(conn, artifact_id)? + .ok_or_else(|| EvidenceError::ArtifactNotFound(artifact_id.to_owned()))?; + + if let Some(run_id) = run_id { + let run_receipt = verification_receipt(conn, run_id)?; + if artifact.receipt_id != run_receipt { + return Err(EvidenceError::ArtifactReceiptMismatch { + artifact_id: artifact_id.to_owned(), + artifact_receipt: artifact.receipt_id, + run_receipt, + }); + } + } + let id = Uuid::new_v4().to_string(); let created_at = unix_time_secs()?; conn.execute( @@ -398,10 +439,16 @@ mod tests { use crate::{db::init_db, timeline, verification}; use tempfile::{tempdir, NamedTempFile}; - fn receipt(conn: &mut Connection) -> String { - timeline::create_session(conn, "session", "Active", None).unwrap(); + fn ensure_session(conn: &Connection) { + if timeline::get_session(conn, "session").unwrap().is_none() { + timeline::create_session(conn, "session", "Active", None).unwrap(); + } + } + + fn receipt(conn: &mut Connection, suffix: &str) -> String { + ensure_session(conn); let action = timeline::Action { - id: "action".to_string(), + id: format!("action-{suffix}"), session_id: "session".to_string(), parent_id: None, kind: "command".to_string(), @@ -414,6 +461,39 @@ mod tests { timeline::finish_execution(conn, &execution, "Succeeded", None, None).unwrap() } + fn verification_run_with_receipt(conn: &mut Connection) -> (String, String) { + ensure_session(conn); + let contract = + verification::create_outcome_contract(conn, "session", "Outcome", None).unwrap(); + let check = verification::add_verification_check( + conn, + &contract.id, + "check", + "Check", + None, + Some("exit 0"), + true, + 0, + ) + .unwrap(); + let run = verification::start_verification_check_run( + conn, + &contract.id, + &check.id, + verification::RunPhase::Before, + ) + .unwrap(); + let receipt = verification::finish_verification_run_with_output( + conn, + &run, + verification::RunStatus::Failed, + Some("evidence"), + None, + ) + .unwrap(); + (run, receipt) + } + #[test] fn store_and_check_artifact() { let dir = tempdir().unwrap(); @@ -473,7 +553,7 @@ mod tests { fn text_is_redacted_before_artifact_storage() { let db_file = NamedTempFile::new().unwrap(); let mut conn = init_db(db_file.path()).unwrap(); - let receipt_id = receipt(&mut conn); + let receipt_id = receipt(&mut conn, "redaction"); let storage = tempdir().unwrap(); let secret = "password=hunter2 Bearer secret-token"; let artifact = persist_text_artifact( @@ -495,7 +575,7 @@ mod tests { fn artifact_read_uses_database_identity_and_verifies_integrity() { let db_file = NamedTempFile::new().unwrap(); let mut conn = init_db(db_file.path()).unwrap(); - let receipt_id = receipt(&mut conn); + let receipt_id = receipt(&mut conn, "integrity"); let storage = tempdir().unwrap(); let artifact = persist_binary_attachment( &conn, @@ -518,19 +598,63 @@ mod tests { } #[test] - fn artifact_links_use_verification_run_and_role() { + fn artifact_links_require_same_verification_receipt() { + let db_file = NamedTempFile::new().unwrap(); + let mut conn = init_db(db_file.path()).unwrap(); + let (run, run_receipt) = verification_run_with_receipt(&mut conn); + let storage = tempdir().unwrap(); + let artifact = persist_text_artifact( + &conn, + storage.path(), + &run_receipt, + "evidence", + None, + ) + .unwrap(); + link_artifact(&conn, &artifact.id, Some(&run), ArtifactRole::Before).unwrap(); + let links = list_artifact_links_for_run(&conn, &run).unwrap(); + assert_eq!(links.len(), 1); + assert_eq!(links[0].role, ArtifactRole::Before); + assert_eq!(links[0].artifact_id, artifact.id); + } + + #[test] + fn artifact_from_another_receipt_cannot_be_relabelled_as_run_proof() { + let db_file = NamedTempFile::new().unwrap(); + let mut conn = init_db(db_file.path()).unwrap(); + let unrelated_receipt = receipt(&mut conn, "unrelated"); + let (run, _run_receipt) = verification_run_with_receipt(&mut conn); + let storage = tempdir().unwrap(); + let artifact = persist_text_artifact( + &conn, + storage.path(), + &unrelated_receipt, + "unrelated evidence", + None, + ) + .unwrap(); + assert!(matches!( + link_artifact(&conn, &artifact.id, Some(&run), ArtifactRole::Before), + Err(EvidenceError::ArtifactReceiptMismatch { .. }) + )); + assert!(list_artifact_links_for_run(&conn, &run).unwrap().is_empty()); + } + + #[test] + fn unfinished_run_cannot_receive_proof_artifact() { let db_file = NamedTempFile::new().unwrap(); let mut conn = init_db(db_file.path()).unwrap(); - let receipt_id = receipt(&mut conn); + ensure_session(&conn); + let unrelated_receipt = receipt(&mut conn, "unfinished"); let contract = verification::create_outcome_contract(&conn, "session", "Outcome", None).unwrap(); let check = verification::add_verification_check( &conn, &contract.id, - "check", + "unfinished-check", "Check", None, - None, + Some("exit 0"), true, 0, ) @@ -543,13 +667,18 @@ mod tests { ) .unwrap(); let storage = tempdir().unwrap(); - let artifact = - persist_text_artifact(&conn, storage.path(), &receipt_id, "evidence", None).unwrap(); - link_artifact(&conn, &artifact.id, Some(&run), ArtifactRole::Before).unwrap(); - let links = list_artifact_links_for_run(&conn, &run).unwrap(); - assert_eq!(links.len(), 1); - assert_eq!(links[0].role, ArtifactRole::Before); - assert_eq!(links[0].artifact_id, artifact.id); + let artifact = persist_text_artifact( + &conn, + storage.path(), + &unrelated_receipt, + "evidence", + None, + ) + .unwrap(); + assert!(matches!( + link_artifact(&conn, &artifact.id, Some(&run), ArtifactRole::Before), + Err(EvidenceError::RunNotFinished(_)) + )); } #[test] From 723db62fa2f08acc5ce016949e313392ea8b4fc6 Mon Sep 17 00:00:00 2001 From: t1k <154753100+t1ktakdev@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:11:25 +0600 Subject: [PATCH 42/73] ci: temporarily format current repair changes --- .github/workflows/format-repair.yml | 35 +++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/format-repair.yml diff --git a/.github/workflows/format-repair.yml b/.github/workflows/format-repair.yml new file mode 100644 index 0000000..c2db7b9 --- /dev/null +++ b/.github/workflows/format-repair.yml @@ -0,0 +1,35 @@ +name: Temporary repair formatter + +on: + push: + branches: + - chatgpt/repair-foundations + +permissions: + contents: write + +jobs: + rustfmt: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: chatgpt/repair-foundations + fetch-depth: 0 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - name: Format Rust workspace + run: cargo fmt --all + - name: Commit formatting if needed + shell: bash + run: | + if git diff --quiet; then + echo "Already formatted" + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "style: apply rustfmt" + git push origin HEAD:chatgpt/repair-foundations From d1d2457c67164b02acca8e433e547d44dacec332 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:11:37 +0000 Subject: [PATCH 43/73] style: apply rustfmt --- crates/reprodeck-core/src/evidence.rs | 21 +++++-------------- .../reprodeck-core/src/verification_exec.rs | 16 +++++++------- 2 files changed, 13 insertions(+), 24 deletions(-) diff --git a/crates/reprodeck-core/src/evidence.rs b/crates/reprodeck-core/src/evidence.rs index 9d7343d..39feb3a 100644 --- a/crates/reprodeck-core/src/evidence.rs +++ b/crates/reprodeck-core/src/evidence.rs @@ -603,14 +603,8 @@ mod tests { let mut conn = init_db(db_file.path()).unwrap(); let (run, run_receipt) = verification_run_with_receipt(&mut conn); let storage = tempdir().unwrap(); - let artifact = persist_text_artifact( - &conn, - storage.path(), - &run_receipt, - "evidence", - None, - ) - .unwrap(); + let artifact = + persist_text_artifact(&conn, storage.path(), &run_receipt, "evidence", None).unwrap(); link_artifact(&conn, &artifact.id, Some(&run), ArtifactRole::Before).unwrap(); let links = list_artifact_links_for_run(&conn, &run).unwrap(); assert_eq!(links.len(), 1); @@ -667,14 +661,9 @@ mod tests { ) .unwrap(); let storage = tempdir().unwrap(); - let artifact = persist_text_artifact( - &conn, - storage.path(), - &unrelated_receipt, - "evidence", - None, - ) - .unwrap(); + let artifact = + persist_text_artifact(&conn, storage.path(), &unrelated_receipt, "evidence", None) + .unwrap(); assert!(matches!( link_artifact(&conn, &artifact.id, Some(&run), ArtifactRole::Before), Err(EvidenceError::RunNotFinished(_)) diff --git a/crates/reprodeck-core/src/verification_exec.rs b/crates/reprodeck-core/src/verification_exec.rs index fed4cb0..c925b2c 100644 --- a/crates/reprodeck-core/src/verification_exec.rs +++ b/crates/reprodeck-core/src/verification_exec.rs @@ -71,10 +71,9 @@ fn parse_expected_exit_code(condition: Option<&str>) -> Result { let lower = condition.to_ascii_lowercase(); if let Some(value) = lower.strip_prefix("exit ") { - return value - .trim() - .parse::() - .map_err(|_| VerificationExecutionError::UnsupportedExpectedCondition(condition.into())); + return value.trim().parse::().map_err(|_| { + VerificationExecutionError::UnsupportedExpectedCondition(condition.into()) + }); } let compact: String = lower.chars().filter(|ch| !ch.is_whitespace()).collect(); @@ -99,9 +98,7 @@ fn get_check( verification::list_verification_checks(conn, contract_id)? .into_iter() .find(|check| check.id == check_id) - .ok_or_else(|| { - verification::VerificationError::CheckNotFound(check_id.to_owned()).into() - }) + .ok_or_else(|| verification::VerificationError::CheckNotFound(check_id.to_owned()).into()) } fn redacted_command_meta( @@ -370,7 +367,10 @@ mod tests { fn expected_condition_parser_is_deliberately_small() { assert_eq!(parse_expected_exit_code(None).unwrap(), 0); assert_eq!(parse_expected_exit_code(Some("exit 0")).unwrap(), 0); - assert_eq!(parse_expected_exit_code(Some("exit_code == 17")).unwrap(), 17); + assert_eq!( + parse_expected_exit_code(Some("exit_code == 17")).unwrap(), + 17 + ); assert_eq!(parse_expected_exit_code(Some("exit=-1")).unwrap(), -1); assert!(matches!( parse_expected_exit_code(Some("stdout contains success")), From e4fcc7b4c235e88cfb8cbab3d56889d15d99d0a9 Mon Sep 17 00:00:00 2001 From: t1k <154753100+t1ktakdev@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:12:24 +0600 Subject: [PATCH 44/73] ci: remove temporary formatter after formatting --- .github/workflows/format-repair.yml | 35 ----------------------------- 1 file changed, 35 deletions(-) delete mode 100644 .github/workflows/format-repair.yml diff --git a/.github/workflows/format-repair.yml b/.github/workflows/format-repair.yml deleted file mode 100644 index c2db7b9..0000000 --- a/.github/workflows/format-repair.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Temporary repair formatter - -on: - push: - branches: - - chatgpt/repair-foundations - -permissions: - contents: write - -jobs: - rustfmt: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: chatgpt/repair-foundations - fetch-depth: 0 - - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt - - name: Format Rust workspace - run: cargo fmt --all - - name: Commit formatting if needed - shell: bash - run: | - if git diff --quiet; then - echo "Already formatted" - exit 0 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "style: apply rustfmt" - git push origin HEAD:chatgpt/repair-foundations From a131fd66d28d008137499b6b38208145f19163f5 Mon Sep 17 00:00:00 2001 From: t1k <154753100+t1ktakdev@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:13:39 +0600 Subject: [PATCH 45/73] security(permissions): model explicit verification approval safely --- crates/reprodeck-core/src/permissions.rs | 195 ++++++++++++++++------- 1 file changed, 135 insertions(+), 60 deletions(-) diff --git a/crates/reprodeck-core/src/permissions.rs b/crates/reprodeck-core/src/permissions.rs index 0f70d27..da096f7 100644 --- a/crates/reprodeck-core/src/permissions.rs +++ b/crates/reprodeck-core/src/permissions.rs @@ -16,8 +16,9 @@ pub enum Permission { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum PermissionReason { Configured, + ExplicitApproval, HardDeniedPrivilegeEscalation, - UnsafeVerificationCommand, + HardDeniedVerificationMutation, OpaqueShellCommand, } @@ -36,6 +37,14 @@ impl PermissionDecision { explanation: "Configured command permission applies.".to_string(), } } + + fn explicitly_approved() -> Self { + Self { + permission: Permission::Allow, + reason: PermissionReason::ExplicitApproval, + explanation: "The user explicitly approved this verification command once.".to_string(), + } + } } fn executable_name(executable: &str) -> String { @@ -54,26 +63,53 @@ fn first_arg(args: &[String]) -> Option<&str> { .find(|arg| !arg.trim().is_empty() && !arg.starts_with('-')) } -/// Apply the additional policy used specifically for BEFORE/AFTER verification. -/// Verification is evidence-gathering, so it must not silently mutate Git -/// history, publish changes, escalate privileges, or hide arbitrary commands in -/// an opaque shell string. +fn is_privilege_escalation(executable: &str) -> bool { + matches!(executable, "sudo" | "doas" | "pkexec" | "runas") +} + +fn is_shell(executable: &str) -> bool { + matches!( + executable, + "sh" | "bash" | "zsh" | "fish" | "cmd" | "powershell" | "pwsh" + ) +} + +fn is_mutating_git(subcommand: &str) -> bool { + matches!( + subcommand, + "push" + | "commit" + | "reset" + | "clean" + | "checkout" + | "switch" + | "merge" + | "rebase" + | "cherry-pick" + | "revert" + | "tag" + | "branch" + | "worktree" + | "stash" + ) +} + +/// Evaluate a BEFORE/AFTER verification command. /// -/// The configured permission is still authoritative for ordinary safe commands: -/// `Ask` remains Ask and `Deny` remains Deny. Only a configured `Allow` can be -/// reduced by the verification safety policy below. -pub fn verification_command_permission( +/// Verification is evidence gathering, not a repository mutation mechanism. +/// Privilege escalation and mutating/publishing Git operations are therefore +/// hard-denied even when a caller claims explicit approval. Opaque shell +/// wrappers and configured `Ask` rules can be satisfied by a one-shot explicit +/// approval, while configured `Deny` remains authoritative. +pub fn verification_command_permission_with_approval( executable: &str, args: &[String], configured: Permission, + explicitly_approved_once: bool, ) -> PermissionDecision { - if configured != Permission::Allow { - return PermissionDecision::configured(configured); - } - let executable = executable_name(executable); - if matches!(executable.as_str(), "sudo" | "doas" | "pkexec" | "runas") { + if is_privilege_escalation(&executable) { return PermissionDecision { permission: Permission::Deny, reason: PermissionReason::HardDeniedPrivilegeEscalation, @@ -81,48 +117,47 @@ pub fn verification_command_permission( }; } - if matches!( - executable.as_str(), - "sh" | "bash" | "zsh" | "fish" | "cmd" | "powershell" | "pwsh" - ) { - return PermissionDecision { - permission: Permission::Ask, - reason: PermissionReason::OpaqueShellCommand, - explanation: "Shell-wrapped verification commands require explicit approval." - .to_string(), - }; - } - if executable == "git" { let subcommand = first_arg(args).unwrap_or("").to_ascii_lowercase(); - if matches!( - subcommand.as_str(), - "push" - | "commit" - | "reset" - | "clean" - | "checkout" - | "switch" - | "merge" - | "rebase" - | "cherry-pick" - | "revert" - | "tag" - | "branch" - | "worktree" - | "stash" - ) { + if is_mutating_git(&subcommand) { return PermissionDecision { - permission: Permission::Ask, - reason: PermissionReason::UnsafeVerificationCommand, + permission: Permission::Deny, + reason: PermissionReason::HardDeniedVerificationMutation, explanation: format!( - "`git {subcommand}` may mutate or publish repository state and requires explicit approval." + "`git {subcommand}` is not permitted from verification because verification must not mutate or publish repository state." ), }; } } - PermissionDecision::configured(Permission::Allow) + if configured == Permission::Deny { + return PermissionDecision::configured(Permission::Deny); + } + + if is_shell(&executable) && !explicitly_approved_once { + return PermissionDecision { + permission: Permission::Ask, + reason: PermissionReason::OpaqueShellCommand, + explanation: "Shell-wrapped verification commands require explicit one-shot approval." + .to_string(), + }; + } + + match configured { + Permission::Allow => PermissionDecision::configured(Permission::Allow), + Permission::Ask if explicitly_approved_once => PermissionDecision::explicitly_approved(), + Permission::Ask => PermissionDecision::configured(Permission::Ask), + Permission::Deny => PermissionDecision::configured(Permission::Deny), + } +} + +/// Evaluate a verification command without an explicit one-shot approval. +pub fn verification_command_permission( + executable: &str, + args: &[String], + configured: Permission, +) -> PermissionDecision { + verification_command_permission_with_approval(executable, args, configured, false) } #[cfg(test)] @@ -134,7 +169,7 @@ mod tests { } #[test] - fn ask_and_deny_are_never_upgraded() { + fn ask_and_deny_are_not_silently_upgraded() { assert_eq!( verification_command_permission("cargo", &args(&["test"]), Permission::Ask).permission, Permission::Ask @@ -145,6 +180,18 @@ mod tests { ); } + #[test] + fn explicit_approval_satisfies_ask_once() { + let decision = verification_command_permission_with_approval( + "cargo", + &args(&["test"]), + Permission::Ask, + true, + ); + assert_eq!(decision.permission, Permission::Allow); + assert_eq!(decision.reason, PermissionReason::ExplicitApproval); + } + #[test] fn ordinary_test_command_can_use_configured_allow() { let decision = verification_command_permission( @@ -157,12 +204,13 @@ mod tests { } #[test] - fn privilege_escalation_is_hard_denied() { + fn privilege_escalation_is_hard_denied_even_after_approval() { for executable in ["sudo", "doas", "pkexec", "runas.exe"] { - let decision = verification_command_permission( + let decision = verification_command_permission_with_approval( executable, &args(&["anything"]), Permission::Allow, + true, ); assert_eq!(decision.permission, Permission::Deny); assert_eq!( @@ -173,23 +221,39 @@ mod tests { } #[test] - fn opaque_shell_requires_approval() { - let decision = verification_command_permission( + fn opaque_shell_requires_approval_but_can_be_approved_once() { + let shell_args = args(&["-Command", "cargo test"]); + let first = verification_command_permission( "powershell.exe", - &args(&["-Command", "Remove-Item -Recurse ."]), + &shell_args, Permission::Allow, ); - assert_eq!(decision.permission, Permission::Ask); - assert_eq!(decision.reason, PermissionReason::OpaqueShellCommand); + assert_eq!(first.permission, Permission::Ask); + assert_eq!(first.reason, PermissionReason::OpaqueShellCommand); + + let approved = verification_command_permission_with_approval( + "powershell.exe", + &shell_args, + Permission::Allow, + true, + ); + assert_eq!(approved.permission, Permission::Allow); } #[test] - fn mutating_git_commands_require_approval() { + fn mutating_git_commands_are_hard_denied_from_verification() { for subcommand in ["push", "commit", "reset", "clean", "rebase", "worktree"] { - let decision = - verification_command_permission("git.exe", &args(&[subcommand]), Permission::Allow); - assert_eq!(decision.permission, Permission::Ask, "{subcommand}"); - assert_eq!(decision.reason, PermissionReason::UnsafeVerificationCommand); + let decision = verification_command_permission_with_approval( + "git.exe", + &args(&[subcommand]), + Permission::Allow, + true, + ); + assert_eq!(decision.permission, Permission::Deny, "{subcommand}"); + assert_eq!( + decision.reason, + PermissionReason::HardDeniedVerificationMutation + ); } } @@ -201,4 +265,15 @@ mod tests { assert_eq!(decision.permission, Permission::Allow, "{subcommand}"); } } + + #[test] + fn explicit_approval_does_not_override_configured_deny() { + let decision = verification_command_permission_with_approval( + "cargo", + &args(&["test"]), + Permission::Deny, + true, + ); + assert_eq!(decision.permission, Permission::Deny); + } } From 752fc64c4f3cbecd7610be1bf9adc6743199570a Mon Sep 17 00:00:00 2001 From: t1k <154753100+t1ktakdev@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:14:43 +0600 Subject: [PATCH 46/73] feat(verification): carry explicit one-shot approval into execution --- .../reprodeck-core/src/verification_exec.rs | 57 ++++++++++++++----- 1 file changed, 44 insertions(+), 13 deletions(-) diff --git a/crates/reprodeck-core/src/verification_exec.rs b/crates/reprodeck-core/src/verification_exec.rs index c925b2c..1205328 100644 --- a/crates/reprodeck-core/src/verification_exec.rs +++ b/crates/reprodeck-core/src/verification_exec.rs @@ -36,6 +36,9 @@ pub struct VerificationExecutionRequest { pub phase: RunPhase, pub spec: CommandSpec, pub configured_permission: Permission, + /// True only for a single command the user has just approved in an Ask + /// prompt. It never bypasses configured Deny or verification hard-denies. + pub explicitly_approved_once: bool, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -71,9 +74,10 @@ fn parse_expected_exit_code(condition: Option<&str>) -> Result { let lower = condition.to_ascii_lowercase(); if let Some(value) = lower.strip_prefix("exit ") { - return value.trim().parse::().map_err(|_| { - VerificationExecutionError::UnsupportedExpectedCondition(condition.into()) - }); + return value + .trim() + .parse::() + .map_err(|_| VerificationExecutionError::UnsupportedExpectedCondition(condition.into())); } let compact: String = lower.chars().filter(|ch| !ch.is_whitespace()).collect(); @@ -192,10 +196,11 @@ pub fn execute_verification_check( let check = get_check(conn, &request.contract_id, &request.check_id)?; let expected_exit_code = parse_expected_exit_code(check.expected_condition.as_deref())?; - let decision = permissions::verification_command_permission( + let decision = permissions::verification_command_permission_with_approval( &request.spec.executable, &request.spec.args, request.configured_permission, + request.explicitly_approved_once, ); match decision.permission { Permission::Deny => { @@ -353,6 +358,7 @@ mod tests { phase, spec, configured_permission: permission, + explicitly_approved_once: false, } } @@ -426,6 +432,29 @@ mod tests { assert_eq!(run_count(&conn), 0); } + #[test] + fn one_shot_approval_satisfies_ask_and_creates_run() { + let (_db, mut conn, contract, check) = setup(); + let storage = tempdir().unwrap(); + let mut request = request( + &contract, + &check, + RunPhase::Before, + git_spec(&["--version"]), + Permission::Ask, + ); + request.explicitly_approved_once = true; + let outcome = execute_verification_check( + &mut conn, + storage.path(), + request, + None, + ) + .unwrap(); + assert_eq!(outcome.status, RunStatus::Passed); + assert_eq!(run_count(&conn), 1); + } + #[test] fn deny_returns_permission_denied_before_creating_run() { let (_db, mut conn, contract, check) = setup(); @@ -537,24 +566,26 @@ mod tests { } #[test] - fn dangerous_git_mutation_requires_approval_even_when_configured_allow() { + fn mutating_git_is_hard_denied_from_verification() { let (_db, mut conn, contract, check) = setup(); let storage = tempdir().unwrap(); + let mut request = request( + &contract, + &check, + RunPhase::Before, + git_spec(&["push"]), + Permission::Allow, + ); + request.explicitly_approved_once = true; let result = execute_verification_check( &mut conn, storage.path(), - request( - &contract, - &check, - RunPhase::Before, - git_spec(&["push"]), - Permission::Allow, - ), + request, None, ); assert!(matches!( result, - Err(VerificationExecutionError::DecisionRequired { .. }) + Err(VerificationExecutionError::PermissionDenied { .. }) )); assert_eq!(run_count(&conn), 0); } From 8da67d2e897636eebdf63b8cde9a329100df8150 Mon Sep 17 00:00:00 2001 From: t1k <154753100+t1ktakdev@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:34:53 +0600 Subject: [PATCH 47/73] ci: temporarily auto-apply rustfmt --- .github/workflows/format-repair.yml | 36 +++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .github/workflows/format-repair.yml diff --git a/.github/workflows/format-repair.yml b/.github/workflows/format-repair.yml new file mode 100644 index 0000000..0e00dae --- /dev/null +++ b/.github/workflows/format-repair.yml @@ -0,0 +1,36 @@ +name: Temporary repair formatter + +on: + push: + branches: + - chatgpt/repair-foundations + +permissions: + contents: write + +jobs: + rustfmt: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: chatgpt/repair-foundations + fetch-depth: 0 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - name: Format Rust + run: cargo fmt --all + - name: Commit formatting if needed + shell: bash + run: | + if git diff --quiet; then + echo "No formatting changes needed." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -- '*.rs' + git commit -m "style: apply rustfmt" + git push origin HEAD:chatgpt/repair-foundations From 7c5044457ea13b7ff322cfafaaafc4b31a001ac9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:35:04 +0000 Subject: [PATCH 48/73] style: apply rustfmt --- crates/reprodeck-core/src/permissions.rs | 7 ++---- .../reprodeck-core/src/verification_exec.rs | 22 +++++-------------- 2 files changed, 7 insertions(+), 22 deletions(-) diff --git a/crates/reprodeck-core/src/permissions.rs b/crates/reprodeck-core/src/permissions.rs index da096f7..a2ac134 100644 --- a/crates/reprodeck-core/src/permissions.rs +++ b/crates/reprodeck-core/src/permissions.rs @@ -223,11 +223,8 @@ mod tests { #[test] fn opaque_shell_requires_approval_but_can_be_approved_once() { let shell_args = args(&["-Command", "cargo test"]); - let first = verification_command_permission( - "powershell.exe", - &shell_args, - Permission::Allow, - ); + let first = + verification_command_permission("powershell.exe", &shell_args, Permission::Allow); assert_eq!(first.permission, Permission::Ask); assert_eq!(first.reason, PermissionReason::OpaqueShellCommand); diff --git a/crates/reprodeck-core/src/verification_exec.rs b/crates/reprodeck-core/src/verification_exec.rs index 1205328..20dbcf6 100644 --- a/crates/reprodeck-core/src/verification_exec.rs +++ b/crates/reprodeck-core/src/verification_exec.rs @@ -74,10 +74,9 @@ fn parse_expected_exit_code(condition: Option<&str>) -> Result { let lower = condition.to_ascii_lowercase(); if let Some(value) = lower.strip_prefix("exit ") { - return value - .trim() - .parse::() - .map_err(|_| VerificationExecutionError::UnsupportedExpectedCondition(condition.into())); + return value.trim().parse::().map_err(|_| { + VerificationExecutionError::UnsupportedExpectedCondition(condition.into()) + }); } let compact: String = lower.chars().filter(|ch| !ch.is_whitespace()).collect(); @@ -444,13 +443,7 @@ mod tests { Permission::Ask, ); request.explicitly_approved_once = true; - let outcome = execute_verification_check( - &mut conn, - storage.path(), - request, - None, - ) - .unwrap(); + let outcome = execute_verification_check(&mut conn, storage.path(), request, None).unwrap(); assert_eq!(outcome.status, RunStatus::Passed); assert_eq!(run_count(&conn), 1); } @@ -577,12 +570,7 @@ mod tests { Permission::Allow, ); request.explicitly_approved_once = true; - let result = execute_verification_check( - &mut conn, - storage.path(), - request, - None, - ); + let result = execute_verification_check(&mut conn, storage.path(), request, None); assert!(matches!( result, Err(VerificationExecutionError::PermissionDenied { .. }) From ff4f91142f64abeacab1ba5217482b7af54dda55 Mon Sep 17 00:00:00 2001 From: t1k <154753100+t1ktakdev@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:35:57 +0600 Subject: [PATCH 49/73] ci: remove temporary formatter --- .github/workflows/format-repair.yml | 36 ----------------------------- 1 file changed, 36 deletions(-) delete mode 100644 .github/workflows/format-repair.yml diff --git a/.github/workflows/format-repair.yml b/.github/workflows/format-repair.yml deleted file mode 100644 index 0e00dae..0000000 --- a/.github/workflows/format-repair.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Temporary repair formatter - -on: - push: - branches: - - chatgpt/repair-foundations - -permissions: - contents: write - -jobs: - rustfmt: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: chatgpt/repair-foundations - fetch-depth: 0 - - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt - - name: Format Rust - run: cargo fmt --all - - name: Commit formatting if needed - shell: bash - run: | - if git diff --quiet; then - echo "No formatting changes needed." - exit 0 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -- '*.rs' - git commit -m "style: apply rustfmt" - git push origin HEAD:chatgpt/repair-foundations From 0ba2bc17c451cc5eabfbf1f722a4687bf37cf89d Mon Sep 17 00:00:00 2001 From: t1k <154753100+t1ktakdev@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:38:12 +0600 Subject: [PATCH 50/73] feat(ui): implement real ReproDeck desktop workspace shell --- src/App.tsx | 425 +++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 358 insertions(+), 67 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index bbd2aee..a56787a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,90 +1,381 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; import "./App.css"; +type Session = { + id: string; + created_at: number; + updated_at: number | null; + state: string; + meta: string | null; +}; + +type Action = { + id: string; + kind: string; + state: string; + created_at: number; +}; + +type Contract = { + id: string; + session_id: string; + title: string; + description: string | null; + state: string; + version: number; + created_at: number; +}; + +type OutcomeCheckSummary = { + check_id: string; + stable_id: string; + description: string; + required: boolean; + before: string | null; + after: string | null; + outcome: string; +}; + +type OutcomeSummary = { + contract_id: string; + overall: string; + checks: OutcomeCheckSummary[]; +}; + +type BridgeError = { + code?: string; + message?: string; +}; + +type WorkspaceView = "Timeline" | "Verification"; + +const formatTime = (seconds: number) => + new Intl.DateTimeFormat(undefined, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }).format(new Date(seconds * 1000)); + +const formatRelative = (seconds: number) => { + const delta = Math.max(0, Math.floor(Date.now() / 1000 - seconds)); + if (delta < 60) return `${delta}s ago`; + if (delta < 3600) return `${Math.floor(delta / 60)}m ago`; + if (delta < 86400) return `${Math.floor(delta / 3600)}h ago`; + return `${Math.floor(delta / 86400)}d ago`; +}; + +const humanize = (value: string) => + value + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/[_-]+/g, " ") + .replace(/^./, (char) => char.toUpperCase()); + +function bridgeMessage(error: unknown) { + if (typeof error === "string") return error; + if (error && typeof error === "object") { + const bridge = error as BridgeError; + if (bridge.message) return bridge.message; + } + return "ReproDeck could not complete that operation."; +} + +function statusTone(value: string) { + const normalized = value.toLowerCase(); + if (normalized.includes("pass") || normalized.includes("success") || normalized.includes("verified")) return "success"; + if (normalized.includes("fail") || normalized.includes("error") || normalized.includes("denied")) return "danger"; + if (normalized.includes("interrupt") || normalized.includes("pending") || normalized.includes("running")) return "warning"; + return "neutral"; +} + +function Mark({ tone = "neutral" }: { tone?: string }) { + return