9a00b592f0
Consolidate the sticky-Manual rule for oneliner.json into a single helper paths::delete_oneliner_unless_manual that holds the per-case lock, reads the state, keeps OnelinerState::Manual, and removes any LLM-derived variant. Three previously-direct deletion paths now route through it: handle_delete_recording, reset_case_artefacts (admin single-reset), and bulk_reset (admin bulk action). Before this fix a clinician's manual override was wiped by a recording delete, letting the LLM auto-regen path overwrite it on the next view. Tests: 4 unit tests for the wrapper contract, integration tests for the recording-delete and case-reset endpoints (Manual preserved, Ready wiped), and a static-scan invariant that fails on direct remove_file(... ONELINER_FILENAME) calls outside paths.rs.
89 lines
3.3 KiB
Rust
89 lines
3.3 KiB
Rust
//! Repo invariant: the only place allowed to issue a direct
|
|
//! `remove_file(... ONELINER_FILENAME)` (or the literal `"oneliner.json"`)
|
|
//! is `server/src/paths.rs`, which hosts the canonical wrapper
|
|
//! `delete_oneliner_unless_manual`. Every other call-site MUST go
|
|
//! through that wrapper so the sticky-Manual rule
|
|
//! (`OnelinerState::Manual { .. }` survives every server-driven wipe)
|
|
//! is enforced uniformly.
|
|
//!
|
|
//! Static, AST-free scan: a line in a non-whitelisted source file is a
|
|
//! violation iff it mentions `remove_file` together with either the
|
|
//! constant `ONELINER_FILENAME` or the literal `"oneliner.json"` —
|
|
//! pragmatic and good enough to catch the common regression pattern.
|
|
//! Comment lines (starting with `//` after trim) are ignored so this
|
|
//! file's own documentation does not self-trip.
|
|
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
/// Files that may legitimately call `remove_file(... oneliner.json)`.
|
|
/// Paths are relative to `server/src/`.
|
|
const REMOVE_WHITELIST: &[&str] = &["paths.rs"];
|
|
|
|
#[test]
|
|
fn no_direct_oneliner_remove_outside_paths_rs() {
|
|
let server_src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
|
let mut rs_files = Vec::new();
|
|
collect_rs_files(&server_src, &mut rs_files);
|
|
assert!(
|
|
!rs_files.is_empty(),
|
|
"expected to find at least one .rs file under {}",
|
|
server_src.display()
|
|
);
|
|
|
|
let mut violations: Vec<String> = Vec::new();
|
|
for path in &rs_files {
|
|
let rel = path.strip_prefix(&server_src).unwrap();
|
|
let rel_str = rel.to_string_lossy().replace('\\', "/");
|
|
if REMOVE_WHITELIST.iter().any(|w| rel_str == *w) {
|
|
continue;
|
|
}
|
|
let contents = match fs::read_to_string(path) {
|
|
Ok(c) => c,
|
|
Err(e) => panic!("failed to read {}: {e}", path.display()),
|
|
};
|
|
for (idx, line) in contents.lines().enumerate() {
|
|
let trimmed = line.trim_start();
|
|
if trimmed.starts_with("//") {
|
|
continue;
|
|
}
|
|
let mentions_remove = trimmed.contains("remove_file");
|
|
let mentions_target =
|
|
trimmed.contains("ONELINER_FILENAME") || trimmed.contains("\"oneliner.json\"");
|
|
if mentions_remove && mentions_target {
|
|
violations.push(format!(
|
|
"{}:{}: direct remove_file on oneliner.json — \
|
|
route through paths::delete_oneliner_unless_manual to honour \
|
|
the sticky-Manual rule. Offending line: {}",
|
|
rel_str,
|
|
idx + 1,
|
|
line.trim()
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
assert!(
|
|
violations.is_empty(),
|
|
"Sticky-Manual oneliner invariant violated:\n - {}\n\n\
|
|
If this is a new legitimate exception, extend REMOVE_WHITELIST in \
|
|
tests/oneliner_disk_invariants_test.rs (and document why).",
|
|
violations.join("\n - ")
|
|
);
|
|
}
|
|
|
|
fn collect_rs_files(dir: &Path, out: &mut Vec<PathBuf>) {
|
|
let entries = match fs::read_dir(dir) {
|
|
Ok(e) => e,
|
|
Err(e) => panic!("read_dir {}: {e}", dir.display()),
|
|
};
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
if path.is_dir() {
|
|
collect_rs_files(&path, out);
|
|
} else if path.extension().and_then(|s| s.to_str()) == Some("rs") {
|
|
out.push(path);
|
|
}
|
|
}
|
|
}
|