#![allow(dead_code)] #![allow(clippy::similar_names)]
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use serde::Serialize;
use sha2::{Digest, Sha256};
use crate::error::BeadsError;
use crate::util::hex_encode;
const SHA256_EMPTY_PREFIXED: &str =
"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
#[cfg(unix)]
fn metadata_mode(meta: &fs::Metadata) -> u32 {
use std::os::unix::fs::PermissionsExt;
meta.permissions().mode()
}
#[cfg(not(unix))]
fn metadata_mode(meta: &fs::Metadata) -> u32 {
if meta.permissions().readonly() {
0o444
} else {
0o666
}
}
#[cfg(unix)]
fn apply_mode(path: &Path, mode: u32) -> std::io::Result<()> {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(mode))
}
#[cfg(not(unix))]
fn apply_mode(path: &Path, mode: u32) -> std::io::Result<()> {
let mut perms = fs::metadata(path)?.permissions();
perms.set_readonly(mode & 0o200 == 0);
fs::set_permissions(path, perms)
}
#[cfg(unix)]
fn copy_source_permissions(src: &Path, dst: &Path) -> std::io::Result<()> {
let meta = fs::metadata(src)?;
apply_mode(dst, metadata_mode(&meta))
}
#[cfg(not(unix))]
fn copy_source_permissions(src: &Path, dst: &Path) -> std::io::Result<()> {
let meta = fs::metadata(src)?;
fs::set_permissions(dst, meta.permissions())
}
#[cfg(unix)]
fn create_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
std::os::unix::fs::symlink(target, link)
}
#[cfg(windows)]
fn create_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
let resolved_target = link
.parent()
.map_or_else(|| target.to_path_buf(), |parent| parent.join(target));
if resolved_target.is_dir() {
std::os::windows::fs::symlink_dir(target, link)
} else {
std::os::windows::fs::symlink_file(target, link)
}
}
#[cfg(all(not(unix), not(windows)))]
fn create_symlink(_target: &Path, _link: &Path) -> std::io::Result<()> {
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"doctor: symlink creation is not supported on this platform",
))
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Op {
WriteFile {
#[serde(skip)]
content: Vec<u8>,
mode: Option<u32>,
},
AppendFile {
#[serde(skip)]
content: Vec<u8>,
},
Rename { to: PathBuf },
Chmod { mode: u32 },
DbExec {
sql: String,
#[serde(skip)]
args: Vec<DbArg>,
#[serde(default)]
affected_tables: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
affected_predicate: Option<String>,
},
DbMigrate { from: u32, to: u32 },
SymlinkAtomic { target: PathBuf },
}
#[derive(Debug, Clone, Serialize)]
#[serde(untagged)]
pub enum DbArg {
Null,
I64(i64),
F64(f64),
Text(String),
Blob(Vec<u8>),
}
impl DbArg {
fn to_sqlite_value(&self) -> fsqlite_types::value::SqliteValue {
use fsqlite_types::value::SqliteValue;
match self {
Self::Null => SqliteValue::Null,
Self::I64(n) => SqliteValue::Integer(*n),
Self::F64(f) => SqliteValue::Float(*f),
Self::Text(s) => SqliteValue::Text(s.as_str().into()),
Self::Blob(b) => SqliteValue::Blob(std::sync::Arc::from(b.as_slice())),
}
}
}
impl Op {
#[must_use]
pub const fn name(&self) -> &'static str {
match self {
Self::WriteFile { .. } => "write_file",
Self::AppendFile { .. } => "append_file",
Self::Rename { .. } => "rename",
Self::Chmod { .. } => "chmod",
Self::DbExec { .. } => "db_exec",
Self::DbMigrate { .. } => "db_migrate",
Self::SymlinkAtomic { .. } => "symlink_atomic",
}
}
}
#[derive(Debug, Clone)]
pub struct Capabilities {
pub write_scopes: Vec<PathBuf>,
}
impl Capabilities {
#[must_use]
pub fn for_repo(repo_root: &Path) -> Self {
Self {
write_scopes: vec![repo_root.join(".beads"), repo_root.join(".doctor")],
}
}
}
pub struct MutateContext {
pub run_id: String,
pub run_dir: PathBuf,
pub capabilities: Capabilities,
pub actions_file: Mutex<std::fs::File>,
pub fixer_id: String,
pub repo_root: PathBuf,
pub dry_run: bool,
pub start_ns: u128,
}
#[derive(Debug, Clone)]
pub struct ActionResult {
pub ok: bool,
pub before_hash: String,
pub after_hash: String,
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ActionRecord {
pub path: String,
pub op: &'static str,
pub before_hash: String,
pub after_hash: String,
pub started_at_ns: u128,
pub finished_at_ns: u128,
pub run_id: String,
pub fixer_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub backup_path: Option<String>,
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub rename_to: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rolled_back: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Serialize)]
struct DbActionRecord<'a> {
path: String,
op: &'static str,
before_hash: &'a str,
after_hash: &'a str,
started_at_ns: u128,
finished_at_ns: u128,
run_id: &'a str,
fixer_id: &'a str,
ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
affected_tables: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
affected_predicate: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
db_snapshots: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
db_snapshot_sha256: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
migrate_from: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
migrate_to: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
warning: Option<&'static str>,
}
struct DbMutationOutcome {
after_hash: String,
affected_tables: Option<String>,
affected_predicate: Option<String>,
db_snapshots: Vec<String>,
db_snapshot_sha256: Vec<String>,
}
fn sha256_hex_prefixed(bytes: &[u8]) -> String {
let h = Sha256::digest(bytes);
format!("sha256:{}", hex_encode(&h))
}
fn read_or_empty(path: &Path) -> std::io::Result<Vec<u8>> {
match fs::read(path) {
Ok(b) => Ok(b),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
Err(e) => Err(e),
}
}
fn canonicalize_existing_or_parent(path: &Path) -> std::io::Result<PathBuf> {
if path.exists() {
return path.canonicalize();
}
let mut tail: Vec<&std::ffi::OsStr> = Vec::new();
let mut cursor = path;
loop {
if let Some(name) = cursor.file_name() {
tail.push(name);
}
match cursor.parent() {
Some(parent) if !parent.as_os_str().is_empty() => {
if parent.exists() {
let mut canonical = parent.canonicalize()?;
for segment in tail.iter().rev() {
canonical.push(segment);
}
return Ok(canonical);
}
cursor = parent;
}
_ => {
let cwd = Path::new(".").canonicalize()?;
let mut canonical = cwd;
for segment in tail.iter().rev() {
canonical.push(segment);
}
return Ok(canonical);
}
}
}
}
fn ensure_in_scope(caps: &Capabilities, path: &Path) -> Result<(), BeadsError> {
let canonical = canonicalize_existing_or_parent(path).map_err(BeadsError::Io)?;
for scope in &caps.write_scopes {
let canonical_scope =
canonicalize_existing_or_parent(scope).unwrap_or_else(|_| scope.clone());
if canonical.starts_with(&canonical_scope) {
return Ok(());
}
}
Err(BeadsError::internal(format!(
"doctor: path {} is outside write_scopes (refused for safety)",
path.display()
)))
}
fn copy_verbatim_with_perms(src: &Path, dst: &Path) -> std::io::Result<()> {
if let Some(parent) = dst.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(src, dst)?;
copy_source_permissions(src, dst)?;
Ok(())
}
fn write_verbatim_backup(src: &Path, dst: &Path, bytes: &[u8]) -> std::io::Result<()> {
if let Some(parent) = dst.parent() {
fs::create_dir_all(parent)?;
}
fs::write(dst, bytes)?;
copy_source_permissions(src, dst)?;
Ok(())
}
fn verbatim_backup_path(run_dir: &Path, rel: &Path) -> (PathBuf, Option<String>) {
let backups = run_dir.join("backups");
let primary = backups.join(rel);
if !primary.exists() {
return (primary, None);
}
let stamp = now_ns();
for collision in 0_u32.. {
let relative = PathBuf::from(".versions")
.join(format!("{stamp}-{collision}"))
.join(rel);
let candidate = backups.join(&relative);
if !candidate.exists() {
return (candidate, Some(relative.to_string_lossy().into_owned()));
}
}
unreachable!("u32 backup-path collision space exhausted")
}
fn cmp_strict(a: &Path, b: &Path) -> std::io::Result<()> {
let ba = fs::read(a)?;
let bb = fs::read(b)?;
if ba != bb {
return Err(std::io::Error::other(
"doctor: backup verify failed (cmp-strict)",
));
}
Ok(())
}
fn now_ns() -> u128 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_nanos())
}
fn fsync_dir(dir: &Path) -> std::io::Result<()> {
crate::util::sync_directory_best_effort(dir)
}
pub fn mutate(ctx: &MutateContext, path: &Path, op: Op) -> Result<ActionResult, BeadsError> {
let before_bytes_or_missing = match fs::read(path) {
Ok(bytes) => Some(bytes),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => return Err(BeadsError::Io(e)),
};
let before_hash = match &before_bytes_or_missing {
Some(bytes) => sha256_hex_prefixed(bytes),
None => SHA256_EMPTY_PREFIXED.to_string(),
};
ensure_in_scope(&ctx.capabilities, path)?;
if let Op::Rename { to } = &op {
ensure_in_scope(&ctx.capabilities, to)?;
}
if matches!(op, Op::DbExec { .. } | Op::DbMigrate { .. }) {
return mutate_db(ctx, path, &op, &before_hash);
}
let op_name = op.name();
let rename_to = match &op {
Op::Rename { to } => Some(to.to_string_lossy().into_owned()),
_ => None,
};
let rel = path.strip_prefix(&ctx.repo_root).unwrap_or(path);
let (backup, backup_path) = verbatim_backup_path(&ctx.run_dir, rel);
let existing_mode = if before_bytes_or_missing.is_some() {
match fs::metadata(path) {
Ok(meta) => Some(metadata_mode(&meta)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => return Err(BeadsError::Io(e)),
}
} else {
None
};
if !ctx.dry_run
&& let Some(bytes) = before_bytes_or_missing.as_ref()
{
write_verbatim_backup(path, &backup, bytes).map_err(BeadsError::Io)?;
cmp_strict(path, &backup).map_err(BeadsError::Io)?;
}
let started_at_ns = now_ns().saturating_sub(ctx.start_ns);
if ctx.dry_run {
eprintln!("[dry-run] would mutate {}: {}", path.display(), op_name);
return Ok(ActionResult {
ok: true,
before_hash: before_hash.clone(),
after_hash: before_hash,
error: None,
});
}
execute_atomic(path, op, before_bytes_or_missing.as_deref(), existing_mode)
.map_err(BeadsError::Io)?;
let after_bytes = read_or_empty(path).map_err(BeadsError::Io)?;
let after_exists = path.exists();
let after_hash = if after_exists {
sha256_hex_prefixed(&after_bytes)
} else {
SHA256_EMPTY_PREFIXED.to_string()
};
let finished_at_ns = now_ns().saturating_sub(ctx.start_ns);
let record = ActionRecord {
path: rel.to_string_lossy().into_owned(),
op: op_name,
before_hash: before_hash.clone(),
after_hash: after_hash.clone(),
started_at_ns,
finished_at_ns,
run_id: ctx.run_id.clone(),
fixer_id: ctx.fixer_id.clone(),
backup_path,
ok: true,
rename_to,
rolled_back: None,
error: None,
};
let line = serde_json::to_string(&record).map_err(BeadsError::Json)? + "\n";
{
let mut f = ctx.actions_file.lock().map_err(|e| {
BeadsError::internal(format!("doctor: actions_file mutex poisoned: {e}"))
})?;
f.write_all(line.as_bytes()).map_err(BeadsError::Io)?;
f.sync_data().map_err(BeadsError::Io)?;
}
Ok(ActionResult {
ok: true,
before_hash,
after_hash,
error: None,
})
}
#[allow(clippy::too_many_lines)]
fn mutate_db(
ctx: &MutateContext,
path: &Path,
op: &Op,
before_hash: &str,
) -> Result<ActionResult, BeadsError> {
let backups_db = ctx.run_dir.join("backups").join("db");
let started_at_ns = now_ns().saturating_sub(ctx.start_ns);
if ctx.dry_run {
eprintln!("[dry-run] would mutate {}: {}", path.display(), op.name());
return Ok(ActionResult {
ok: true,
before_hash: before_hash.to_string(),
after_hash: before_hash.to_string(),
error: None,
});
}
let rel = path.strip_prefix(&ctx.repo_root).unwrap_or(path);
let op_name = op.name();
let exec_result: Result<DbMutationOutcome, BeadsError> = match op {
Op::DbExec {
sql,
args,
affected_tables,
affected_predicate,
} => {
fs::create_dir_all(&backups_db).map_err(BeadsError::Io)?;
let predicate_for_snapshot = affected_predicate.clone();
let tables_for_snapshot = affected_tables.clone();
let sql_owned = sql.clone();
let args_owned: Vec<fsqlite_types::value::SqliteValue> =
args.iter().map(DbArg::to_sqlite_value).collect();
run_db_exec(
path,
&backups_db,
&sql_owned,
&args_owned,
&tables_for_snapshot,
predicate_for_snapshot.as_deref(),
)
.map(|artifacts| {
let table_summary = if tables_for_snapshot.is_empty() {
None
} else {
Some(tables_for_snapshot.join(","))
};
let mut snapshot_strs = Vec::with_capacity(artifacts.len());
let mut snapshot_hashes = Vec::with_capacity(artifacts.len());
for artifact in artifacts {
snapshot_strs.push(
artifact
.path
.strip_prefix(&ctx.repo_root)
.unwrap_or(&artifact.path)
.to_string_lossy()
.into_owned(),
);
snapshot_hashes.push(artifact.sha256_prefixed);
}
DbMutationOutcome {
after_hash: sha256_file_hex_prefixed(path)
.unwrap_or_else(|_| SHA256_EMPTY_PREFIXED.to_string()),
affected_tables: table_summary,
affected_predicate: predicate_for_snapshot,
db_snapshots: snapshot_strs,
db_snapshot_sha256: snapshot_hashes,
}
})
}
Op::DbMigrate { from, to } => {
fs::create_dir_all(&backups_db).map_err(BeadsError::Io)?;
run_db_migrate(path, &backups_db, *from, *to).map(|_warning| DbMutationOutcome {
after_hash: sha256_file_hex_prefixed(path)
.unwrap_or_else(|_| SHA256_EMPTY_PREFIXED.to_string()),
affected_tables: None,
affected_predicate: None,
db_snapshots: Vec::new(),
db_snapshot_sha256: Vec::new(),
})
}
_ => unreachable!("mutate_db only handles DB ops"),
};
let DbMutationOutcome {
after_hash,
affected_tables: db_affected_tables,
affected_predicate: db_predicate,
db_snapshots,
db_snapshot_sha256,
} = exec_result?;
let finished_at_ns = now_ns().saturating_sub(ctx.start_ns);
let (migrate_from, migrate_to, warning) = match op {
Op::DbMigrate { from, to } => (Some(*from), Some(*to), None),
_ => (None, None, None),
};
let record = DbActionRecord {
path: rel.to_string_lossy().into_owned(),
op: op_name,
before_hash,
after_hash: &after_hash,
started_at_ns,
finished_at_ns,
run_id: &ctx.run_id,
fixer_id: &ctx.fixer_id,
ok: true,
affected_tables: db_affected_tables,
affected_predicate: db_predicate,
db_snapshots,
db_snapshot_sha256,
migrate_from,
migrate_to,
warning,
};
let line = serde_json::to_string(&record).map_err(BeadsError::Json)? + "\n";
{
let mut f = ctx.actions_file.lock().map_err(|e| {
BeadsError::internal(format!("doctor: actions_file mutex poisoned: {e}"))
})?;
f.write_all(line.as_bytes()).map_err(BeadsError::Io)?;
f.sync_data().map_err(BeadsError::Io)?;
}
Ok(ActionResult {
ok: true,
before_hash: before_hash.to_string(),
after_hash,
error: None,
})
}
fn sha256_file_hex_prefixed(path: &Path) -> std::io::Result<String> {
let bytes = read_or_empty(path)?;
if bytes.is_empty() && !path.exists() {
return Ok(SHA256_EMPTY_PREFIXED.to_string());
}
Ok(sha256_hex_prefixed(&bytes))
}
fn run_db_exec(
db_path: &Path,
backups_db: &Path,
sql: &str,
args: &[fsqlite_types::value::SqliteValue],
affected_tables: &[String],
affected_predicate: Option<&str>,
) -> Result<Vec<DbSnapshotArtifact>, BeadsError> {
use crate::franken_sync::Connection;
for table in affected_tables {
validate_identifier(table)?;
}
let mut snapshot_artifacts: Vec<DbSnapshotArtifact> = Vec::with_capacity(affected_tables.len());
let conn = Connection::open(db_path.to_string_lossy().into_owned())?;
if let Err(e) = conn.execute("BEGIN IMMEDIATE") {
let _ = conn.close();
return Err(e.into());
}
for table in affected_tables {
let artifact = match snapshot_db_table(&conn, backups_db, table, affected_predicate) {
Ok(a) => a,
Err(e) => {
let _ = conn.execute("ROLLBACK");
let _ = conn.close();
scrub_orphan_snapshots(
&snapshot_artifacts
.iter()
.map(|a| a.path.clone())
.collect::<Vec<_>>(),
);
return Err(e);
}
};
snapshot_artifacts.push(artifact);
}
let exec_outcome = if args.is_empty() {
conn.execute(sql).map(|_| ())
} else {
conn.execute_with_params(sql, args).map(|_| ())
};
match exec_outcome {
Ok(()) => {
if let Err(e) = conn.execute("COMMIT") {
let _ = conn.execute("ROLLBACK");
let _ = conn.close();
scrub_orphan_snapshots(
&snapshot_artifacts
.iter()
.map(|a| a.path.clone())
.collect::<Vec<_>>(),
);
return Err(e.into());
}
}
Err(e) => {
let _ = conn.execute("ROLLBACK");
let _ = conn.close();
scrub_orphan_snapshots(
&snapshot_artifacts
.iter()
.map(|a| a.path.clone())
.collect::<Vec<_>>(),
);
return Err(e.into());
}
}
let _ = conn.close();
Ok(snapshot_artifacts)
}
#[derive(Debug, Clone)]
pub(crate) struct DbSnapshotArtifact {
pub path: PathBuf,
pub sha256_prefixed: String,
}
fn snapshot_db_table(
conn: &crate::franken_sync::Connection,
backups_db: &Path,
table: &str,
affected_predicate: Option<&str>,
) -> Result<DbSnapshotArtifact, BeadsError> {
let predicate = affected_predicate.unwrap_or("").trim();
let select_sql = if predicate.is_empty() {
format!("SELECT * FROM {table}")
} else {
format!("SELECT * FROM {table} WHERE {predicate}")
};
let column_names = collect_column_names(conn, table)?;
let stmt = conn.prepare(&select_sql)?;
let rows = stmt.query()?;
let mut json_rows: Vec<serde_json::Value> = Vec::with_capacity(rows.len());
for row in &rows {
let mut obj = serde_json::Map::new();
for (i, name) in column_names.iter().enumerate() {
let val = row
.get(i)
.cloned()
.unwrap_or(fsqlite_types::value::SqliteValue::Null);
obj.insert(name.clone(), sqlite_value_to_json(&val));
}
json_rows.push(serde_json::Value::Object(obj));
}
let mut hasher = Sha256::new();
hasher.update(predicate.as_bytes());
let predicate_hash = &hex_encode(&hasher.finalize())[..8];
let snapshot_envelope = serde_json::json!({
"schema_version": "br.doctor.db_snapshot.v1",
"table": table,
"predicate": affected_predicate,
"columns": column_names,
"rows": json_rows,
});
let body = serde_json::to_vec_pretty(&snapshot_envelope).map_err(BeadsError::Json)?;
let body_sha256 = sha256_hex_prefixed(&body);
let path = write_unique_db_snapshot(backups_db, table, predicate_hash, &body)
.map_err(BeadsError::Io)?;
Ok(DbSnapshotArtifact {
path,
sha256_prefixed: body_sha256,
})
}
fn scrub_orphan_snapshots(snapshot_paths: &[PathBuf]) {
for path in snapshot_paths {
let _ = fs::remove_file(path);
}
}
fn write_unique_db_snapshot(
backups_db: &Path,
table: &str,
predicate_hash: &str,
body: &[u8],
) -> std::io::Result<PathBuf> {
write_unique_db_snapshot_with_stamp(backups_db, table, predicate_hash, now_ns(), body)
}
fn write_unique_db_snapshot_with_stamp(
backups_db: &Path,
table: &str,
predicate_hash: &str,
stamp: u128,
body: &[u8],
) -> std::io::Result<PathBuf> {
for collision in 0..=999_u16 {
let suffix = if collision == 0 {
String::new()
} else {
format!("__{collision:03}")
};
let path = backups_db.join(format!(
"{table}__{predicate_hash}__{stamp:020}{suffix}.json"
));
match OpenOptions::new().write(true).create_new(true).open(&path) {
Ok(mut file) => {
file.write_all(body)?;
file.sync_data()?;
return Ok(path);
}
Err(err) => {
if err.kind() != std::io::ErrorKind::AlreadyExists {
return Err(err);
}
}
}
}
Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
format!(
"doctor: exhausted DB snapshot collision suffixes for {table}__{predicate_hash}__{stamp:020}"
),
))
}
fn validate_identifier(ident: &str) -> Result<(), BeadsError> {
if ident.is_empty() || !ident.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return Err(BeadsError::internal(format!(
"doctor: invalid SQL identifier '{ident}' (must be ASCII alphanumeric + underscore)"
)));
}
Ok(())
}
fn collect_column_names(
conn: &crate::franken_sync::Connection,
table: &str,
) -> Result<Vec<String>, BeadsError> {
use fsqlite_types::value::SqliteValue;
validate_identifier(table)?;
let rows = conn.query(&format!("PRAGMA table_info({table})"))?;
if rows.is_empty() {
return Err(BeadsError::internal(format!(
"doctor: table '{table}' has no columns (does it exist?)"
)));
}
let mut names = Vec::with_capacity(rows.len());
for row in &rows {
if let Some(SqliteValue::Text(name)) = row.get(1) {
names.push(name.to_string());
} else {
return Err(BeadsError::internal(format!(
"doctor: PRAGMA table_info({table}) returned non-text column name"
)));
}
}
Ok(names)
}
fn sqlite_value_to_json(val: &fsqlite_types::value::SqliteValue) -> serde_json::Value {
use fsqlite_types::value::SqliteValue;
match val {
SqliteValue::Null => serde_json::Value::Null,
SqliteValue::Integer(n) => serde_json::Value::from(*n),
SqliteValue::Float(f) => serde_json::Number::from_f64(*f)
.map_or(serde_json::Value::Null, serde_json::Value::Number),
SqliteValue::Text(s) => serde_json::Value::String(s.to_string()),
SqliteValue::Blob(b) => {
serde_json::json!({ "$blob_hex": hex_encode(b.as_ref()) })
}
}
}
fn run_db_migrate(
db_path: &Path,
backups_db: &Path,
from: u32,
to: u32,
) -> Result<Option<()>, BeadsError> {
use crate::franken_sync::Connection;
use fsqlite_types::value::SqliteValue;
if to <= from {
return Err(BeadsError::internal(format!(
"doctor: db migrate refused — to ({to}) must be > from ({from})"
)));
}
let snapshot_path = backups_db.join("beads.db.pre-migrate");
if let Some(parent) = snapshot_path.parent() {
fs::create_dir_all(parent).map_err(BeadsError::Io)?;
}
fs::copy(db_path, &snapshot_path).map_err(BeadsError::Io)?;
copy_source_permissions(db_path, &snapshot_path).map_err(BeadsError::Io)?;
let conn = Connection::open(db_path.to_string_lossy().into_owned())?;
let row = conn.query_row("PRAGMA user_version")?;
let current = row
.get(0)
.and_then(|v| match v {
SqliteValue::Integer(n) => u32::try_from(*n).ok(),
_ => None,
})
.unwrap_or(0);
let _ = conn.close();
if current != from {
let _ = fs::remove_file(&snapshot_path);
return Err(BeadsError::internal(format!(
"doctor: db migrate refused — user_version mismatch (expected {from}, got {current})"
)));
}
let migrate_conn = Connection::open(db_path.to_string_lossy().into_owned())?;
let migration_result = crate::storage::schema::run_migrations_atomic(&migrate_conn, from, to);
let _ = migrate_conn.close();
match migration_result {
Ok(()) => Ok(None),
Err(err) => {
if let Err(restore_err) = fs::copy(&snapshot_path, db_path) {
return Err(BeadsError::internal(format!(
"doctor: db migrate failed ({err}); restore from snapshot also failed: {restore_err}"
)));
}
Err(err)
}
}
}
fn execute_atomic(
path: &Path,
op: Op,
existing_bytes: Option<&[u8]>,
existing_mode: Option<u32>,
) -> std::io::Result<()> {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
fs::create_dir_all(parent)?;
match op {
Op::WriteFile { content, mode } => {
let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
tmp.write_all(&content)?;
tmp.as_file().sync_data()?;
apply_mode(tmp.path(), mode.unwrap_or(0o644))?;
tmp.persist(path)
.map_err(|e| std::io::Error::other(e.error.to_string()))?;
fsync_dir(parent)?;
}
Op::AppendFile { content } => {
let existing: &[u8] = existing_bytes.unwrap_or(&[]);
let mut buf = Vec::with_capacity(existing.len().saturating_add(content.len()));
buf.extend_from_slice(existing);
buf.extend_from_slice(&content);
let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
tmp.write_all(&buf)?;
tmp.as_file().sync_data()?;
let mode = existing_mode.unwrap_or(0o644);
apply_mode(tmp.path(), mode)?;
tmp.persist(path)
.map_err(|e| std::io::Error::other(e.error.to_string()))?;
fsync_dir(parent)?;
}
Op::Rename { to } => {
if let Some(p) = to.parent() {
fs::create_dir_all(p)?;
}
fs::rename(path, &to)?;
fsync_dir(parent)?;
if let Some(dest_parent) = to.parent()
&& dest_parent != parent
{
fsync_dir(dest_parent)?;
}
}
Op::Chmod { mode } => {
apply_mode(path, mode)?;
}
Op::SymlinkAtomic { target } => {
let basename = path
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "target".to_string());
let tmp = path.with_file_name(format!(
".{}.doctor-symlink-tmp.{}.{}",
basename,
std::process::id(),
now_ns()
));
match fs::remove_file(&tmp) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e),
}
create_symlink(&target, &tmp)?;
fs::rename(&tmp, path)?;
fsync_dir(parent)?;
}
Op::DbExec { .. } | Op::DbMigrate { .. } => {
return Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"doctor: db op reached execute_atomic; should be intercepted",
));
}
}
Ok(())
}
pub fn record_legacy_op<F>(
ctx: &MutateContext,
fixer_id: &str,
paths: &[&Path],
legacy: F,
) -> Result<(), BeadsError>
where
F: FnOnce() -> Result<(), BeadsError>,
{
struct LegacyPreState {
path: PathBuf,
backup: PathBuf,
backup_path: Option<String>,
before_hash: String,
existed_before: bool,
bytes: Option<Vec<u8>>,
}
let mut pre_state = Vec::with_capacity(paths.len());
for path in paths {
ensure_in_scope(&ctx.capabilities, path)?;
let bytes_or_missing = match fs::read(path) {
Ok(b) => Some(b),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => return Err(BeadsError::Io(e)),
};
let before_hash = match &bytes_or_missing {
Some(bytes) => sha256_hex_prefixed(bytes),
None => SHA256_EMPTY_PREFIXED.to_string(),
};
let rel = path.strip_prefix(&ctx.repo_root).unwrap_or(path);
let (backup, backup_path) = verbatim_backup_path(&ctx.run_dir, rel);
pre_state.push(LegacyPreState {
path: path.to_path_buf(),
backup,
backup_path,
before_hash,
existed_before: bytes_or_missing.is_some(),
bytes: bytes_or_missing,
});
}
let started_at_ns = now_ns().saturating_sub(ctx.start_ns);
if ctx.dry_run {
for state in &pre_state {
eprintln!("[dry-run] would mutate {}: legacy_op", state.path.display());
}
return Ok(());
}
for state in &pre_state {
if let Some(bytes) = &state.bytes {
write_verbatim_backup(&state.path, &state.backup, bytes).map_err(BeadsError::Io)?;
cmp_strict(&state.path, &state.backup).map_err(BeadsError::Io)?;
}
}
let outcome = legacy();
let legacy_ok = outcome.is_ok();
let legacy_error = outcome.as_ref().err().map(ToString::to_string);
let finished_at_ns = now_ns().saturating_sub(ctx.start_ns);
for state in &pre_state {
let rel = state
.path
.strip_prefix(&ctx.repo_root)
.unwrap_or(&state.path);
let after_bytes_or_missing = match fs::read(&state.path) {
Ok(b) => Some(b),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => return Err(BeadsError::Io(e)),
};
let after_hash = match &after_bytes_or_missing {
Some(bytes) => sha256_hex_prefixed(bytes),
None => SHA256_EMPTY_PREFIXED.to_string(),
};
let record = ActionRecord {
path: rel.to_string_lossy().into_owned(),
op: "legacy_op",
before_hash: state.before_hash.clone(),
after_hash,
started_at_ns,
finished_at_ns,
run_id: ctx.run_id.clone(),
fixer_id: fixer_id.to_string(),
backup_path: state.backup_path.clone(),
ok: legacy_ok,
rename_to: None,
rolled_back: None,
error: legacy_error.clone(),
};
let mut line = serde_json::to_string(&record).map_err(BeadsError::Json)?;
if !state.existed_before {
line = line.trim_end_matches('}').to_string() + ",\"existed_before\":false}";
}
line.push('\n');
let mut f = ctx.actions_file.lock().map_err(|e| {
BeadsError::internal(format!("doctor: actions_file mutex poisoned: {e}"))
})?;
f.write_all(line.as_bytes()).map_err(BeadsError::Io)?;
f.sync_data().map_err(BeadsError::Io)?;
}
outcome
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use crate::franken_sync::Connection;
use std::io::BufRead;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
fn make_ctx(tmp: &Path, dry_run: bool) -> (MutateContext, PathBuf) {
let run_id = "test-run".to_string();
let run_dir = tmp.join(".doctor/runs").join(&run_id);
fs::create_dir_all(run_dir.join("backups")).unwrap();
let actions_path = run_dir.join("actions.jsonl");
let actions_file = OpenOptions::new()
.create(true)
.append(true)
.open(&actions_path)
.unwrap();
let ctx = MutateContext {
run_id,
run_dir: run_dir.clone(),
capabilities: Capabilities::for_repo(tmp),
actions_file: Mutex::new(actions_file),
fixer_id: "test-fixer".to_string(),
repo_root: tmp.to_path_buf(),
dry_run,
start_ns: now_ns(),
};
(ctx, actions_path)
}
#[test]
fn write_file_creates_backup_and_records_action() {
let tmp = tempfile::tempdir().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let target = beads_dir.join("foo.txt");
fs::write(&target, b"original").unwrap();
let (ctx, actions_path) = make_ctx(tmp.path(), false);
let result = mutate(
&ctx,
&target,
Op::WriteFile {
content: b"updated".to_vec(),
mode: Some(0o644),
},
)
.expect("mutate should succeed");
assert!(result.ok);
assert!(result.before_hash.starts_with("sha256:"));
assert!(result.after_hash.starts_with("sha256:"));
assert_ne!(result.before_hash, result.after_hash);
assert_eq!(fs::read(&target).unwrap(), b"updated");
let backup = ctx.run_dir.join("backups/.beads/foo.txt");
assert!(backup.exists(), "backup must exist: {}", backup.display());
assert_eq!(fs::read(&backup).unwrap(), b"original");
let f = std::fs::File::open(&actions_path).unwrap();
let lines: Vec<String> = std::io::BufReader::new(f)
.lines()
.collect::<std::io::Result<_>>()
.unwrap();
assert_eq!(lines.len(), 1);
let v: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
assert_eq!(v["op"], "write_file");
assert_eq!(v["fixer_id"], "test-fixer");
assert_eq!(v["run_id"], "test-run");
assert_eq!(v["ok"], true);
}
#[test]
fn dry_run_does_not_touch_disk() {
let tmp = tempfile::tempdir().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let target = beads_dir.join("dry.txt");
fs::write(&target, b"keep me").unwrap();
let (ctx, actions_path) = make_ctx(tmp.path(), true);
let result = mutate(
&ctx,
&target,
Op::WriteFile {
content: b"would not write".to_vec(),
mode: Some(0o644),
},
)
.expect("dry-run mutate should succeed");
assert!(result.ok);
assert_eq!(result.before_hash, result.after_hash);
assert_eq!(fs::read(&target).unwrap(), b"keep me");
let backup = ctx.run_dir.join("backups/.beads/dry.txt");
assert!(!backup.exists());
assert_eq!(fs::metadata(&actions_path).unwrap().len(), 0);
}
#[test]
fn legacy_op_dry_run_does_not_invoke_legacy_closure() {
let tmp = tempfile::tempdir().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let target = beads_dir.join("legacy.txt");
fs::write(&target, b"original").unwrap();
let (ctx, actions_path) = make_ctx(tmp.path(), true);
let invoked = std::cell::Cell::new(false);
record_legacy_op(&ctx, "legacy-dry-run", &[&target], || {
invoked.set(true);
fs::write(&target, b"mutated").map_err(BeadsError::Io)?;
Ok(())
})
.expect("dry-run legacy op should succeed");
assert!(
!invoked.get(),
"dry-run legacy op must not invoke the mutating closure"
);
assert_eq!(
fs::read(&target).unwrap(),
b"original",
"dry-run legacy op must leave target bytes unchanged"
);
let backup = ctx.run_dir.join("backups/.beads/legacy.txt");
assert!(
!backup.exists(),
"dry-run legacy op must not create a backup"
);
assert_eq!(
fs::metadata(&actions_path).unwrap().len(),
0,
"dry-run legacy op must not append actions.jsonl"
);
}
#[test]
fn legacy_op_records_closure_error_in_actions_jsonl() {
let tmp = tempfile::tempdir().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let target = beads_dir.join("legacy-error.txt");
fs::write(&target, b"original").unwrap();
let (ctx, actions_path) = make_ctx(tmp.path(), false);
let err = record_legacy_op(&ctx, "legacy-error", &[&target], || {
fs::write(&target, b"partially mutated").map_err(BeadsError::Io)?;
Err(BeadsError::Config("legacy fixer failed".to_string()))
})
.expect_err("legacy closure error should be propagated");
assert!(
err.to_string().contains("legacy fixer failed"),
"unexpected propagated error: {err}"
);
let backup = ctx.run_dir.join("backups/.beads/legacy-error.txt");
assert_eq!(
fs::read(&backup).unwrap(),
b"original",
"legacy failure must still retain the pre-mutation backup"
);
let lines = fs::read_to_string(actions_path).unwrap();
let action: serde_json::Value = serde_json::from_str(lines.trim()).unwrap();
assert_eq!(action["op"], "legacy_op");
assert_eq!(action["fixer_id"], "legacy-error");
assert_eq!(action["ok"], false);
assert!(
action["error"]
.as_str()
.is_some_and(|msg| msg.contains("legacy fixer failed")),
"legacy action should preserve closure error text: {action}"
);
}
#[test]
fn repeated_legacy_ops_preserve_each_pre_mutation_backup() {
let tmp = tempfile::tempdir().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let target = beads_dir.join("repeated.txt");
fs::write(&target, b"original").unwrap();
let (ctx, actions_path) = make_ctx(tmp.path(), false);
record_legacy_op(&ctx, "first", &[&target], || {
fs::write(&target, b"intermediate").map_err(BeadsError::Io)
})
.unwrap();
record_legacy_op(&ctx, "second", &[&target], || {
fs::write(&target, b"final").map_err(BeadsError::Io)
})
.unwrap();
let actions = fs::read_to_string(actions_path)
.unwrap()
.lines()
.map(|line| serde_json::from_str::<serde_json::Value>(line).unwrap())
.collect::<Vec<_>>();
assert_eq!(actions.len(), 2);
assert!(actions[0].get("backup_path").is_none());
let second_backup = actions[1]["backup_path"]
.as_str()
.expect("repeated path should use a versioned backup");
assert_eq!(
fs::read(ctx.run_dir.join("backups/.beads/repeated.txt")).unwrap(),
b"original"
);
assert_eq!(
fs::read(ctx.run_dir.join("backups").join(second_backup)).unwrap(),
b"intermediate"
);
assert_eq!(actions[0]["before_hash"], sha256_hex_prefixed(b"original"));
assert_eq!(
actions[1]["before_hash"],
sha256_hex_prefixed(b"intermediate")
);
assert_eq!(fs::read(target).unwrap(), b"final");
}
#[test]
fn legacy_op_validates_every_target_before_writing_backups() {
let tmp = tempfile::tempdir().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let in_scope = beads_dir.join("in-scope.txt");
fs::write(&in_scope, b"original").unwrap();
let outside = tempfile::tempdir().unwrap();
let out_of_scope = outside.path().join("out-of-scope.txt");
fs::write(&out_of_scope, b"outside").unwrap();
let (ctx, actions_path) = make_ctx(tmp.path(), false);
let invoked = std::cell::Cell::new(false);
let error = record_legacy_op(
&ctx,
"preflight-all-targets",
&[&in_scope, &out_of_scope],
|| {
invoked.set(true);
Ok(())
},
)
.expect_err("an out-of-scope target must reject the whole legacy operation");
assert!(error.to_string().contains("outside write_scopes"));
assert!(!invoked.get(), "legacy closure must not run after refusal");
assert_eq!(fs::read(&in_scope).unwrap(), b"original");
assert!(!ctx.run_dir.join("backups/.beads/in-scope.txt").exists());
assert_eq!(fs::metadata(actions_path).unwrap().len(), 0);
}
#[test]
fn rename_moves_file_and_after_hash_is_empty_sentinel() {
let tmp = tempfile::tempdir().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let src = beads_dir.join("orphan.lock");
fs::write(&src, b"lock-bytes").unwrap();
let (ctx, _) = make_ctx(tmp.path(), false);
let dst = ctx.run_dir.join("quarantine/orphan.lock");
let result =
mutate(&ctx, &src, Op::Rename { to: dst.clone() }).expect("rename should succeed");
assert!(result.ok);
assert_ne!(result.before_hash, SHA256_EMPTY_PREFIXED);
assert_eq!(result.after_hash, SHA256_EMPTY_PREFIXED);
assert!(!src.exists(), "source must be moved");
assert!(dst.exists(), "destination must exist");
assert_eq!(fs::read(&dst).unwrap(), b"lock-bytes");
}
#[test]
fn append_file_preserves_backed_up_bytes_and_mode() {
let tmp = tempfile::tempdir().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let target = beads_dir.join("append.log");
fs::write(&target, b"before").unwrap();
fs::set_permissions(&target, fs::Permissions::from_mode(0o600)).unwrap();
let (ctx, actions_path) = make_ctx(tmp.path(), false);
let result = mutate(
&ctx,
&target,
Op::AppendFile {
content: b"-after".to_vec(),
},
)
.expect("append should succeed");
assert!(result.ok);
assert_eq!(fs::read(&target).unwrap(), b"before-after");
assert_eq!(
fs::metadata(&target).unwrap().permissions().mode() & 0o777,
0o600
);
assert_eq!(
fs::read(ctx.run_dir.join("backups/.beads/append.log")).unwrap(),
b"before"
);
let lines = fs::read_to_string(actions_path).unwrap();
let action: serde_json::Value = serde_json::from_str(lines.trim()).unwrap();
assert_eq!(action["op"], "append_file");
assert_eq!(action["ok"], true);
}
#[test]
fn out_of_scope_path_is_refused() {
let tmp = tempfile::tempdir().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let (ctx, _) = make_ctx(tmp.path(), false);
let outside = tempfile::tempdir().unwrap();
let outside_target = outside.path().join("victim.txt");
fs::write(&outside_target, b"untouched").unwrap();
let err = mutate(
&ctx,
&outside_target,
Op::WriteFile {
content: b"oops".to_vec(),
mode: None,
},
)
.expect_err("out-of-scope writes must be refused");
assert!(
err.to_string().contains("outside write_scopes"),
"error must mention scope refusal: {err}"
);
assert_eq!(fs::read(&outside_target).unwrap(), b"untouched");
}
#[test]
fn rename_destination_outside_write_scope_is_refused() {
let tmp = tempfile::tempdir().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let source = beads_dir.join("keep-inside.txt");
fs::write(&source, b"keep inside").unwrap();
let (ctx, actions_path) = make_ctx(tmp.path(), false);
let outside = tempfile::tempdir().unwrap();
let outside_target = outside.path().join("escaped.txt");
let err = mutate(
&ctx,
&source,
Op::Rename {
to: outside_target.clone(),
},
)
.expect_err("out-of-scope rename destinations must be refused");
assert!(
err.to_string().contains("outside write_scopes"),
"error must mention scope refusal: {err}"
);
assert_eq!(fs::read(&source).unwrap(), b"keep inside");
assert!(!outside_target.exists());
assert_eq!(fs::metadata(&actions_path).unwrap().len(), 0);
let backup = ctx.run_dir.join("backups/.beads/keep-inside.txt");
assert!(!backup.exists());
}
#[test]
fn append_file_anchors_to_supplied_bytes_not_live_file() {
let tmp = tempfile::tempdir().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let target = beads_dir.join("append.log");
fs::write(&target, b"hijacked\n").unwrap();
execute_atomic(
&target,
Op::AppendFile {
content: b"new\n".to_vec(),
},
Some(b"original\n"),
Some(0o600),
)
.expect("append should use supplied bytes");
assert_eq!(fs::read(&target).unwrap(), b"original\nnew\n");
assert_eq!(
fs::metadata(&target).unwrap().permissions().mode() & 0o777,
0o600
);
}
fn setup_test_db(tmp: &Path) -> PathBuf {
let beads_dir = tmp.join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db = beads_dir.join("beads.db");
let conn = Connection::open(db.to_string_lossy().into_owned()).unwrap();
conn.execute(
"CREATE TABLE IF NOT EXISTS sample_widgets (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
value INTEGER NOT NULL DEFAULT 0
)",
)
.unwrap();
let _ = conn.close();
db
}
#[test]
fn db_snapshot_writer_never_overwrites_same_stamp() {
let tmp = tempfile::tempdir().unwrap();
let backups_db = tmp.path();
let first = write_unique_db_snapshot_with_stamp(
backups_db,
"sample_widgets",
"abcdef12",
42,
b"first",
)
.expect("first snapshot");
let second = write_unique_db_snapshot_with_stamp(
backups_db,
"sample_widgets",
"abcdef12",
42,
b"second",
)
.expect("second snapshot");
assert_ne!(first, second);
assert_eq!(fs::read(&first).unwrap(), b"first");
assert_eq!(fs::read(&second).unwrap(), b"second");
assert!(
second
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.ends_with("__001.json")),
"collision suffix should be visible in second filename: {}",
second.display()
);
}
#[test]
fn test_db_exec_snapshots_affected_rows() {
let tmp = tempfile::tempdir().unwrap();
let db = setup_test_db(tmp.path());
let (ctx, actions_path) = make_ctx(tmp.path(), false);
let before_sha_path = sha256_file_hex_prefixed(&db).unwrap();
assert!(before_sha_path.starts_with("sha256:"));
let result = mutate(
&ctx,
&db,
Op::DbExec {
sql: "INSERT INTO sample_widgets(name, value) VALUES (?, ?)".into(),
args: vec![DbArg::Text("alpha".into()), DbArg::I64(7)],
affected_tables: vec!["sample_widgets".into()],
affected_predicate: None,
},
)
.expect("DbExec should succeed");
assert!(result.ok);
assert_ne!(
result.before_hash, result.after_hash,
"after_hash must differ from before_hash because the DB grew"
);
let snapshot_dir = ctx.run_dir.join("backups/db");
let entries: Vec<_> = fs::read_dir(&snapshot_dir)
.unwrap()
.filter_map(std::result::Result::ok)
.collect();
assert_eq!(entries.len(), 1, "expected exactly one snapshot file");
let body = fs::read_to_string(entries[0].path()).unwrap();
let snap: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(snap["table"], "sample_widgets");
assert_eq!(snap["rows"].as_array().unwrap().len(), 0);
assert_eq!(
snap["columns"].as_array().unwrap(),
&vec![
serde_json::Value::String("id".into()),
serde_json::Value::String("name".into()),
serde_json::Value::String("value".into()),
]
);
let conn = Connection::open(db.to_string_lossy().into_owned()).unwrap();
let rows = conn
.query("SELECT name, value FROM sample_widgets")
.unwrap();
assert_eq!(rows.len(), 1);
let _ = conn.close();
let log = fs::read_to_string(&actions_path).unwrap();
let line = log.lines().next().expect("at least one action line");
let v: serde_json::Value = serde_json::from_str(line).unwrap();
assert_eq!(v["op"], "db_exec");
assert_eq!(v["affected_tables"], "sample_widgets");
let hashes = v["db_snapshot_sha256"].as_array().unwrap();
assert_eq!(hashes.len(), 1);
assert_eq!(
hashes[0].as_str().unwrap(),
sha256_hex_prefixed(body.as_bytes())
);
}
#[test]
fn test_db_exec_rollback_on_constraint_violation() {
let tmp = tempfile::tempdir().unwrap();
let db = setup_test_db(tmp.path());
{
let conn = Connection::open(db.to_string_lossy().into_owned()).unwrap();
conn.execute("INSERT INTO sample_widgets(name, value) VALUES ('dup', 1)")
.unwrap();
let _ = conn.close();
}
let (ctx, actions_path) = make_ctx(tmp.path(), false);
let err = mutate(
&ctx,
&db,
Op::DbExec {
sql: "INSERT INTO sample_widgets(name, value) VALUES (?, ?)".into(),
args: vec![DbArg::Text("dup".into()), DbArg::I64(2)],
affected_tables: vec!["sample_widgets".into()],
affected_predicate: None,
},
)
.expect_err("UNIQUE violation should propagate");
let msg = err.to_string();
assert!(
msg.to_lowercase().contains("unique") || msg.to_lowercase().contains("constraint"),
"expected unique/constraint failure; got {msg}"
);
let log_len = fs::metadata(&actions_path).map(|m| m.len()).unwrap_or(0);
assert_eq!(log_len, 0, "actions.jsonl must remain empty on rollback");
let conn = Connection::open(db.to_string_lossy().into_owned()).unwrap();
let rows = conn.query("SELECT COUNT(*) FROM sample_widgets").unwrap();
assert_eq!(
rows[0].get(0).and_then(|v| match v {
fsqlite_types::value::SqliteValue::Integer(n) => Some(*n),
_ => None,
}),
Some(1),
"rollback must leave only the seed row in the table"
);
let _ = conn.close();
let snapshot_dir = ctx.run_dir.join("backups/db");
if snapshot_dir.exists() {
let leftover: Vec<_> = fs::read_dir(&snapshot_dir)
.unwrap()
.filter_map(std::result::Result::ok)
.map(|e| e.path())
.collect();
assert!(
leftover.is_empty(),
"rollback must scrub orphan snapshots; found {leftover:?}",
);
}
}
#[test]
fn test_db_migrate_user_version_mismatch_refuses() {
let tmp = tempfile::tempdir().unwrap();
let db = setup_test_db(tmp.path());
{
let conn = Connection::open(db.to_string_lossy().into_owned()).unwrap();
conn.execute("PRAGMA user_version = 5").unwrap();
let _ = conn.close();
}
let (ctx, actions_path) = make_ctx(tmp.path(), false);
let err = mutate(&ctx, &db, Op::DbMigrate { from: 4, to: 6 })
.expect_err("user_version mismatch must refuse");
let msg = err.to_string();
assert!(
msg.contains("user_version mismatch"),
"error should reference user_version mismatch; got {msg}"
);
let conn = Connection::open(db.to_string_lossy().into_owned()).unwrap();
let row = conn.query_row("PRAGMA user_version").unwrap();
let v = match row.get(0) {
Some(fsqlite_types::value::SqliteValue::Integer(n)) => *n,
_ => -1,
};
assert_eq!(v, 5, "user_version must not change after refusal");
let _ = conn.close();
let log_len = fs::metadata(&actions_path).map(|m| m.len()).unwrap_or(0);
assert_eq!(log_len, 0);
let snap = ctx.run_dir.join("backups/db/beads.db.pre-migrate");
assert!(
!snap.exists(),
"refused migrate must not leave a stale snapshot at {}",
snap.display()
);
}
#[test]
fn test_db_migrate_happy_path_writes_backup_and_runs_ddl() {
let tmp = tempfile::tempdir().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let db = beads_dir.join("beads.db");
{
let conn = Connection::open(db.to_string_lossy().into_owned()).unwrap();
crate::storage::schema::apply_schema(&conn).expect("apply_schema on fresh test DB");
conn.execute("PRAGMA user_version = 7").unwrap();
let _ = conn.close();
}
let pre_size = fs::metadata(&db).unwrap().len();
let (ctx, actions_path) = make_ctx(tmp.path(), false);
let result = mutate(
&ctx,
&db,
Op::DbMigrate {
from: 7,
to: crate::storage::schema::CURRENT_SCHEMA_VERSION as u32,
},
)
.expect("DbMigrate should run schema migrations end-to-end");
assert!(result.ok);
let snap = ctx.run_dir.join("backups/db/beads.db.pre-migrate");
assert!(
snap.exists(),
"pre-migrate snapshot missing: {}",
snap.display()
);
let snap_size = fs::metadata(&snap).unwrap().len();
assert_eq!(
snap_size, pre_size,
"snapshot length must match pre-migrate DB length"
);
let snap_bytes = fs::read(&snap).unwrap();
assert!(
snap_bytes.starts_with(b"SQLite format 3\0"),
"snapshot must be a valid SQLite file (header check)"
);
let target = crate::storage::schema::CURRENT_SCHEMA_VERSION as u32;
{
let conn = Connection::open(db.to_string_lossy().into_owned()).unwrap();
let row = conn.query_row("PRAGMA user_version").unwrap();
let v = match row.get(0) {
Some(fsqlite_types::value::SqliteValue::Integer(n)) => u32::try_from(*n).unwrap(),
_ => 0,
};
assert_eq!(
v, target,
"post-migrate user_version should be CURRENT_SCHEMA_VERSION ({target})"
);
let rows = conn
.query(
"SELECT name FROM sqlite_master WHERE type='table' AND name='close_metadata'",
)
.unwrap();
assert_eq!(
rows.len(),
1,
"v9 migration must have created close_metadata table"
);
let _ = conn.close();
}
let log = fs::read_to_string(&actions_path).unwrap();
let line = log.lines().next().expect("expected one action line");
let v: serde_json::Value = serde_json::from_str(line).unwrap();
assert_eq!(v["op"], "db_migrate");
assert_eq!(v["migrate_from"], 7);
assert_eq!(v["migrate_to"], i64::from(target));
assert!(
v.get("warning").is_none() || v["warning"].is_null(),
"warning field should be absent now that DDL routing is wired: {v}"
);
}
mod reversibility_props {
use super::*;
use proptest::prelude::*;
proptest! {
#![proptest_config(ProptestConfig::with_cases(64))]
#[test]
fn prop_writefile_backup_roundtrips_any_bytes(
original in proptest::collection::vec(any::<u8>(), 0..2048),
updated in proptest::collection::vec(any::<u8>(), 0..2048),
) {
let tmp = tempfile::tempdir().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let target = beads_dir.join("rt.bin");
fs::write(&target, &original).unwrap();
let (ctx, _ap) = make_ctx(tmp.path(), false);
let result = mutate(
&ctx,
&target,
Op::WriteFile { content: updated.clone(), mode: None },
).unwrap();
prop_assert_eq!(fs::read(&target).unwrap(), updated.clone());
prop_assert_eq!(&result.before_hash, &sha256_hex_prefixed(&original));
let backup = ctx.run_dir.join("backups/.beads/rt.bin");
prop_assert_eq!(fs::read(&backup).unwrap(), original.clone());
let restored = fs::read(&backup).unwrap();
fs::write(&target, &restored).unwrap();
prop_assert_eq!(fs::read(&target).unwrap(), original);
}
#[test]
fn prop_appendfile_backup_roundtrips_any_bytes(
original in proptest::collection::vec(any::<u8>(), 1..2048),
suffix in proptest::collection::vec(any::<u8>(), 0..2048),
) {
let tmp = tempfile::tempdir().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let target = beads_dir.join("append-rt.bin");
fs::write(&target, &original).unwrap();
let (ctx, _ap) = make_ctx(tmp.path(), false);
mutate(
&ctx,
&target,
Op::AppendFile { content: suffix.clone() },
).unwrap();
let mut expected = original.clone();
expected.extend_from_slice(&suffix);
prop_assert_eq!(fs::read(&target).unwrap(), expected);
let backup = ctx.run_dir.join("backups/.beads/append-rt.bin");
prop_assert_eq!(fs::read(&backup).unwrap(), original.clone());
let restored = fs::read(&backup).unwrap();
fs::write(&target, &restored).unwrap();
prop_assert_eq!(fs::read(&target).unwrap(), original);
}
#[test]
fn prop_chmod_backup_preserves_original_mode(
content in proptest::collection::vec(any::<u8>(), 0..512),
m1 in 0o400u32..=0o777,
m2 in 0o400u32..=0o777,
) {
let tmp = tempfile::tempdir().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let target = beads_dir.join("chmod-rt.bin");
fs::write(&target, &content).unwrap();
fs::set_permissions(&target, fs::Permissions::from_mode(m1)).unwrap();
let (ctx, _ap) = make_ctx(tmp.path(), false);
mutate(&ctx, &target, Op::Chmod { mode: m2 }).unwrap();
prop_assert_eq!(fs::read(&target).unwrap(), content.clone());
prop_assert_eq!(
fs::metadata(&target).unwrap().permissions().mode() & 0o777,
m2 & 0o777
);
let backup = ctx.run_dir.join("backups/.beads/chmod-rt.bin");
prop_assert_eq!(fs::read(&backup).unwrap(), content);
prop_assert_eq!(
fs::metadata(&backup).unwrap().permissions().mode() & 0o777,
m1 & 0o777
);
let backup_mode = fs::metadata(&backup).unwrap().permissions().mode() & 0o777;
fs::set_permissions(&target, fs::Permissions::from_mode(backup_mode)).unwrap();
prop_assert_eq!(
fs::metadata(&target).unwrap().permissions().mode() & 0o777,
m1 & 0o777
);
}
}
}
mod idempotence_props {
use super::*;
use proptest::prelude::*;
fn ctx_with_run(tmp: &Path, run: &str) -> MutateContext {
let run_dir = tmp.join(".doctor/runs").join(run);
fs::create_dir_all(run_dir.join("backups")).unwrap();
let actions_file = OpenOptions::new()
.create(true)
.append(true)
.open(run_dir.join("actions.jsonl"))
.unwrap();
MutateContext {
run_id: run.to_string(),
run_dir,
capabilities: Capabilities::for_repo(tmp),
actions_file: Mutex::new(actions_file),
fixer_id: "test-fixer".to_string(),
repo_root: tmp.to_path_buf(),
dry_run: false,
start_ns: now_ns(),
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(64))]
#[test]
fn prop_writefile_second_apply_is_content_noop(
original in proptest::collection::vec(any::<u8>(), 0..1024),
target_content in proptest::collection::vec(any::<u8>(), 0..1024),
) {
let tmp = tempfile::tempdir().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let target = beads_dir.join("idem-write.bin");
fs::write(&target, &original).unwrap();
let ctx1 = ctx_with_run(tmp.path(), "run-1");
mutate(&ctx1, &target, Op::WriteFile { content: target_content.clone(), mode: None }).unwrap();
let ctx2 = ctx_with_run(tmp.path(), "run-2");
let r2 = mutate(&ctx2, &target, Op::WriteFile { content: target_content.clone(), mode: None }).unwrap();
prop_assert_eq!(&r2.before_hash, &r2.after_hash);
prop_assert_eq!(fs::read(&target).unwrap(), target_content);
}
#[test]
fn prop_chmod_second_apply_is_mode_noop(
content in proptest::collection::vec(any::<u8>(), 0..256),
m in 0o400u32..=0o777,
) {
let tmp = tempfile::tempdir().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let target = beads_dir.join("idem-chmod.bin");
fs::write(&target, &content).unwrap();
fs::set_permissions(&target, fs::Permissions::from_mode(m)).unwrap();
let ctx1 = ctx_with_run(tmp.path(), "run-1");
mutate(&ctx1, &target, Op::Chmod { mode: m }).unwrap();
let ctx2 = ctx_with_run(tmp.path(), "run-2");
mutate(&ctx2, &target, Op::Chmod { mode: m }).unwrap();
prop_assert_eq!(
fs::metadata(&target).unwrap().permissions().mode() & 0o777,
m & 0o777
);
}
#[test]
fn prop_appendfile_is_not_idempotent(
original in proptest::collection::vec(any::<u8>(), 0..512),
suffix in proptest::collection::vec(any::<u8>(), 1..512),
) {
let tmp = tempfile::tempdir().unwrap();
let beads_dir = tmp.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let target = beads_dir.join("idem-append.bin");
fs::write(&target, &original).unwrap();
let ctx1 = ctx_with_run(tmp.path(), "run-1");
mutate(&ctx1, &target, Op::AppendFile { content: suffix.clone() }).unwrap();
let ctx2 = ctx_with_run(tmp.path(), "run-2");
mutate(&ctx2, &target, Op::AppendFile { content: suffix.clone() }).unwrap();
let mut expected = original.clone();
expected.extend_from_slice(&suffix);
expected.extend_from_slice(&suffix);
prop_assert_eq!(fs::read(&target).unwrap(), expected);
}
}
}
}