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 diff --git a/crates/reprodeck-core/src/db.rs b/crates/reprodeck-core/src/db.rs index 02071d0..9378cf0 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,32 @@ 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)) + let conn = init_db(tmp.path()).unwrap(); + let foreign_keys: i64 = conn + .query_row("PRAGMA foreign_keys;", [], |row| row.get(0)) .unwrap(); - assert_eq!(fk, 1); - // check journal_mode (string) - let jm: String = conn - .query_row("PRAGMA journal_mode;", [], |r| r.get(0)) + assert_eq!(foreign_keys, 1); + let journal_mode: String = conn + .query_row("PRAGMA journal_mode;", [], |row| row.get(0)) .unwrap(); - assert!(jm.eq_ignore_ascii_case("wal")); + 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 +404,141 @@ 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); } } diff --git a/crates/reprodeck-core/src/evidence.rs b/crates/reprodeck-core/src/evidence.rs index 832ac19..39feb3a 100644 --- a/crates/reprodeck-core/src/evidence.rs +++ b/crates/reprodeck-core/src/evidence.rs @@ -1,99 +1,498 @@ +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; -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()); +#[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("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}")] + Integrity(String), +} - // two-level directory by first two chars - if checksum.len() < 2 { - return Err(std::io::Error::other("checksum too short")); - } +pub type Result = std::result::Result; - // canonicalize storage root - let base = storage_dir.canonicalize()?; - let prefix = &checksum[0..2]; - let dir = storage_dir.join(prefix); +#[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, +} - // 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", - )); +#[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"), } } +} - fs::create_dir_all(&dir)?; - let tmp = dir.join(format!("{}.tmp", checksum)); - let finalp = dir.join(&checksum); +#[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, +} - // write to tmp - match fs::write(&tmp, data) { - Ok(()) => {} - Err(e) => { - let _ = fs::remove_file(&tmp); - return Err(e); - } +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 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 = (metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT) != 0; + Ok(is_symlink || is_reparse) } - // Re-check containment before rename to mitigate TOCTOU where possible + #[cfg(not(windows))] + { + Ok(is_symlink) + } +} + +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", + )); + } + + 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(()) +} + +/// 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()?; + + 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 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 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 _ = fs::remove_file(&tmp); - return Err(e); + let final_path = dir.join(&checksum); + if final_path.exists() { + verify_existing_artifact(&final_path, &checksum, data.len())?; + return Ok((checksum, final_path)); + } + + 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); + } + + let current_dir_canon = dir.canonicalize()?; + if current_dir_canon != dir_canon || !current_dir_canon.starts_with(&base) { + let _ = fs::remove_file(&temp_path); + return Err(std::io::Error::other( + "artifact directory changed or escaped storage root", + )); + } + + match fs::rename(&temp_path, &final_path) { + Ok(()) => {} + Err(_) if final_path.exists() => { + let _ = fs::remove_file(&temp_path); + verify_existing_artifact(&final_path, &checksum, data.len())?; + } + Err(error) => { + let _ = fs::remove_file(&temp_path); + return Err(error); + } } - // Verify final path containment - let final_canon = finalp.canonicalize()?; + let final_canon = final_path.canonicalize()?; if !final_canon.starts_with(&base) { - // attempt to remove the file we just created - 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(&final_path, &checksum, data.len())?; + Ok((checksum, final_path)) +} + +/// 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, + } +} + +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) +} - Ok((checksum, finalp)) +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())) } -/// 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, +/// 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( + "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 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: format!("action-{suffix}"), + 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() + } + + 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() { @@ -101,19 +500,30 @@ 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] 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!(p1.exists()); - assert!(p2.exists()); + 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] + fn existing_corrupt_content_is_rejected() { + let dir = tempdir().unwrap(); + let data = b"expected content"; + let checksum = hex::encode(Sha256::digest(data)); + 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(); + assert!(store_artifact(dir.path(), data).is_err()); } #[test] @@ -121,7 +531,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)); } @@ -133,17 +543,136 @@ 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); - // create symlink at prefix pointing outside + let prefix_path = dir.path().join(&checksum[0..2]); 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()); + 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, "redaction"); + 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, "integrity"); + 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_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(); + 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, + "unfinished-check", + "Check", + None, + Some("exit 0"), + 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(), &unrelated_receipt, "evidence", None) + .unwrap(); + assert!(matches!( + link_artifact(&conn, &artifact.id, Some(&run), ArtifactRole::Before), + Err(EvidenceError::RunNotFinished(_)) + )); + } + + #[test] + fn invalid_store_key_is_rejected_before_read() { + assert!(validate_store_key("../outside").is_err()); + assert!(validate_store_key("/absolute").is_err()); } } diff --git a/crates/reprodeck-core/src/git_shadow.rs b/crates/reprodeck-core/src/git_shadow.rs index 71e7cc9..074faab 100644 --- a/crates/reprodeck-core/src/git_shadow.rs +++ b/crates/reprodeck-core/src/git_shadow.rs @@ -1,91 +1,585 @@ -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 +592,47 @@ 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 +640,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 +650,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 +669,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 +678,125 @@ 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, + 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() - ))); - } - } - } - } - } - - // 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() }); - } + 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)); } - // 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 +804,258 @@ 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 ); } } diff --git a/crates/reprodeck-core/src/lib.rs b/crates/reprodeck-core/src/lib.rs index 92638c6..83b6fd5 100644 --- a/crates/reprodeck-core/src/lib.rs +++ b/crates/reprodeck-core/src/lib.rs @@ -10,6 +10,9 @@ pub mod permissions; pub mod platform; pub mod recovery; pub mod redaction; +pub mod repository; pub mod runner; +pub mod shadow_session; pub mod timeline; pub mod verification; +pub mod verification_exec; diff --git a/crates/reprodeck-core/src/permissions.rs b/crates/reprodeck-core/src/permissions.rs index 5182e84..a2ac134 100644 --- a/crates/reprodeck-core/src/permissions.rs +++ b/crates/reprodeck-core/src/permissions.rs @@ -1,13 +1,276 @@ 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, + ExplicitApproval, + HardDeniedPrivilegeEscalation, + HardDeniedVerificationMutation, + 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 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 { + 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('-')) +} + +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. +/// +/// 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 { + let executable = executable_name(executable); + + if is_privilege_escalation(&executable) { + return PermissionDecision { + permission: Permission::Deny, + reason: PermissionReason::HardDeniedPrivilegeEscalation, + explanation: "Privilege escalation is not allowed from verification.".to_string(), + }; + } + + if executable == "git" { + let subcommand = first_arg(args).unwrap_or("").to_ascii_lowercase(); + if is_mutating_git(&subcommand) { + return PermissionDecision { + permission: Permission::Deny, + reason: PermissionReason::HardDeniedVerificationMutation, + explanation: format!( + "`git {subcommand}` is not permitted from verification because verification must not mutate or publish repository state." + ), + }; + } + } + + 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)] +mod tests { + use super::*; + + fn args(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_string()).collect() + } + + #[test] + fn ask_and_deny_are_not_silently_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 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( + "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_even_after_approval() { + for executable in ["sudo", "doas", "pkexec", "runas.exe"] { + let decision = verification_command_permission_with_approval( + executable, + &args(&["anything"]), + Permission::Allow, + true, + ); + assert_eq!(decision.permission, Permission::Deny); + assert_eq!( + decision.reason, + PermissionReason::HardDeniedPrivilegeEscalation + ); + } + } + + #[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); + 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_are_hard_denied_from_verification() { + for subcommand in ["push", "commit", "reset", "clean", "rebase", "worktree"] { + 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 + ); + } + } + + #[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}"); + } + } + + #[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); + } +} diff --git a/crates/reprodeck-core/src/redaction.rs b/crates/reprodeck-core/src/redaction.rs index 7ffbc45..5acf31a 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,50 @@ 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")); } } diff --git a/crates/reprodeck-core/src/repository.rs b/crates/reprodeck-core/src/repository.rs new file mode 100644 index 0000000..5b19c88 --- /dev/null +++ b/crates/reprodeck-core/src/repository.rs @@ -0,0 +1,263 @@ +use git2::{Repository, Status, StatusOptions}; +use rusqlite::{Connection, OptionalExtension}; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use std::time::{SystemTime, SystemTimeError, UNIX_EPOCH}; +use thiserror::Error; +use uuid::Uuid; + +#[derive(Debug, Error)] +pub enum RepositoryError { + #[error(transparent)] + Git(#[from] git2::Error), + #[error(transparent)] + Db(#[from] rusqlite::Error), + #[error(transparent)] + Io(#[from] std::io::Error), + #[error(transparent)] + Clock(#[from] SystemTimeError), + #[error("repository path is not valid UTF-8")] + NonUtf8Path, + #[error("repository has no commit at HEAD")] + UnbornRepository, + #[error("session not found: {0}")] + SessionNotFound(String), +} + +pub type Result = std::result::Result; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RepositoryInfo { + pub id: Option, + pub path: String, + pub head_commit: String, + pub branch: String, + pub is_dirty: bool, +} + +fn unix_time_secs() -> Result { + Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as i64) +} + +pub fn inspect_repository(path: &Path) -> Result { + let repository = Repository::discover(path)?; + let root = repository + .workdir() + .ok_or_else(|| git2::Error::from_str("bare repositories are not supported"))? + .canonicalize()?; + let root = root.to_str().ok_or(RepositoryError::NonUtf8Path)?.to_owned(); + let head = repository.head().map_err(|error| { + if error.code() == git2::ErrorCode::UnbornBranch { + RepositoryError::UnbornRepository + } else { + RepositoryError::Git(error) + } + })?; + let head_commit = head + .target() + .ok_or(RepositoryError::UnbornRepository)? + .to_string(); + let branch = if head.is_branch() { + head.shorthand().unwrap_or("HEAD").to_owned() + } else { + "HEAD".to_owned() + }; + drop(head); + + let mut options = StatusOptions::new(); + options + .include_untracked(true) + .recurse_untracked_dirs(true) + .include_ignored(false); + let statuses = repository.statuses(Some(&mut options))?; + let is_dirty = statuses.iter().any(|entry| entry.status() != Status::CURRENT); + + Ok(RepositoryInfo { + id: None, + path: root, + head_commit, + branch, + is_dirty, + }) +} + +pub fn attach_repository_to_session( + conn: &mut Connection, + session_id: &str, + path: &Path, +) -> Result { + let mut info = inspect_repository(path)?; + let session_exists = conn + .query_row( + "SELECT 1 FROM sessions WHERE id = ?1", + rusqlite::params![session_id], + |_| Ok(()), + ) + .optional()? + .is_some(); + if !session_exists { + return Err(RepositoryError::SessionNotFound(session_id.to_owned())); + } + + let tx = conn.transaction()?; + let existing_id: Option = tx + .query_row( + "SELECT id FROM repositories WHERE path = ?1 ORDER BY rowid ASC LIMIT 1", + rusqlite::params![info.path], + |row| row.get(0), + ) + .optional()?; + let repository_id = existing_id.unwrap_or_else(|| Uuid::new_v4().to_string()); + tx.execute( + "INSERT INTO repositories(id, path, head_commit) VALUES (?1, ?2, ?3) + ON CONFLICT(id) DO UPDATE SET path = excluded.path, head_commit = excluded.head_commit", + rusqlite::params![repository_id, info.path, info.head_commit], + )?; + tx.execute( + "UPDATE sessions SET repo_id = ?1, updated_at = ?2 WHERE id = ?3", + rusqlite::params![repository_id, unix_time_secs()?, session_id], + )?; + tx.commit()?; + + info.id = Some(repository_id); + Ok(info) +} + +pub fn get_session_repository( + conn: &Connection, + session_id: &str, +) -> Result> { + let stored: Option<(String, String)> = conn + .query_row( + "SELECT r.id, r.path FROM sessions s JOIN repositories r ON r.id = s.repo_id WHERE s.id = ?1", + rusqlite::params![session_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional()?; + let Some((id, path)) = stored else { + return Ok(None); + }; + let mut info = inspect_repository(Path::new(&path))?; + info.id = Some(id); + Ok(Some(info)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{db::init_db, timeline}; + use git2::{IndexAddOption, Signature}; + use tempfile::{tempdir, NamedTempFile}; + + fn init_repo(path: &Path) -> Repository { + let repository = Repository::init(path).unwrap(); + std::fs::write(path.join("tracked.txt"), "base\n").unwrap(); + let mut index = repository.index().unwrap(); + index + .add_all(["tracked.txt"], IndexAddOption::DEFAULT, None) + .unwrap(); + index.write().unwrap(); + let tree_id = index.write_tree().unwrap(); + let tree = repository.find_tree(tree_id).unwrap(); + let signature = Signature::now("ReproDeck Tests", "tests@reprodeck.local").unwrap(); + repository + .commit(Some("HEAD"), &signature, &signature, "initial", &tree, &[]) + .unwrap(); + drop(tree); + repository + } + + #[test] + fn inspect_reports_head_and_dirty_state() { + let directory = tempdir().unwrap(); + let repository = init_repo(directory.path()); + let head = repository.head().unwrap().target().unwrap().to_string(); + drop(repository); + + let clean = inspect_repository(directory.path()).unwrap(); + assert_eq!(clean.head_commit, head); + assert!(!clean.branch.is_empty()); + assert!(!clean.is_dirty); + + std::fs::write(directory.path().join("tracked.txt"), "changed\n").unwrap(); + let dirty = inspect_repository(directory.path()).unwrap(); + assert!(dirty.is_dirty); + } + + #[test] + fn attach_and_reload_repository_for_session() { + let directory = tempdir().unwrap(); + let repository = init_repo(directory.path()); + drop(repository); + let db_file = NamedTempFile::new().unwrap(); + let mut conn = init_db(db_file.path()).unwrap(); + timeline::create_session(&conn, "session", "Active", None).unwrap(); + + let attached = + attach_repository_to_session(&mut conn, "session", directory.path()).unwrap(); + assert!(attached.id.is_some()); + let session = timeline::get_session_record(&conn, "session") + .unwrap() + .unwrap(); + assert_eq!(session.repo_id, attached.id); + + let reloaded = get_session_repository(&conn, "session").unwrap().unwrap(); + assert_eq!(reloaded.id, attached.id); + assert_eq!(reloaded.path, attached.path); + assert_eq!(reloaded.head_commit, attached.head_commit); + } + + #[test] + fn same_repository_is_reused_across_sessions() { + let directory = tempdir().unwrap(); + let repository = init_repo(directory.path()); + drop(repository); + let db_file = NamedTempFile::new().unwrap(); + let mut conn = init_db(db_file.path()).unwrap(); + timeline::create_session(&conn, "one", "Active", None).unwrap(); + timeline::create_session(&conn, "two", "Active", None).unwrap(); + + let first = attach_repository_to_session(&mut conn, "one", directory.path()).unwrap(); + let second = attach_repository_to_session(&mut conn, "two", directory.path()).unwrap(); + assert_eq!(first.id, second.id); + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM repositories", [], |row| row.get(0)) + .unwrap(); + assert_eq!(count, 1); + } + + #[test] + fn attaching_missing_session_does_not_persist_repository() { + let directory = tempdir().unwrap(); + let repository = init_repo(directory.path()); + drop(repository); + let db_file = NamedTempFile::new().unwrap(); + let mut conn = init_db(db_file.path()).unwrap(); + + assert!(matches!( + attach_repository_to_session(&mut conn, "missing", directory.path()), + Err(RepositoryError::SessionNotFound(_)) + )); + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM repositories", [], |row| row.get(0)) + .unwrap(); + assert_eq!(count, 0); + } + + #[test] + fn non_repository_is_rejected() { + let directory = tempdir().unwrap(); + assert!(inspect_repository(directory.path()).is_err()); + } + + #[test] + fn unborn_repository_is_rejected() { + let directory = tempdir().unwrap(); + let repository = Repository::init(directory.path()).unwrap(); + drop(repository); + assert!(matches!( + inspect_repository(directory.path()), + Err(RepositoryError::UnbornRepository) + )); + } +} diff --git a/crates/reprodeck-core/src/shadow_session.rs b/crates/reprodeck-core/src/shadow_session.rs new file mode 100644 index 0000000..030c92a --- /dev/null +++ b/crates/reprodeck-core/src/shadow_session.rs @@ -0,0 +1,426 @@ +use crate::git_shadow::{GitShadowError, Shadow}; +use crate::repository::{self, RepositoryError}; +use crate::timeline::TimelineError; +use git2::{Delta, DiffFindOptions, DiffOptions, IndexAddOption, Repository, Signature}; +use rusqlite::{Connection, OptionalExtension}; +use serde::{Deserialize, Serialize}; +use std::path::Path; +#[cfg(not(test))] +use std::path::PathBuf; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum ShadowSessionError { + #[error(transparent)] + Db(#[from] rusqlite::Error), + #[error(transparent)] + Git(#[from] git2::Error), + #[error(transparent)] + Shadow(#[from] GitShadowError), + #[error(transparent)] + Repository(#[from] RepositoryError), + #[error(transparent)] + Timeline(#[from] TimelineError), + #[error("session not found: {0}")] + SessionNotFound(String), + #[error("session has no attached repository: {0}")] + RepositoryNotAttached(String), + #[error("shadow workspace not found for session: {0}")] + ShadowNotFound(String), + #[error("shadow workspace record is stale or unsafe to resume")] + StaleShadow, + #[error("shadow workspace has no changes to finalize")] + NoChanges, + #[error("shadow workspace was applied, but its database record could not be removed")] + AppliedStateCleanupFailed, + #[error("shadow workspace was discarded, but its database record could not be removed")] + DiscardedStateCleanupFailed, +} + +pub type Result = std::result::Result; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ShadowWorkspaceRecord { + pub session_id: String, + pub repo_id: String, + pub repo_path: String, + pub base_commit: String, + pub branch: String, + pub worktree_path: String, + pub original_branch: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum ShadowChangeKind { + Added, + Modified, + Deleted, + Renamed, + Copied, + TypeChanged, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ShadowChange { + pub kind: ShadowChangeKind, + pub path: String, + pub old_path: Option, +} + +fn path_for_display(path: Option<&Path>) -> String { + path.map(|value| value.to_string_lossy().into_owned()) + .unwrap_or_else(|| "".to_string()) +} + +fn record_from_row( + conn: &Connection, + session_id: &str, +) -> Result> { + let row: Option<(String, String, String, String, String)> = conn + .query_row( + "SELECT sw.repo_id, r.path, sw.base_commit, sw.branch, sw.worktree_path + FROM shadow_workspaces sw + JOIN repositories r ON r.id = sw.repo_id + WHERE sw.id = ?1", + rusqlite::params![session_id], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + )) + }, + ) + .optional()?; + let Some((repo_id, repo_path, base_commit, branch, worktree_path)) = row else { + return Ok(None); + }; + let original_branch = repository::inspect_repository(Path::new(&repo_path))?.branch; + Ok(Some(ShadowWorkspaceRecord { + session_id: session_id.to_owned(), + repo_id, + repo_path, + base_commit, + branch, + worktree_path, + original_branch, + })) +} + +pub fn get_session_shadow( + conn: &Connection, + session_id: &str, +) -> Result> { + record_from_row(conn, session_id) +} + +pub fn create_session_shadow( + conn: &Connection, + session_id: &str, +) -> Result { + if let Some(existing) = get_session_shadow(conn, session_id)? { + return Ok(existing); + } + + let session = crate::timeline::get_session_record(conn, session_id)? + .ok_or_else(|| ShadowSessionError::SessionNotFound(session_id.to_owned()))?; + let repo_id = session + .repo_id + .ok_or_else(|| ShadowSessionError::RepositoryNotAttached(session_id.to_owned()))?; + let attached = repository::get_session_repository(conn, session_id)? + .ok_or_else(|| ShadowSessionError::RepositoryNotAttached(session_id.to_owned()))?; + let shadow = Shadow::create(Path::new(&attached.path), None)?; + + let insert = conn.execute( + "INSERT INTO shadow_workspaces(id, repo_id, base_commit, branch, worktree_path) + VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![ + session_id, + repo_id, + shadow.base_commit, + shadow.branch, + shadow.worktree.to_string_lossy().into_owned() + ], + ); + if let Err(error) = insert { + let _ = shadow.discard(); + return Err(error.into()); + } + + Ok(ShadowWorkspaceRecord { + session_id: session_id.to_owned(), + repo_id, + repo_path: shadow.repo.to_string_lossy().into_owned(), + base_commit: shadow.base_commit.clone(), + branch: shadow.branch.clone(), + worktree_path: shadow.worktree.to_string_lossy().into_owned(), + original_branch: shadow.original_branch.clone(), + }) +} + +fn open_main_repository(record: &ShadowWorkspaceRecord) -> Result { + let worktree_path = Path::new(&record.worktree_path); + if !worktree_path.exists() { + return Err(ShadowSessionError::StaleShadow); + } + let repository = Repository::open(&record.repo_path)?; + if repository + .find_reference(&format!("refs/heads/{}", record.branch)) + .is_err() + { + return Err(ShadowSessionError::StaleShadow); + } + Ok(repository) +} + +pub fn list_session_shadow_changes( + conn: &Connection, + session_id: &str, +) -> Result> { + let record = get_session_shadow(conn, session_id)? + .ok_or_else(|| ShadowSessionError::ShadowNotFound(session_id.to_owned()))?; + let repository = open_main_repository(&record)?; + let base = repository.find_commit(git2::Oid::from_str(&record.base_commit)?)?; + let target_oid = repository.refname_to_id(&format!("refs/heads/{}", record.branch))?; + let target = repository.find_commit(target_oid)?; + let base_tree = base.tree()?; + let target_tree = target.tree()?; + let mut options = DiffOptions::new(); + options.include_typechange(true); + let mut diff = repository.diff_tree_to_tree( + Some(&base_tree), + Some(&target_tree), + Some(&mut options), + )?; + let mut find = DiffFindOptions::new(); + find.renames(true).copies(true); + diff.find_similar(Some(&mut find))?; + + let mut changes = Vec::new(); + for delta in diff.deltas() { + let old_path = delta.old_file().path(); + let new_path = delta.new_file().path(); + let change = match delta.status() { + Delta::Added => ShadowChange { + kind: ShadowChangeKind::Added, + path: path_for_display(new_path), + old_path: None, + }, + Delta::Modified => ShadowChange { + kind: ShadowChangeKind::Modified, + path: path_for_display(new_path), + old_path: None, + }, + Delta::Deleted => ShadowChange { + kind: ShadowChangeKind::Deleted, + path: path_for_display(old_path), + old_path: None, + }, + Delta::Renamed => ShadowChange { + kind: ShadowChangeKind::Renamed, + path: path_for_display(new_path), + old_path: Some(path_for_display(old_path)), + }, + Delta::Copied => ShadowChange { + kind: ShadowChangeKind::Copied, + path: path_for_display(new_path), + old_path: Some(path_for_display(old_path)), + }, + Delta::Typechange => ShadowChange { + kind: ShadowChangeKind::TypeChanged, + path: path_for_display(new_path), + old_path: Some(path_for_display(old_path)), + }, + Delta::Unmodified + | Delta::Ignored + | Delta::Untracked + | Delta::Unreadable + | Delta::Conflicted => continue, + }; + changes.push(change); + } + changes.sort_by(|left, right| left.path.cmp(&right.path)); + Ok(changes) +} + +pub fn finalize_session_shadow(conn: &Connection, session_id: &str) -> Result { + let record = get_session_shadow(conn, session_id)? + .ok_or_else(|| ShadowSessionError::ShadowNotFound(session_id.to_owned()))?; + open_main_repository(&record)?; + let repository = Repository::open(&record.worktree_path)?; + let mut index = repository.index()?; + index.add_all(["*"], IndexAddOption::DEFAULT, None)?; + index.update_all(["*"], None)?; + index.write()?; + let tree_id = index.write_tree()?; + let tree = repository.find_tree(tree_id)?; + let parent = repository.head()?.peel_to_commit()?; + if parent.tree_id() == tree_id { + return Err(ShadowSessionError::NoChanges); + } + let signature = Signature::now("ReproDeck", "local@reprodeck.invalid")?; + let commit = repository.commit( + Some("HEAD"), + &signature, + &signature, + "ReproDeck shadow checkpoint", + &tree, + &[&parent], + )?; + Ok(commit.to_string()) +} + +#[cfg(not(test))] +fn restore_shadow(record: &ShadowWorkspaceRecord) -> Shadow { + Shadow { + repo: PathBuf::from(&record.repo_path), + worktree: PathBuf::from(&record.worktree_path), + branch: record.branch.clone(), + base_commit: record.base_commit.clone(), + original_head: record.base_commit.clone(), + original_branch: record.original_branch.clone(), + } +} + +#[cfg(not(test))] +pub fn apply_session_shadow(conn: &Connection, session_id: &str) -> Result<()> { + let record = get_session_shadow(conn, session_id)? + .ok_or_else(|| ShadowSessionError::ShadowNotFound(session_id.to_owned()))?; + open_main_repository(&record)?; + let shadow = restore_shadow(&record); + shadow.apply()?; + conn.execute( + "DELETE FROM shadow_workspaces WHERE id = ?1", + rusqlite::params![session_id], + ) + .map_err(|_| ShadowSessionError::AppliedStateCleanupFailed)?; + Ok(()) +} + +#[cfg(not(test))] +pub fn discard_session_shadow(conn: &Connection, session_id: &str) -> Result<()> { + let record = get_session_shadow(conn, session_id)? + .ok_or_else(|| ShadowSessionError::ShadowNotFound(session_id.to_owned()))?; + let shadow = restore_shadow(&record); + shadow.discard()?; + conn.execute( + "DELETE FROM shadow_workspaces WHERE id = ?1", + rusqlite::params![session_id], + ) + .map_err(|_| ShadowSessionError::DiscardedStateCleanupFailed)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{db::init_db, timeline}; + use tempfile::{tempdir, NamedTempFile}; + + fn init_repo(path: &Path) { + let repository = Repository::init(path).unwrap(); + std::fs::write(path.join("tracked.txt"), "base\n").unwrap(); + let mut index = repository.index().unwrap(); + index + .add_all(["tracked.txt"], IndexAddOption::DEFAULT, None) + .unwrap(); + index.write().unwrap(); + let tree_id = index.write_tree().unwrap(); + let tree = repository.find_tree(tree_id).unwrap(); + let signature = Signature::now("ReproDeck Tests", "tests@reprodeck.local").unwrap(); + repository + .commit(Some("HEAD"), &signature, &signature, "initial", &tree, &[]) + .unwrap(); + } + + fn setup() -> (tempfile::TempDir, NamedTempFile, Connection) { + let directory = tempdir().unwrap(); + init_repo(directory.path()); + let db_file = NamedTempFile::new().unwrap(); + let mut conn = init_db(db_file.path()).unwrap(); + timeline::create_session(&conn, "session", "Active", None).unwrap(); + repository::attach_repository_to_session(&mut conn, "session", directory.path()).unwrap(); + (directory, db_file, conn) + } + + #[test] + fn create_shadow_keeps_original_worktree_unchanged() { + let (directory, _db_file, conn) = setup(); + let record = create_session_shadow(&conn, "session").unwrap(); + assert!(Path::new(&record.worktree_path).exists()); + assert_eq!( + std::fs::read_to_string(directory.path().join("tracked.txt")).unwrap(), + "base\n" + ); + assert_eq!( + get_session_shadow(&conn, "session").unwrap(), + Some(record) + ); + } + + #[test] + fn create_shadow_is_idempotent_for_active_session() { + let (_directory, _db_file, conn) = setup(); + let first = create_session_shadow(&conn, "session").unwrap(); + let second = create_session_shadow(&conn, "session").unwrap(); + assert_eq!(first, second); + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM shadow_workspaces", [], |row| row.get(0)) + .unwrap(); + assert_eq!(count, 1); + } + + #[test] + fn finalize_and_list_changes_use_shadow_branch_only() { + let (directory, _db_file, conn) = setup(); + let record = create_session_shadow(&conn, "session").unwrap(); + std::fs::write( + Path::new(&record.worktree_path).join("tracked.txt"), + "fixed\n", + ) + .unwrap(); + let commit = finalize_session_shadow(&conn, "session").unwrap(); + assert!(!commit.is_empty()); + let changes = list_session_shadow_changes(&conn, "session").unwrap(); + assert_eq!(changes.len(), 1); + assert_eq!(changes[0].kind, ShadowChangeKind::Modified); + assert_eq!(changes[0].path, "tracked.txt"); + assert_eq!( + std::fs::read_to_string(directory.path().join("tracked.txt")).unwrap(), + "base\n" + ); + } + + #[test] + fn session_without_repository_cannot_create_shadow() { + let db_file = NamedTempFile::new().unwrap(); + let conn = init_db(db_file.path()).unwrap(); + timeline::create_session(&conn, "session", "Active", None).unwrap(); + assert!(matches!( + create_session_shadow(&conn, "session"), + Err(ShadowSessionError::RepositoryNotAttached(_)) + )); + } + + #[test] + fn missing_session_is_rejected() { + let db_file = NamedTempFile::new().unwrap(); + let conn = init_db(db_file.path()).unwrap(); + assert!(matches!( + create_session_shadow(&conn, "missing"), + Err(ShadowSessionError::SessionNotFound(_)) + )); + } + + #[test] + fn finalize_without_changes_is_rejected() { + let (_directory, _db_file, conn) = setup(); + create_session_shadow(&conn, "session").unwrap(); + assert!(matches!( + finalize_session_shadow(&conn, "session"), + Err(ShadowSessionError::NoChanges) + )); + } +} diff --git a/crates/reprodeck-core/src/timeline.rs b/crates/reprodeck-core/src/timeline.rs index 32b57fa..366f81e 100644 --- a/crates/reprodeck-core/src/timeline.rs +++ b/crates/reprodeck-core/src/timeline.rs @@ -1,11 +1,12 @@ use regex::Regex; -use rusqlite::Connection; +use rusqlite::{Connection, OptionalExtension, Transaction}; 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; -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct Action { pub id: String, pub session_id: String, @@ -16,18 +17,149 @@ 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)] pub enum TimelineError { #[error(transparent)] Db(#[from] rusqlite::Error), + #[error(transparent)] + 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(|| { + 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 value = bearer_regex().replace_all(input, "[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 = long_hex_regex() + .replace_all(&value, "[REDACTED_TOKEN]") + .into_owned(); + long_token_regex() + .replace_all(&value, "[REDACTED_TOKEN]") + .into_owned() +} + +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 +168,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], @@ -51,339 +180,497 @@ 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 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; +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 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) } -/// finish_execution inserts receipt and optional artifact metadata atomically. -pub fn finish_execution( - conn: &mut Connection, +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( + tx: &Transaction<'_>, execution_id: &str, status: &str, stdout_preview: Option<&str>, stderr_preview: Option<&str>, ) -> Result { - let tx = conn.transaction()?; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() as i64; - - // update execution - tx.execute( - "UPDATE executions SET status = ?1, finished_at = ?2 WHERE id = ?3", - rusqlite::params![status, now, execution_id], + let now = unix_time_secs()?; + let started_at = tx + .query_row( + "SELECT started_at FROM executions WHERE id = ?1 AND finished_at IS NULL", + rusqlite::params![execution_id], + |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 AND finished_at IS NULL", + rusqlite::params![status, now, duration_ms, execution_id], )?; - - // 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 + 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 (stdout_preview, stdout_truncated) = match stdout_preview { + Some(value) => { + let sanitized = sanitize_preview(value); + 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 (stderr_preview, stderr_truncated) = match stderr_preview { + Some(value) => { + let sanitized = sanitize_preview(value); + 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, "", stdout_preview, stderr_preview, stdout_truncated, stderr_truncated, now], + )?; + Ok(receipt_id) +} +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) } -/// 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, - }; - - // 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"); + } + } - 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"); - // 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()); + 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(); - - // insert multiple actions with same created_at + 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))); + } - // 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(); - 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); - // 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| { - r.get::<_, String>(0) - }) - .unwrap(); - let ids2: Vec = rows2.map(|r| r.unwrap()).collect(); - assert!(!ids2.is_empty()); + #[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) + )); + } + + #[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(); - // 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(); - let stored: String = conn - .query_row( - "SELECT stdout_preview FROM receipts WHERE id = ?1", - rusqlite::params![receipt], - |r| r.get(0), - ) + 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!(!stored.contains("abcdef12345")); - assert!(stored.contains("[REDACTED]") || stored.contains("REDACTED")); + 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(); - // JWT without Bearer - 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), - ) + 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!(!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") - ); + assert!(!preview.contains("eyJhbGci")); + assert!(!preview.contains("AKIA")); } #[test] - fn session_action_foreign_key() { + 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 - 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"); + let mut conn = init_db(tmp.path()).unwrap(); + create_session(&conn, "s-unicode", "Active", None).unwrap(); + 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())); + } - // deleting session should cascade to actions + #[test] + fn session_action_foreign_key_cascades() { + let tmp = NamedTempFile::new().unwrap(); + 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(); - 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); + 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"); - - // prepare session & action + 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(); - - // 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", - 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()); } } diff --git a/crates/reprodeck-core/src/verification.rs b/crates/reprodeck-core/src/verification.rs index 89683a5..877e138 100644 --- a/crates/reprodeck-core/src/verification.rs +++ b/crates/reprodeck-core/src/verification.rs @@ -2,10 +2,39 @@ 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, Serialize, Deserialize)] +#[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 }, + #[error("invalid persisted verification value for {field}: {value}")] + InvalidPersistedState { field: &'static str, value: String }, +} + +type Result = std::result::Result; + +fn unix_time_secs() -> Result { + Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as i64) +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct OutcomeContract { pub id: String, pub session_id: String, @@ -17,7 +46,7 @@ pub struct OutcomeContract { pub updated_at: Option, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct VerificationCheck { pub id: String, pub contract_id: String, @@ -45,6 +74,42 @@ pub enum RunStatus { Interrupted, } +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy)] +pub enum OutcomeState { + VerifiedFix, + ReproductionNotProven, + NotFixed, + 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 { @@ -67,17 +132,129 @@ 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_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" => 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, 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 +263,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,118 +271,407 @@ pub fn create_outcome_contract( }) } -pub fn get_outcome_contract( +pub fn get_outcome_contract(conn: &Connection, id: &str) -> Result> { + 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, - id: &str, -) -> Result, rusqlite::Error> { - 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 { - 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(Some(c)) + 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( + 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 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(()) } -/// 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 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 ASC, rowid ASC", + )?; + Ok(stmt + .query_map(rusqlite::params![contract_id], check_from_row)? + .collect::, _>>()?) +} + +fn start_run( + conn: &mut 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 { + let tx = conn.transaction()?; - // find session_id for contract to populate action - let session_id: String = conn.query_row( + if let Some(check_id) = check_id { + let exists: Option = tx + .query_row( + "SELECT 1 FROM verification_checks WHERE id = ?1 AND contract_id = ?2", + rusqlite::params![check_id, contract_id], + |row| row.get(0), + ) + .optional()?; + if exists.is_none() { + return Err(VerificationError::CheckNotFound(check_id.to_owned())); + } + } + + let run_id = Uuid::new_v4().to_string(); + let now = unix_time_secs()?; + 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), )?; - // 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, - )), - state: "Created".to_string(), + meta: Some(meta), + state: "Running".to_string(), created_at: now, }; - timeline::create_action(conn, &action)?; + timeline::create_action(&tx, &action)?; + timeline::start_execution(&tx, &action.id)?; + + 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) +} + +pub fn start_verification_run( + conn: &mut Connection, + contract_id: &str, + phase: RunPhase, +) -> Result { + start_run(conn, contract_id, None, phase) +} + +pub fn start_verification_check_run( + conn: &mut Connection, + contract_id: &str, + check_id: &str, + phase: RunPhase, +) -> Result { + 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() +} - // start execution - let exec_id = timeline::start_execution(conn, &action.id)?; +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], + |row| row.get(0), + ) + .optional()? + .ok_or_else(|| VerificationError::RunNotFound(run_id.to_owned())) +} - // insert verification_runs +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", + } +} + +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], + |row| row.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())); + } 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], + "UPDATE actions SET state = ?1 WHERE id = ?2", + rusqlite::params![status.to_string(), run_id], )?; + Ok(()) +} - Ok(run_id) +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 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, + )?; + update_finished_run(&tx, run_id, status, &receipt_id, unix_time_secs()?)?; + tx.commit()?; + Ok(receipt_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). 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 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], + |row| row.get(0), + ) + .optional()?; + if matching_receipt.is_none() { + return Err(VerificationError::ReceiptMismatch { + run_id: run_id.to_owned(), + receipt_id: receipt_id.to_owned(), + }); + } + update_finished_run(&tx, run_id, status, receipt_id, unix_time_secs()?)?; + tx.commit()?; 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 interrupt_running_verifications(conn: &mut Connection) -> Result { + let tx = conn.transaction()?; + 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 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) +} - if before_failed.is_none() { - return Ok("BeforePassedOrNotObserved".to_string()); +pub fn recover_running_verifications(conn: &mut Connection) -> Result { + interrupt_running_verifications(conn) +} + +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 rowid DESC LIMIT 1", + rusqlite::params![contract_id, check_id, phase.to_string()], + |row| row.get(0), + ) + .optional()?; + value.map(|value| parse_run_status(&value)).transpose() +} + +fn evaluate_check(before: Option, after: Option) -> OutcomeState { + 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, } +} - 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 get_outcome_summary(conn: &Connection, contract_id: &str) -> Result { + let checks = list_verification_checks(conn, contract_id)?; + 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)?; + let outcome = evaluate_check(before, after); + summaries.push(VerificationCheckSummary { + check, + before, + after, + outcome, + }); + } - if after_passed.is_some() { - Ok("VerifiedFix".to_string()) + 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("NotFixed".to_string()) - } + 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 { + Ok(evaluate_outcome_state(conn, contract_id)?.to_string()) } #[cfg(test)] @@ -214,52 +680,357 @@ mod tests { 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 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"], + ) + .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) - .expect("get") - .expect("found"); + #[test] + fn create_query_and_list_contract() { + let (_tmp, conn, contract, _check) = setup(); + 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 start_and_finish_run_lifecycle() { - let tmp = NamedTempFile::new().unwrap(); - let path = tmp.path(); - let mut conn = init_db(path).expect("init db"); + 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"); + } - 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(); + #[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 running = get_verification_run(&conn, &run_id).unwrap().unwrap(); + assert_eq!(running.status, RunStatus::Running); + assert!(running.receipt_id.is_none()); - let run_id = start_verification_run(&conn, &c.id, RunPhase::Before).expect("start"); - // there should be a verification_runs row - let status: String = conn - .query_row( - "SELECT status FROM verification_runs WHERE id = ?1", - rusqlite::params![&run_id], - |r| r.get(0), + let receipt = finish_verification_run_with_output( + &mut conn, + &run_id, + RunStatus::Failed, + Some("failure"), + None, + ) + .unwrap(); + 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!(status, "Running"); + .unwrap() + .unwrap() + .status, + "Failed" + ); + } - // simulate finishing by calling finish_verification_run - finish_verification_run(&mut conn, &run_id, RunStatus::Passed, "receipt-x") - .expect("finish"); - let status2: String = conn - .query_row( - "SELECT status FROM verification_runs WHERE id = ?1", - rusqlite::params![&run_id], - |r| r.get(0), - ) - .unwrap(); - assert_eq!(status2, "Passed"); + #[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, + ); + 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, + ); + 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 + ); + } + + #[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 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(); + 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 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, + ); + 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); + } + + #[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 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" + ); } } diff --git a/crates/reprodeck-core/src/verification_exec.rs b/crates/reprodeck-core/src/verification_exec.rs new file mode 100644 index 0000000..20dbcf6 --- /dev/null +++ b/crates/reprodeck-core/src/verification_exec.rs @@ -0,0 +1,580 @@ +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("unsupported verification expected condition: {0}")] + UnsupportedExpectedCondition(String), + #[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)] +pub struct VerificationExecutionRequest { + pub contract_id: String, + pub check_id: String, + 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)] +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, + } +} + +/// 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 + .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": 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, + "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 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_with_approval( + &request.spec.executable, + &request.spec.args, + request.configured_permission, + request.explicitly_approved_once, + ); + 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(&request, expected_exit_code); + 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(request.spec, Permission::Allow, cancel_token) { + Ok(result) => { + let status = if result.exit_code == Some(expected_exit_code) { + 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(request.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: request.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: request.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 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, + explicitly_approved_once: false, + } + } + + fn run_count(conn: &Connection) -> i64 { + conn.query_row("SELECT COUNT(*) FROM verification_runs", [], |row| { + row.get(0) + }) + .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(); + let storage = tempdir().unwrap(); + let result = execute_verification_check( + &mut conn, + storage.path(), + request( + &contract, + &check, + RunPhase::Before, + git_spec(&["--version"]), + Permission::Ask, + ), + None, + ); + assert!(matches!( + result, + Err(VerificationExecutionError::DecisionRequired { .. }) + )); + 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(); + let storage = tempdir().unwrap(); + let result = execute_verification_check( + &mut conn, + storage.path(), + request( + &contract, + &check, + 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(), + request( + &contract, + &check, + 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(), + request( + &contract, + &check, + 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(), + request( + &contract, + &check, + 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 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, None); + assert!(matches!( + result, + Err(VerificationExecutionError::PermissionDenied { .. }) + )); + assert_eq!(run_count(&conn), 0); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0731a79..a4632fa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,148 +1,533 @@ -// Tauri command bridge to ReproDeck core APIs -// serde::Serialize imported by other modules when needed -use serde_json::json; -use std::path::PathBuf; +mod shadow_bridge; -use reprodeck_core::db; -use reprodeck_core::timeline; -use reprodeck_core::verification; +use reprodeck_core::{db, evidence, repository, timeline, verification}; +use serde::{Deserialize, Serialize}; +use shadow_bridge::{ + apply_shadow_workspace, create_shadow_workspace, discard_shadow_workspace, + finalize_shadow_workspace, get_shadow_workspace, refresh_shadow_workspace, +}; +use std::fmt::{self, Display}; +use std::path::{Path, PathBuf}; + +#[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) -> Self { + Self::new("database_error", context) + } +} + +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 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 RepositoryDto { + pub id: Option, + pub path: String, + pub head_commit: String, + pub branch: String, + pub is_dirty: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ActionDto { + pub id: String, + pub kind: String, + pub state: String, + pub meta: Option, + pub created_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ExecutionDto { + 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 ReceiptDto { + 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, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ArtifactDto { + pub id: String, + pub receipt_id: String, + pub checksum: String, + pub size: i64, + pub media_type: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct TimelineEntryDto { + pub action: ActionDto, + pub execution: Option, + pub receipt: Option, + pub artifacts: Vec, +} + +#[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: Option, + pub finished_at: Option, + 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, +} + +impl From for SessionDto { + fn from(value: timeline::SessionRecord) -> Self { + Self { + id: value.id, + repo_id: value.repo_id, + created_at: value.created_at, + updated_at: value.updated_at, + state: value.state, + meta: value.meta, + } + } +} + +impl From for RepositoryDto { + fn from(value: repository::RepositoryInfo) -> Self { + Self { + id: value.id, + path: value.path, + head_commit: value.head_commit, + branch: value.branch, + is_dirty: value.is_dirty, + } + } +} + +impl From for ActionDto { + fn from(value: timeline::ActionRecord) -> Self { + Self { + id: value.id, + kind: value.kind, + state: value.state, + meta: value.meta, + created_at: value.created_at, + } + } +} + +impl From for ExecutionDto { + fn from(value: timeline::ExecutionRecord) -> Self { + Self { + id: value.id, + action_id: value.action_id, + status: value.status, + started_at: value.started_at, + finished_at: value.finished_at, + duration_ms: value.duration_ms, + } + } +} + +impl From for ReceiptDto { + fn from(value: timeline::ReceiptRecord) -> Self { + Self { + id: value.id, + execution_id: value.execution_id, + summary: value.summary, + 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, + } + } +} + +impl From for ArtifactDto { + fn from(value: evidence::ArtifactRecord) -> Self { + Self { + id: value.id, + receipt_id: value.receipt_id, + checksum: value.checksum, + size: value.size, + media_type: value.media_type, + } + } +} + +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(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(|_| BridgeError::database("Unable to initialize ReproDeck storage.")) } -#[tauri::command] -pub fn list_sessions() -> Result { +fn repository_error(error: repository::RepositoryError) -> BridgeError { + match error { + repository::RepositoryError::SessionNotFound(_) => { + BridgeError::new("not_found", "Session not found.") + } + repository::RepositoryError::UnbornRepository => BridgeError::new( + "repository_unborn", + "The Git repository needs at least one commit before ReproDeck can attach it.", + ), + repository::RepositoryError::NonUtf8Path => BridgeError::new( + "repository_path_unsupported", + "This repository path cannot be represented safely by the desktop bridge.", + ), + _ => BridgeError::new( + "repository_invalid", + "The selected path is not an accessible Git working repository.", + ), + } +} + +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", - ) - .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)) + timeline::list_sessions(&conn, None, 200) + .map(|values| values.into_iter().map(SessionDto::from).collect()) + .map_err(|_| BridgeError::database("Unable to list sessions.")) } -#[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(|_| 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.")) } -#[tauri::command] -pub fn list_actions(session_id: &str) -> Result { +pub fn inspect_repository_service(path: &str) -> Result { + let path = path.trim(); + if path.is_empty() { + return Err(BridgeError::new( + "invalid_request", + "Repository path must not be empty.", + )); + } + repository::inspect_repository(Path::new(path)) + .map(RepositoryDto::from) + .map_err(repository_error) +} + +pub fn attach_repository_service( + session_id: &str, + path: &str, +) -> Result { + let path = path.trim(); + if path.is_empty() { + return Err(BridgeError::new( + "invalid_request", + "Repository path must not be empty.", + )); + } + let mut conn = open_conn()?; + repository::attach_repository_to_session(&mut conn, session_id, Path::new(path)) + .map(RepositoryDto::from) + .map_err(repository_error) +} + +pub fn get_session_repository_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)) + repository::get_session_repository(&conn, session_id) + .map(|value| value.map(RepositoryDto::from)) + .map_err(repository_error) } -#[tauri::command] -pub fn get_receipt(receipt_id: &str) -> Result { +pub fn list_actions_service(session_id: &str) -> Result, BridgeError> { 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) + 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.")) } -#[tauri::command] -pub fn list_contracts() -> Result { +pub fn list_timeline_entries_service( + session_id: &str, +) -> Result, BridgeError> { + let conn = open_conn()?; + let actions = timeline::list_actions(&conn, session_id, None, 500) + .map_err(|_| BridgeError::database("Unable to load the session timeline."))?; + let mut entries = Vec::with_capacity(actions.len()); + + for action in actions { + let action_id = action.id.clone(); + let execution = timeline::list_executions(&conn, &action_id) + .map_err(|_| BridgeError::database("Unable to load action executions."))? + .into_iter() + .last(); + + let (receipt, artifacts) = if let Some(execution) = execution.as_ref() { + let receipt = timeline::list_receipts(&conn, &execution.id) + .map_err(|_| BridgeError::database("Unable to load execution receipts."))? + .into_iter() + .last(); + let artifacts = if let Some(receipt) = receipt.as_ref() { + evidence::list_artifacts_for_receipt(&conn, &receipt.id) + .map_err(|_| BridgeError::database("Unable to load receipt evidence."))? + .into_iter() + .map(ArtifactDto::from) + .collect() + } else { + Vec::new() + }; + (receipt.map(ReceiptDto::from), artifacts) + } else { + (None, Vec::new()) + }; + + entries.push(TimelineEntryDto { + action: ActionDto::from(action), + execution: execution.map(ExecutionDto::from), + receipt, + artifacts, + }); + } + + Ok(entries) +} + +pub fn get_receipt_service(receipt_id: &str) -> Result { let conn = open_conn()?; - 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)) + 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.")) } -#[tauri::command] -pub fn list_verification_runs(contract_id: &str) -> Result { +pub fn list_contracts_service(session_id: Option<&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)) + 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.")) } -#[tauri::command] -pub fn evaluate_contract(contract_id: &str) -> Result { +pub fn list_verification_runs_service( + contract_id: &str, +) -> Result, BridgeError> { let conn = open_conn()?; - match verification::evaluate_outcome(&conn, contract_id) { - Ok(s) => Ok(json!({"verdict": s})), - Err(_) => Err("evaluation failed".to_string()), - } + 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.")) +} + +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 { + get_outcome_summary_service(contract_id).map(|summary| VerdictDto { + verdict: summary.overall, + }) +} + +#[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 inspect_repository(path: String) -> Result { + inspect_repository_service(&path) +} + +#[tauri::command] +fn attach_repository(session_id: String, path: String) -> Result { + attach_repository_service(&session_id, &path) +} + +#[tauri::command] +fn get_session_repository(session_id: String) -> Result, BridgeError> { + get_session_repository_service(&session_id) +} + +#[tauri::command] +fn list_actions(session_id: String) -> Result, BridgeError> { + list_actions_service(&session_id) +} + +#[tauri::command] +fn list_timeline_entries(session_id: String) -> Result, BridgeError> { + list_timeline_entries_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 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) } #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -152,12 +537,108 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ list_sessions, create_session, + inspect_repository, + attach_repository, + get_session_repository, list_actions, + list_timeline_entries, get_receipt, list_contracts, list_verification_runs, + get_outcome_summary, evaluate_contract, + get_shadow_workspace, + create_shadow_workspace, + refresh_shadow_workspace, + finalize_shadow_workspace, + apply_shadow_workspace, + discard_shadow_workspace, ]) .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"); + } + + #[test] + fn repository_dto_preserves_runtime_status() { + let dto = RepositoryDto::from(repository::RepositoryInfo { + id: Some("repo".to_string()), + path: "C:/work/repo".to_string(), + head_commit: "abcdef".to_string(), + branch: "main".to_string(), + is_dirty: true, + }); + assert_eq!(dto.id.as_deref(), Some("repo")); + assert_eq!(dto.branch, "main"); + assert!(dto.is_dirty); + } + + #[test] + fn action_dto_keeps_sanitized_metadata_surface() { + let dto = ActionDto::from(timeline::ActionRecord { + created_seq: 1, + id: "action".to_string(), + session_id: "session".to_string(), + parent_id: None, + kind: "verification".to_string(), + meta: Some("{\"phase\":\"Before\"}".to_string()), + state: "Completed".to_string(), + created_at: 10, + }); + assert_eq!(dto.meta.as_deref(), Some("{\"phase\":\"Before\"}")); + } + + #[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."); + assert_eq!(error.code, "database_error"); + assert_eq!(error.message, "Unable to load timeline."); + } +} diff --git a/src-tauri/src/shadow_bridge.rs b/src-tauri/src/shadow_bridge.rs new file mode 100644 index 0000000..37011e6 --- /dev/null +++ b/src-tauri/src/shadow_bridge.rs @@ -0,0 +1,123 @@ +use reprodeck_core::shadow_session::{self, ShadowChange, ShadowSessionError, ShadowWorkspaceRecord}; +use serde::{Deserialize, Serialize}; + +use super::BridgeError; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub(crate) struct ShadowWorkspaceDto { + pub session_id: String, + pub repo_id: String, + pub repo_path: String, + pub base_commit: String, + pub branch: String, + pub worktree_path: String, + pub original_branch: String, + pub changes: Vec, +} + +fn map_shadow_error(error: ShadowSessionError) -> BridgeError { + match error { + ShadowSessionError::SessionNotFound(_) => { + BridgeError::new("not_found", "Session not found.") + } + ShadowSessionError::RepositoryNotAttached(_) => BridgeError::new( + "repository_required", + "Attach a Git repository before creating an isolated workspace.", + ), + ShadowSessionError::ShadowNotFound(_) => { + BridgeError::new("shadow_not_found", "This session has no isolated workspace.") + } + ShadowSessionError::StaleShadow => BridgeError::new( + "shadow_stale", + "The isolated workspace can no longer be resumed safely.", + ), + ShadowSessionError::NoChanges => BridgeError::new( + "no_changes", + "The isolated workspace has no changes to checkpoint.", + ), + ShadowSessionError::AppliedStateCleanupFailed => BridgeError::new( + "applied_cleanup_pending", + "Changes were applied, but ReproDeck could not clear the local workspace record.", + ), + ShadowSessionError::DiscardedStateCleanupFailed => BridgeError::new( + "discarded_cleanup_pending", + "The isolated workspace was discarded, but ReproDeck could not clear its local record.", + ), + _ => BridgeError::new( + "shadow_workspace_error", + "ReproDeck could not complete the isolated workspace operation.", + ), + } +} + +fn dto( + conn: &rusqlite::Connection, + record: ShadowWorkspaceRecord, +) -> Result { + let changes = shadow_session::list_session_shadow_changes(conn, &record.session_id) + .map_err(map_shadow_error)?; + Ok(ShadowWorkspaceDto { + session_id: record.session_id, + repo_id: record.repo_id, + repo_path: record.repo_path, + base_commit: record.base_commit, + branch: record.branch, + worktree_path: record.worktree_path, + original_branch: record.original_branch, + changes, + }) +} + +#[tauri::command] +pub(crate) fn get_shadow_workspace( + session_id: String, +) -> Result, BridgeError> { + let conn = super::open_conn()?; + let record = shadow_session::get_session_shadow(&conn, &session_id).map_err(map_shadow_error)?; + record.map(|record| dto(&conn, record)).transpose() +} + +#[tauri::command] +pub(crate) fn create_shadow_workspace( + session_id: String, +) -> Result { + let conn = super::open_conn()?; + let record = + shadow_session::create_session_shadow(&conn, &session_id).map_err(map_shadow_error)?; + dto(&conn, record) +} + +#[tauri::command] +pub(crate) fn refresh_shadow_workspace( + session_id: String, +) -> Result { + let conn = super::open_conn()?; + let record = shadow_session::get_session_shadow(&conn, &session_id) + .map_err(map_shadow_error)? + .ok_or_else(|| BridgeError::new("shadow_not_found", "This session has no isolated workspace."))?; + dto(&conn, record) +} + +#[tauri::command] +pub(crate) fn finalize_shadow_workspace( + session_id: String, +) -> Result { + let conn = super::open_conn()?; + shadow_session::finalize_session_shadow(&conn, &session_id).map_err(map_shadow_error)?; + let record = shadow_session::get_session_shadow(&conn, &session_id) + .map_err(map_shadow_error)? + .ok_or_else(|| BridgeError::new("shadow_not_found", "This session has no isolated workspace."))?; + dto(&conn, record) +} + +#[tauri::command] +pub(crate) fn apply_shadow_workspace(session_id: String) -> Result<(), BridgeError> { + let conn = super::open_conn()?; + shadow_session::apply_session_shadow(&conn, &session_id).map_err(map_shadow_error) +} + +#[tauri::command] +pub(crate) fn discard_shadow_workspace(session_id: String) -> Result<(), BridgeError> { + let conn = super::open_conn()?; + shadow_session::discard_session_shadow(&conn, &session_id).map_err(map_shadow_error) +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 71779ff..6667b70 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,8 +1,8 @@ { "$schema": "https://schema.tauri.app/config/2", - "productName": "reprodeck", + "productName": "ReproDeck", "version": "0.1.0", - "identifier": "com.user.reprodeck", + "identifier": "com.reprodeck.app", "build": { "beforeDevCommand": "npm run dev", "devUrl": "http://localhost:1420", @@ -12,9 +12,13 @@ "app": { "windows": [ { - "title": "reprodeck", - "width": 800, - "height": 600 + "title": "ReproDeck", + "width": 1440, + "height": 900, + "minWidth": 1000, + "minHeight": 700, + "resizable": true, + "center": true } ], "security": { diff --git a/src/App.css b/src/App.css index 2e20489..3c41ca0 100644 --- a/src/App.css +++ b/src/App.css @@ -1,135 +1,320 @@ -.logo.vite:hover { - filter: drop-shadow(0 0 2em #747bff); -} - -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafb); -} :root { - font-family: Inter, Avenir, Helvetica, Arial, sans-serif; - font-size: 16px; - line-height: 24px; - font-weight: 400; - - color: #0f0f0f; - background-color: #f6f6f6; - + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: #e8ece8; + background: #0b0d0e; font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - -webkit-text-size-adjust: 100%; + --bg: #0b0d0e; + --panel: #0e1112; + --panel-2: #101415; + --panel-3: #14191a; + --border: #252b2c; + --border-soft: #202627; + --text: #e8ece8; + --muted: #8a9690; + --dim: #69756f; + --green: #83d8ae; + --green-2: #aef0c7; + --green-bg: #153523; + --red: #df7477; + --red-bg: #321719; + --amber: #d8ad72; + --amber-bg: #2d2215; + --blue: #82acc6; + --blue-bg: #15232d; + --radius: 7px; } -.container { - margin: 0; - padding-top: 10vh; +* { box-sizing: border-box; } +html, body, #root { margin: 0; min-width: 900px; min-height: 100%; height: 100%; background: var(--bg); } +body { overflow: hidden; } +button, input { font: inherit; } +button { color: inherit; } +button:focus-visible, input:focus-visible { outline: 1px solid #5ca67c; outline-offset: 1px; } +button:disabled { cursor: not-allowed; opacity: .48; } + +.shell { + height: 100vh; + display: grid; + grid-template-rows: 48px minmax(0, 1fr) 24px; + color: var(--text); + background: var(--bg); + letter-spacing: -0.01em; +} + +.topbar { display: flex; - flex-direction: column; - justify-content: center; - text-align: center; + align-items: center; + min-width: 0; + border-bottom: 1px solid var(--border); + background: #101314; + padding: 0 14px; } -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: 0.75s; +.brand-block { + width: 216px; + flex: 0 0 216px; + display: flex; + align-items: center; + gap: 9px; + font-size: 11px; + letter-spacing: .08em; } -.logo.tauri:hover { - filter: drop-shadow(0 0 2em #24c8db); +.brand-mark, .empty-logo { + display: grid; + place-items: center; + width: 25px; + height: 25px; + border-radius: 6px; + border: 1px solid #3d7757; + background: #123222; + color: #a8edc4; + font-weight: 800; + font-size: 8px; + letter-spacing: -.03em; + box-shadow: inset 0 0 0 1px rgba(255,255,255,.02); } -.row { +.workspace-crumbs { + min-width: 0; + flex: 1; display: flex; - justify-content: center; + align-items: center; + gap: 7px; + color: #9aa69f; + font-size: 11px; } -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; +.repo-chip, .branch-chip, .isolation-chip { + display: inline-flex; + align-items: center; + min-width: 0; + height: 27px; + border-radius: 6px; + white-space: nowrap; +} +.repo-chip { padding: 0 10px; border: 1px solid #293031; background: #15191a; color: #ced6d1; } +.branch-chip { max-width: 240px; overflow: hidden; text-overflow: ellipsis; padding: 0 7px; color: #8d9a93; } +.isolation-chip { + padding: 0 8px; + border: 1px solid #315f46; + background: #122419; + color: #a2e6b9; + font: 700 9px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + letter-spacing: .08em; } +.isolation-chip i { width: 6px; height: 6px; margin-right: 6px; border-radius: 50%; background: var(--green); box-shadow: 0 0 8px rgba(131,216,174,.25); } -a:hover { - color: #535bf2; +.search-box { + display: flex; + align-items: center; + gap: 6px; + width: 210px; + height: 28px; + padding: 0 8px; + border: 1px solid #293031; + border-radius: 6px; + background: #15191a; + color: #76827b; } +.search-box input { width: 100%; min-width: 0; padding: 0; border: 0; outline: 0; background: transparent; color: #d9e0dc; font-size: 11px; } +.search-box input::placeholder { color: #6d7772; } +.search-box kbd { color: #69746e; font: 9px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } -h1 { - text-align: center; +.workspace-grid { + min-height: 0; + display: grid; + grid-template-columns: 216px minmax(480px, 1fr) 320px; } -input, -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - color: #0f0f0f; - background-color: #ffffff; - transition: border-color 0.25s; - box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2); +.sidebar { + min-height: 0; + display: flex; + flex-direction: column; + border-right: 1px solid var(--border); + background: #0d1011; } -button { - cursor: pointer; +.sidebar-action-wrap { padding: 11px 10px; border-bottom: 1px solid var(--border-soft); } +.new-session-button { + width: 100%; height: 34px; display: flex; align-items: center; gap: 8px; justify-content: flex-start; + border: 1px solid #2b3332; border-radius: 6px; background: #14191a; color: #c7d0ca; padding: 0 10px; font-size: 11px; cursor: pointer; } +.new-session-button:hover { background: #181e1e; border-color: #36413d; } +.new-session-button span { color: var(--green); font-size: 14px; } -button:hover { - border-color: #396cd8; +.nav-block { padding: 11px 8px 8px; } +.section-label { margin: 0 0 7px; padding: 0 8px; color: #66736d; font: 700 8px/1.2 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; letter-spacing: .14em; text-transform: uppercase; } +.nav-block button { + width: 100%; height: 28px; display: flex; align-items: center; gap: 8px; margin-bottom: 2px; padding: 0 8px; border: 0; border-radius: 6px; background: transparent; color: #95a19a; font-size: 11px; text-align: left; cursor: pointer; } -button:active { - border-color: #396cd8; - background-color: #e8e8e8; +.nav-block button:hover:not(:disabled) { background: #151a1a; } +.nav-block button.active { background: #1a211f; color: #e1e9e3; } +.nav-block button.active .nav-icon { color: var(--green); } +.nav-icon { width: 15px; color: #75817b; text-align: center; font-size: 12px; } +.nav-block em { margin-left: auto; color: #7f8b85; font: normal 9px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } +.nav-block small { margin-left: auto; color: #5d6662; font: 8px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } +.sidebar-divider { margin: 0 10px; border-top: 1px solid #222828; } +.recent-block { min-height: 0; padding: 11px 8px; } +.recent-scroll { max-height: 270px; overflow-y: auto; } +.recent-session { + width: 100%; padding: 8px; margin-bottom: 2px; border: 0; border-radius: 6px; background: transparent; text-align: left; cursor: pointer; } +.recent-session:hover { background: #111716; } +.recent-session.selected { background: #141a18; } +.recent-session div { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.recent-session strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #cfd8d2; font-size: 10px; font-weight: 600; } +.recent-session span { display: block; margin-top: 4px; color: #69766f; font: 8px/1.2 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } +.muted-empty { padding: 8px; margin: 0; color: #66716b; font-size: 10px; } +.sidebar-footer { margin-top: auto; padding: 10px 11px; border-top: 1px solid #222828; color: #728078; font-size: 9px; } +.sidebar-footer span { margin-right: 7px; color: #79a78a; } -input, -button { - outline: none; -} +.content-pane { position: relative; min-width: 0; min-height: 0; display: flex; flex-direction: column; background: var(--panel); } +.error-banner { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid #5b2b2d; background: #251214; color: #ef9b9e; font-size: 10px; } +.error-banner > span { display: grid; place-items: center; width: 16px; height: 16px; border: 1px solid #8f4145; border-radius: 50%; font-weight: 800; } +.error-banner button { margin-left: auto; border: 0; background: transparent; color: #c98487; cursor: pointer; font-size: 16px; } -#greet-input { - margin-right: 5px; +.session-header { min-height: 66px; display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 10px 16px; border-bottom: 1px solid var(--border); } +.title-row { display: flex; align-items: center; gap: 8px; } +.session-header h1 { max-width: 620px; margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #eaf0ec; font-size: 15px; font-weight: 650; } +.session-header p { margin: 5px 0 0; color: #738078; font: 9px/1.2 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } +.state-pill, .mini-pill, .verdict-badge, .large-status { display: inline-flex; align-items: center; border-radius: 4px; border: 1px solid #35403c; background: #171d1b; color: #a9b5ae; } +.state-pill { padding: 3px 6px; font: 700 8px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; letter-spacing: .06em; text-transform: uppercase; } +.header-actions { display: flex; gap: 6px; } +.ghost-button, .primary-button, .apply-button { + min-height: 29px; border-radius: 6px; padding: 0 10px; cursor: pointer; font-size: 10px; font-weight: 600; } +.ghost-button { border: 1px solid #2e3634; background: #141819; color: #aab6af; } +.ghost-button:hover:not(:disabled) { background: #1a2020; } +.primary-button, .apply-button { border: 1px solid #478463; background: #1b422c; color: #b8f0ce; } +.primary-button:hover:not(:disabled), .apply-button:hover:not(:disabled) { background: #205337; } +.apply-button:disabled { border-color: #30433a; background: #16251d; color: #668073; } + +.tabbar { height: 39px; flex: 0 0 39px; display: flex; align-items: stretch; gap: 18px; padding: 0 16px; border-bottom: 1px solid var(--border); } +.tabbar button { position: relative; padding: 0; border: 0; background: transparent; color: #7f8b85; font-size: 10px; cursor: pointer; } +.tabbar button.active { color: #dce5df; } +.tabbar button.active::after { content: ""; position: absolute; left: 0; right: 0; bottom: -1px; height: 2px; background: var(--green); } +.tabbar button span { margin-left: 4px; color: #65716b; font: 9px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } + +.scroll-content { min-height: 0; flex: 1; overflow: auto; padding: 14px 16px 28px; } +.content-kicker { display: flex; justify-content: space-between; gap: 20px; margin-bottom: 10px; color: #66736d; font: 8px/1.2 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; letter-spacing: .1em; text-transform: uppercase; } +.content-kicker span:last-child { text-transform: none; letter-spacing: 0; } +.panel-empty { max-width: 620px; margin-top: 30px; padding: 24px; border: 1px dashed #2d3634; border-radius: 8px; background: #101415; } +.panel-empty strong { display: block; color: #cbd5ce; font-size: 12px; } +.panel-empty p { max-width: 520px; margin: 7px 0 0; color: #77837c; font-size: 10px; line-height: 1.6; } + +.timeline-list { position: relative; max-width: 760px; } +.timeline-list::before { content: ""; position: absolute; left: 80px; top: 19px; bottom: 19px; width: 1px; background: #27302e; } +.timeline-row { position: relative; z-index: 1; width: 100%; display: grid; grid-template-columns: 63px 35px minmax(0, 1fr); align-items: start; min-height: 62px; padding: 11px 0; border: 0; border-bottom: 1px solid #202626; background: transparent; text-align: left; cursor: pointer; } +.timeline-row:hover { background: #111617; } +.timeline-row.selected { background: #131918; box-shadow: inset 2px 0 0 #3d7054; } +.timeline-row time { padding-top: 2px; padding-right: 7px; color: #637068; text-align: right; font: 8px/1.3 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } +.timeline-node { display: grid; place-items: center; padding-top: 1px; } +.mark { display: inline-block; width: 10px; height: 10px; border-radius: 50%; border: 2px solid #607068; background: #0e1112; box-shadow: 0 0 0 3px #0e1112; } +.mark-success { border-color: var(--green); } +.mark-danger { border-color: var(--red); } +.mark-warning { border-color: var(--amber); } +.mark-neutral { border-color: #6f7d76; } +.timeline-copy { min-width: 0; padding-right: 10px; } +.timeline-copy > div { display: flex; align-items: center; gap: 7px; } +.timeline-copy strong { color: #d8e1dc; font-size: 10px; font-weight: 650; } +.timeline-copy p { margin: 5px 0 0; overflow: hidden; color: #76817b; font: 8px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; text-overflow: ellipsis; white-space: nowrap; } +.mini-pill { padding: 2px 5px; font: 700 7px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; text-transform: uppercase; } + +.contract-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 9px; max-width: 830px; } +.contract-card { min-height: 138px; display: flex; flex-direction: column; align-items: stretch; padding: 12px; border: 1px solid #28302f; border-radius: 7px; background: #111516; text-align: left; cursor: pointer; } +.contract-card:hover { border-color: #34403c; background: #131818; } +.contract-card.selected { border-color: #3c7555; box-shadow: inset 0 0 0 1px rgba(131,216,174,.05); } +.contract-top { display: flex; align-items: center; justify-content: space-between; } +.contract-icon { display: grid; place-items: center; width: 20px; height: 20px; border: 1px solid #315f46; border-radius: 5px; background: #122419; color: var(--green); font-size: 10px; } +.contract-card > strong { margin-top: 10px; color: #d9e2dc; font-size: 11px; } +.contract-card > p { margin: 5px 0 13px; color: #77837c; font-size: 9px; line-height: 1.5; } +.contract-card footer { display: flex; justify-content: space-between; margin-top: auto; color: #64716a; font: 8px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } + +.verification-result { max-width: 830px; margin-top: 12px; border: 1px solid #2d3633; border-radius: 8px; background: #101415; overflow: hidden; } +.tone-border-success { border-color: #315f46; } +.tone-border-danger { border-color: #653235; } +.tone-border-warning { border-color: #604d32; } +.result-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 14px; border-bottom: 1px solid #252d2a; } +.eyebrow { margin: 0; color: #758279; font: 8px/1.2 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; letter-spacing: .12em; text-transform: uppercase; } +.result-heading h2 { margin: 5px 0 0; color: #e1e9e3; font-size: 15px; } +.verdict-badge { padding: 5px 7px; font: 700 8px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; text-transform: uppercase; } +.checks-table { width: 100%; } +.check-row { display: grid; grid-template-columns: minmax(180px, 1.5fr) .65fr .65fr .9fr; min-height: 48px; align-items: center; border-bottom: 1px solid #202725; } +.check-row:last-child { border-bottom: 0; } +.check-row > span { min-width: 0; padding: 9px 12px; color: #9aa69f; font-size: 9px; } +.check-row > span + span { border-left: 1px solid #202725; } +.check-row strong { display: block; overflow: hidden; color: #ced8d1; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; } +.check-row small { display: block; margin-top: 4px; color: #657169; font: 7px/1.2 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } +.check-head { min-height: 29px; background: #0d1111; } +.check-head > span { color: #626f68; font: 700 7px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; text-transform: uppercase; letter-spacing: .08em; } +.status-text { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } -@media (prefers-color-scheme: dark) { - :root { - color: #f6f6f6; - background-color: #2f2f2f; - } +.inspector { min-width: 0; min-height: 0; display: flex; flex-direction: column; border-left: 1px solid var(--border); background: #0d1011; } +.inspector-heading { min-height: 49px; display: flex; flex-direction: column; justify-content: center; padding: 0 12px; border-bottom: 1px solid var(--border); } +.inspector-heading span { color: #6c7871; font: 7px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; letter-spacing: .1em; text-transform: uppercase; } +.inspector-heading strong { margin-top: 4px; color: #cdd6d0; font-size: 10px; font-weight: 600; } +.inspector-body { padding: 14px 12px; overflow: auto; } +.large-status { padding: 5px 7px; font: 700 8px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; text-transform: uppercase; } +.inspector-body dl { margin: 14px 0 0; } +.inspector-body dl > div { display: grid; grid-template-columns: 85px minmax(0, 1fr); gap: 8px; padding: 8px 0; border-bottom: 1px solid #202625; } +.inspector-body dt { color: #69766f; font-size: 8px; } +.inspector-body dd { min-width: 0; margin: 0; color: #b3c0b8; font-size: 9px; text-align: right; } +.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } +.breakable { overflow-wrap: anywhere; } +.inspector-note { margin-top: 14px; padding: 10px; border: 1px solid #313936; border-radius: 6px; background: #131817; } +.inspector-note strong { display: block; color: #b9c5be; font-size: 9px; } +.inspector-note p { margin: 5px 0 0; color: #75817a; font-size: 8px; line-height: 1.55; } +.success-note { border-color: #2e5d43; background: #102118; } +.success-note strong { color: #9fdfb8; } +.inspector-empty { padding: 22px 12px; color: #66716b; font-size: 9px; line-height: 1.6; } +.full { width: 100%; margin-top: 14px; } - a:hover { - color: #24c8db; - } +.empty-workspace { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 32px; text-align: center; } +.empty-logo { width: 42px; height: 42px; border-radius: 10px; font-size: 11px; box-shadow: 0 15px 40px rgba(0,0,0,.25); } +.empty-workspace .eyebrow { margin-top: 17px; } +.empty-workspace h1 { margin: 8px 0 0; color: #e2ebe5; font-size: 21px; } +.empty-workspace > p:not(.eyebrow) { max-width: 490px; margin: 10px 0 18px; color: #79857e; font-size: 10px; line-height: 1.65; } - input, - button { - color: #ffffff; - background-color: #0f0f0f98; - } - button:active { - background-color: #0f0f0f69; - } +.statusbar { display: flex; align-items: center; gap: 18px; padding: 0 10px; border-top: 1px solid #1f2525; background: #090b0c; color: #5e6a63; font: 7px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } +.statusbar span:first-child { color: #77a98a; } +.statusbar span:last-child { margin-left: auto; } + +.tone-success { color: var(--green-2) !important; border-color: #315f46 !important; background: #122419 !important; } +.tone-danger { color: #ef9699 !important; border-color: #653235 !important; background: #281416 !important; } +.tone-warning { color: #e0ba82 !important; border-color: #604d32 !important; background: #241d13 !important; } +.tone-neutral { color: #a2aea7 !important; } + +.modal-backdrop { position: fixed; inset: 0; z-index: 50; display: grid; place-items: center; background: rgba(0,0,0,.62); backdrop-filter: blur(3px); } +.modal { width: min(440px, calc(100vw - 32px)); border: 1px solid #37413d; border-radius: 9px; background: #141819; box-shadow: 0 24px 80px rgba(0,0,0,.55); overflow: hidden; } +.modal header { display: flex; gap: 11px; padding: 16px; border-bottom: 1px solid #29312f; } +.modal-icon { flex: 0 0 auto; display: grid; place-items: center; width: 26px; height: 26px; border: 1px solid #3f7859; border-radius: 6px; background: #133222; color: var(--green); } +.modal h2 { margin: 0; color: #e4ebe6; font-size: 14px; } +.modal header p { margin: 6px 0 0; color: #7c8881; font-size: 9px; line-height: 1.55; } +.modal > label { display: block; padding: 16px; } +.modal > label span { display: block; margin-bottom: 6px; color: #8d9992; font-size: 9px; } +.modal > label input { width: 100%; height: 34px; padding: 0 10px; border: 1px solid #323b38; border-radius: 6px; background: #0e1212; color: #dfe7e2; font: 10px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } +.modal > label input::placeholder { color: #58625d; } +.modal footer { display: flex; justify-content: flex-end; gap: 7px; padding: 10px 16px; border-top: 1px solid #29312f; } + +::-webkit-scrollbar { width: 8px; height: 8px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { border: 2px solid transparent; border-radius: 8px; background: #29302e; background-clip: padding-box; } +::-webkit-scrollbar-thumb:hover { background: #353e3b; background-clip: padding-box; } + +@media (max-width: 1100px) { + .workspace-grid { grid-template-columns: 200px minmax(480px, 1fr); } + .brand-block { width: 200px; flex-basis: 200px; } + .inspector { display: none; } } -.app-root { - display: flex; - height: 100vh; +@media (max-width: 940px) { + html, body, #root { min-width: 760px; } + .workspace-grid { grid-template-columns: 185px minmax(0, 1fr); } + .brand-block { width: 185px; flex-basis: 185px; } + .search-box { width: 170px; } + .checks-table { overflow-x: auto; } + .check-row { min-width: 650px; } } -.sidebar { - width: 280px; - border-right: 1px solid #e6e6e6; - padding: 12px; - box-sizing: border-box; -} -.sidebar-header { display:flex; justify-content:space-between; align-items:center; } -.session-list { list-style:none; padding:0; margin-top:12px; } -.session-list li { padding:8px; border-radius:6px; cursor:pointer; } -.session-list li.selected { background:#eef; } -.main { flex:1; padding:16px; overflow:auto; } -.pane { margin-bottom:16px; padding:12px; border:1px solid #eee; border-radius:6px; } -.empty { color:#888; padding:8px; } -.verdict { margin-top:8px; font-weight:600; } diff --git a/src/App.tsx b/src/App.tsx index ee2ec8d..4e2f4e2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,122 +1,590 @@ -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; - state: string; + repo_id: string | null; created_at: number; - updated_at?: number | null; - meta?: string | null; + updated_at: number | null; + state: string; + meta: string | null; +}; + +type RepositoryInfo = { + id: string | null; + path: string; + head_commit: string; + branch: string; + is_dirty: boolean; }; type Action = { id: string; kind: string; state: string; + meta: string | null; created_at: number; }; +type Execution = { + id: string; + action_id: string; + status: string; + started_at: number; + finished_at: number | null; + duration_ms: number | null; +}; + +type Receipt = { + id: string; + execution_id: string; + summary: string | null; + stdout_preview: string | null; + stderr_preview: string | null; + stdout_truncated: boolean; + stderr_truncated: boolean; + created_at: number; +}; + +type Artifact = { + id: string; + receipt_id: string; + checksum: string; + size: number; + media_type: string | null; +}; + +type TimelineEntry = { + action: Action; + execution: Execution | null; + receipt: Receipt | null; + artifacts: Artifact[]; +}; + type Contract = { id: string; session_id: string; title: string; - description?: string | null; + description: string | null; state: string; version: number; created_at: number; }; -type EvaluationResult = { - verdict?: string | null; +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 ActionMeta = { + phase?: string; + expected_exit_code?: number; + command?: { + executable?: string; + args?: string[]; + cwd?: string | null; + }; +}; + +type WorkspaceView = "Timeline" | "Verification"; +type InspectorTab = "Details" | "Output" | "Evidence"; + +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 formatDuration = (durationMs: number | null) => { + if (durationMs === null) return "—"; + if (durationMs < 1000) return `${durationMs} ms`; + return `${(durationMs / 1000).toFixed(durationMs < 10_000 ? 2 : 1)} s`; +}; + +const formatBytes = (bytes: number) => { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +}; + +const humanize = (value: string) => + value + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/[_-]+/g, " ") + .replace(/^./, (char) => char.toUpperCase()); + +const repositoryName = (path: string) => { + const normalized = path.replace(/\\/g, "/").replace(/\/+$/, ""); + return normalized.split("/").pop() || normalized; +}; + +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 parseActionMeta(meta: string | null): ActionMeta | null { + if (!meta) return null; + try { + const parsed: unknown = JSON.parse(meta); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + return parsed as ActionMeta; + } catch { + return null; + } +} + +function commandLabel(meta: ActionMeta | null) { + const executable = meta?.command?.executable?.trim(); + if (!executable) return null; + const args = Array.isArray(meta?.command?.args) ? meta.command.args.filter((arg) => typeof arg === "string") : []; + return [executable, ...args].join(" "); +} + +function entryStatus(entry: TimelineEntry) { + return entry.execution?.status ?? entry.action.state; +} + +function entryTitle(entry: TimelineEntry) { + const meta = parseActionMeta(entry.action.meta); + if (meta?.phase) return `${humanize(meta.phase)} · ${humanize(entry.action.kind)}`; + return humanize(entry.action.kind); +} + +function Mark({ tone = "neutral" }: { tone?: string }) { + return