Skip to main content

ironwork_rt/
evidence.rs

1//! Run journals in cobolwork's evidence format (cobolwork `docs/spec/evidence.md` §4-6), so one
2//! verifier reads both tools: each record is canonical JSON hashed as
3//! SHA-256("cobolwork-evidence/v1\n" || record without "hash"), chained by `prev` and `seq`, and
4//! each closed run adds its tip to `ledger.jsonl`. Nothing written holds source text or a secret.
5
6use std::collections::BTreeMap;
7use std::fs::{self, File, OpenOptions};
8use std::io::{self, Read, Seek, SeekFrom, Write};
9use std::path::{Path, PathBuf};
10use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
11
12use crate::digest::{hex, sha256};
13
14const DOMAIN: &str = "cobolwork-evidence/v1\n";
15const ZERO: &str = "0000000000000000000000000000000000000000000000000000000000000000";
16pub const LEDGER: &str = "ledger.jsonl";
17const LOCK: &str = "ledger.lock";
18
19#[derive(Clone, Debug, PartialEq, Eq)]
20pub enum Value {
21    Null,
22    Bool(bool),
23    Int(i64),
24    Str(String),
25    Arr(Vec<Value>),
26    Obj(BTreeMap<String, Value>),
27}
28
29impl From<&str> for Value {
30    fn from(s: &str) -> Self {
31        Self::Str(s.to_string())
32    }
33}
34impl From<String> for Value {
35    fn from(s: String) -> Self {
36        Self::Str(s)
37    }
38}
39impl From<i64> for Value {
40    fn from(n: i64) -> Self {
41        Self::Int(n)
42    }
43}
44impl From<u64> for Value {
45    fn from(n: u64) -> Self {
46        Self::Int(i64::try_from(n).unwrap_or(i64::MAX))
47    }
48}
49impl From<bool> for Value {
50    fn from(b: bool) -> Self {
51        Self::Bool(b)
52    }
53}
54
55/// Builds the fields of one record: `fields([("dd", "IN".into()), ...])`.
56pub fn fields<const N: usize>(pairs: [(&str, Value); N]) -> BTreeMap<String, Value> {
57    pairs.into_iter().map(|(k, v)| (k.to_string(), v)).collect()
58}
59
60/// Strings escaped as JavaScript's JSON.stringify escapes them.
61fn escape(s: &str, out: &mut String) {
62    out.push('"');
63    for c in s.chars() {
64        match c {
65            '"' => out.push_str("\\\""),
66            '\\' => out.push_str("\\\\"),
67            '\u{8}' => out.push_str("\\b"),
68            '\u{c}' => out.push_str("\\f"),
69            '\n' => out.push_str("\\n"),
70            '\r' => out.push_str("\\r"),
71            '\t' => out.push_str("\\t"),
72            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
73            c => out.push(c),
74        }
75    }
76    out.push('"');
77}
78
79/// Keys sorted by UTF-16 code unit, as JavaScript sorts them, at every depth; no whitespace.
80pub fn canonical(value: &Value) -> String {
81    let mut out = String::new();
82    write(value, &mut out);
83    out
84}
85
86fn write(value: &Value, out: &mut String) {
87    match value {
88        Value::Null => out.push_str("null"),
89        Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
90        Value::Int(n) => out.push_str(&n.to_string()),
91        Value::Str(s) => escape(s, out),
92        Value::Arr(items) => {
93            out.push('[');
94            for (i, item) in items.iter().enumerate() {
95                if i > 0 {
96                    out.push(',');
97                }
98                write(item, out);
99            }
100            out.push(']');
101        }
102        Value::Obj(map) => {
103            let mut keys: Vec<&String> = map.keys().collect();
104            keys.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16()));
105            out.push('{');
106            for (i, k) in keys.into_iter().enumerate() {
107                if i > 0 {
108                    out.push(',');
109                }
110                escape(k, out);
111                out.push(':');
112                write(&map[k], out);
113            }
114            out.push('}');
115        }
116    }
117}
118
119pub fn record_hash(record: &BTreeMap<String, Value>) -> String {
120    let mut body = record.clone();
121    body.remove("hash");
122    let mut text = String::from(DOMAIN);
123    text.push_str(&canonical(&Value::Obj(body)));
124    hex(&sha256(text.as_bytes()))
125}
126
127/// Each kind ironwork writes: the file it goes in, the fields it may carry and those it must. Each
128/// row stays within cobolwork's kinds table (fixtures/cobolwork/evidence/kinds.tsv), which its
129/// verifier holds every record to.
130const KINDS: [(&str, &str, &[&str], &[&str]); 13] = [
131    ("open", "journal", &["tool", "toolVersion", "toolRevision", "command", "argv", "roots", "platform"], &["tool", "toolVersion", "command", "argv", "roots"]),
132    ("input", "journal", &["root", "path", "sha256", "bytes"], &["root", "path", "sha256"]),
133    ("dd", "journal", &["dd", "event", "mode", "sha256", "bytes"], &["dd", "event"]),
134    ("call", "journal", &["program", "from", "sha256"], &["program"]),
135    ("abend", "journal", &["code", "file", "line"], &["code"]),
136    ("step", "journal", &["step", "pgm", "outcome"], &["step", "pgm", "outcome"]),
137    ("sink", "journal", &["sink", "file", "line", "marker", "reached", "input"], &["sink", "file", "line"]),
138    ("statement", "journal", &["file", "line", "capped"], &["file", "line"]),
139    ("output", "journal", &["name", "sha256", "bytes", "path", "stdout"], &["name", "sha256"]),
140    ("close", "journal", &["exit", "counts", "durationMs", "ledger", "executor", "assumptions"], &["exit"]),
141    ("genesis", "ledger", &["createdAt"], &["createdAt"]),
142    ("run", "ledger", &["run", "runChain", "runLength", "runTip"], &["run", "runChain", "runLength", "runTip"]),
143    ("lock-broken", "ledger", &["holderPid", "ageMs"], &["holderPid", "ageMs"]),
144];
145
146fn kind_fields(kind: &str) -> Option<(&'static [&'static str], &'static [&'static str])> {
147    KINDS.iter().find(|(name, ..)| *name == kind).map(|(_, _, allowed, required)| (*allowed, *required))
148}
149
150fn check(kind: &str, record: &BTreeMap<String, Value>) -> io::Result<()> {
151    let bad = |m: String| io::Error::new(io::ErrorKind::InvalidInput, m);
152    let (allowed, required) = kind_fields(kind).ok_or_else(|| bad(format!("no evidence record kind {kind}")))?;
153    for k in record.keys() {
154        if !allowed.contains(&k.as_str()) {
155            return Err(bad(format!("{kind}: no field {k}")));
156        }
157    }
158    for k in required {
159        if !record.contains_key(*k) {
160            return Err(bad(format!("{kind}: {k} is required")));
161        }
162    }
163    Ok(())
164}
165
166/// Now, as JavaScript's Date.prototype.toISOString writes it.
167pub fn iso_now() -> String {
168    let d = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default();
169    iso(d.as_secs() as i64, d.subsec_millis())
170}
171
172pub fn iso(seconds: i64, millis: u32) -> String {
173    let c = crate::calendar::civil(seconds);
174    format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z", c.year, c.month, c.day, c.hour, c.minute, c.second, millis)
175}
176
177/// `n` unpredictable bytes from the operating system, or, where it offers none, bytes that are
178/// unique to this process and moment: a chain id must not repeat, and needs no secrecy.
179fn random(n: usize) -> Vec<u8> {
180    let mut buf = vec![0u8; n];
181    if File::open("/dev/urandom").and_then(|mut f| f.read_exact(&mut buf)).is_ok() {
182        return buf;
183    }
184    use std::hash::{BuildHasher, Hasher};
185    let d = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default();
186    let mut keyed = std::collections::hash_map::RandomState::new().build_hasher();
187    keyed.write_u128(d.as_nanos());
188    let seed = format!("{}:{}:{:p}:{}", d.as_nanos(), std::process::id(), &buf, keyed.finish());
189    sha256(seed.as_bytes())[..n.min(32)].to_vec()
190}
191
192fn open_new(path: &Path) -> io::Result<File> {
193    let mut o = OpenOptions::new();
194    o.write(true).create_new(true);
195    #[cfg(unix)]
196    {
197        use std::os::unix::fs::OpenOptionsExt;
198        o.mode(0o600);
199    }
200    o.open(path)
201}
202
203fn refuse_link(path: &Path) -> io::Result<()> {
204    match fs::symlink_metadata(path) {
205        Ok(m) if m.file_type().is_symlink() => Err(io::Error::new(io::ErrorKind::InvalidInput, format!("{} is a symbolic link, which evidence is not written through", path.display()))),
206        _ => Ok(()),
207    }
208}
209
210/// The evidence directory with runs/ and seals/, refusing a link at any of the three, and a
211/// directory inside a tree the run reads.
212pub fn prepare(dir: &Path, reads: &[PathBuf]) -> io::Result<PathBuf> {
213    let absolute = std::path::absolute(dir)?;
214    let mut existing = absolute.clone();
215    let mut rest = Vec::new();
216    while !existing.exists() {
217        match (existing.file_name().map(|n| n.to_os_string()), existing.parent()) {
218            (Some(name), Some(parent)) => {
219                rest.push(name);
220                existing = parent.to_path_buf();
221            }
222            _ => break,
223        }
224    }
225    let mut intended = fs::canonicalize(&existing)?;
226    for name in rest.iter().rev() {
227        intended.push(name);
228    }
229    for root in reads {
230        let tree = if root.as_os_str().is_empty() { Path::new(".") } else { root };
231        if let Ok(r) = fs::canonicalize(tree)
232            && intended.starts_with(&r)
233        {
234            return Err(io::Error::new(io::ErrorKind::InvalidInput, format!("the evidence directory {} is inside {}, which this run reads", dir.display(), root.display())));
235        }
236    }
237    for p in [absolute.clone(), absolute.join("runs"), absolute.join("seals")] {
238        refuse_link(&p)?;
239        if !p.exists() {
240            fs::create_dir_all(&p)?;
241            #[cfg(unix)]
242            {
243                use std::os::unix::fs::PermissionsExt;
244                fs::set_permissions(&p, fs::Permissions::from_mode(0o700))?;
245            }
246        }
247        refuse_link(&p)?;
248    }
249    let made = fs::canonicalize(&absolute)?;
250    if made != intended {
251        return Err(io::Error::new(io::ErrorKind::InvalidInput, format!("the evidence directory {} resolved to {} while it was made", dir.display(), made.display())));
252    }
253    Ok(made)
254}
255
256struct Chain {
257    chain: String,
258    seq: i64,
259    prev: String,
260}
261
262impl Chain {
263    fn record(&mut self, kind: &str, mut fields: BTreeMap<String, Value>, at: &str) -> io::Result<String> {
264        check(kind, &fields)?;
265        fields.insert("v".into(), Value::Int(1));
266        fields.insert("chain".into(), Value::Str(self.chain.clone()));
267        fields.insert("seq".into(), Value::Int(self.seq));
268        fields.insert("at".into(), Value::Str(at.to_string()));
269        fields.insert("kind".into(), Value::Str(kind.to_string()));
270        fields.insert("prev".into(), Value::Str(self.prev.clone()));
271        let hash = record_hash(&fields);
272        fields.insert("hash".into(), Value::Str(hash.clone()));
273        self.prev = hash;
274        self.seq += 1;
275        Ok(format!("{}\n", canonical(&Value::Obj(fields))))
276    }
277}
278
279pub struct Journal {
280    pub id: String,
281    pub path: PathBuf,
282    dir: PathBuf,
283    file: File,
284    chain: Chain,
285    counts: BTreeMap<String, i64>,
286    started: Instant,
287    /// Which ran the program, `vm` or `interpreter`, for the close record.
288    pub executor: Option<&'static str>,
289    /// The ids of the register's assumptions the run could have rested on, in byte order, for the
290    /// close record; None for a command that ran no program.
291    pub assumptions: Option<Vec<&'static str>>,
292}
293
294/// Where a closed run's tip went.
295#[derive(Debug, PartialEq, Eq)]
296pub enum Ledger {
297    Recorded,
298    Unrecorded(String),
299}
300
301impl Journal {
302    pub fn create(dir: &Path, reads: &[PathBuf], command: &str, argv: &[String], tool_version: &str) -> io::Result<Self> {
303        let dir = prepare(dir, reads)?;
304        let now = iso_now();
305        let stamp: String = now.chars().filter(|c| c.is_ascii_digit() || *c == 'T').take(15).collect();
306        let id = format!("{stamp}Z-{}", hex(&random(8)));
307        let path = dir.join("runs").join(format!("{id}.jsonl"));
308        let file = open_new(&path)?;
309        let mut journal = Self { id, path, dir, file, chain: Chain { chain: hex(&random(16)), seq: 0, prev: ZERO.into() }, counts: BTreeMap::new(), started: Instant::now(), executor: None, assumptions: None };
310        let roots = reads.iter().map(|r| Value::Str(r.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default())).collect();
311        journal.append_at(
312            "open",
313            fields([
314                ("tool", "ironwork".into()),
315                ("toolVersion", tool_version.into()),
316                ("command", command.into()),
317                ("argv", Value::Arr(argv.iter().map(|a| Value::Str(a.clone())).collect())),
318                ("roots", Value::Arr(roots)),
319                ("platform", std::env::consts::OS.into()),
320            ]),
321            &now,
322        )?;
323        Ok(journal)
324    }
325
326    fn append_at(&mut self, kind: &str, fields: BTreeMap<String, Value>, at: &str) -> io::Result<()> {
327        let line = self.chain.record(kind, fields, at)?;
328        self.file.write_all(line.as_bytes())?;
329        *self.counts.entry(kind.to_string()).or_default() += 1;
330        Ok(())
331    }
332
333    /// The hash of the last record written.
334    pub fn tip(&self) -> &str {
335        &self.chain.prev
336    }
337
338    pub fn append(&mut self, kind: &str, fields: BTreeMap<String, Value>) -> io::Result<()> {
339        self.append_at(kind, fields, &iso_now())
340    }
341
342    /// Writes close, then the ledger record carrying this journal's tip.
343    pub fn close(mut self, exit: Option<i64>) -> io::Result<Ledger> {
344        let lock = self.dir.join(LOCK);
345        let held = take_lock(&lock, LOCK_WAIT);
346        let counts = self.counts.iter().map(|(k, v)| (k.clone(), Value::Int(*v))).collect();
347        let ledger = if held.is_ok() { "recorded" } else { "unrecorded" };
348        let duration = i64::try_from(self.started.elapsed().as_millis()).unwrap_or(i64::MAX);
349        let mut close = fields([("exit", exit.map_or(Value::Null, Value::Int)), ("counts", Value::Obj(counts)), ("durationMs", Value::Int(duration)), ("ledger", ledger.into())]);
350        if let Some(executor) = self.executor {
351            close.insert("executor".into(), executor.into());
352        }
353        if let Some(ids) = self.assumptions.take() {
354            close.insert("assumptions".into(), Value::Arr(ids.into_iter().map(Value::from).collect()));
355        }
356        self.append("close", close)?;
357        self.file.sync_all()?;
358        let broken = match held {
359            Ok(broken) => broken,
360            Err(reason) => return Ok(Ledger::Unrecorded(reason)),
361        };
362        let result = append_ledger(&self.dir, &self.id, &self.chain, broken);
363        let _ = fs::remove_file(&lock);
364        match result {
365            Ok(()) => Ok(Ledger::Recorded),
366            Err(e) => Ok(Ledger::Unrecorded(e.to_string())),
367        }
368    }
369}
370
371const LOCK_WAIT: Duration = Duration::from_secs(5);
372const LOCK_STALE_MS: i64 = 60_000;
373
374/// A lock that was broken: the pid it names, if it names one, and its age.
375struct Broken {
376    pid: Option<i64>,
377    age_ms: i64,
378}
379
380/// The ledger lock, broken as cobolwork breaks it (cobolwork `docs/spec/evidence.md` §6): a lock
381/// older than a minute whose holder is not running. It is aged from the time it holds or, holding
382/// none, from its modification time. The break goes into the ledger.
383fn take_lock(path: &Path, wait: Duration) -> Result<Option<Broken>, String> {
384    let deadline = Instant::now() + wait;
385    loop {
386        match open_new(path) {
387            Ok(mut f) => {
388                let _ = writeln!(f, "{} {}", std::process::id(), now_ms());
389                return Ok(None);
390            }
391            Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
392                if let Some(seen) = read_lock(path)
393                    && seen.stale()
394                    && break_stale(path, &seen)
395                {
396                    return Ok(Some(Broken { pid: seen.pid, age_ms: seen.age_ms }));
397                }
398                if Instant::now() >= deadline {
399                    return Err("the ledger lock could not be had".into());
400                }
401                std::thread::sleep(Duration::from_millis(25));
402            }
403            Err(e) => return Err(e.to_string()),
404        }
405    }
406}
407
408fn now_ms() -> i64 {
409    i64::try_from(SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis()).unwrap_or(i64::MAX)
410}
411
412/// A lock as read: its contents, its file's identity, the pid it names and its age.
413struct SeenLock {
414    text: Vec<u8>,
415    file: u64,
416    pid: Option<i64>,
417    age_ms: i64,
418}
419
420fn file_id(meta: &fs::Metadata) -> u64 {
421    #[cfg(unix)]
422    {
423        std::os::unix::fs::MetadataExt::ino(meta)
424    }
425    #[cfg(not(unix))]
426    {
427        let _ = meta;
428        0
429    }
430}
431
432fn read_lock(path: &Path) -> Option<SeenLock> {
433    let meta = fs::symlink_metadata(path).ok()?;
434    let text = fs::read(path).ok()?;
435    let mut numbers = String::from_utf8_lossy(&text).split_whitespace().map(|w| w.parse::<i64>().ok().filter(|n| *n > 0)).collect::<Vec<_>>().into_iter();
436    let pid = numbers.next().flatten();
437    let since = numbers.next().flatten();
438    let age_ms = match (pid, since) {
439        (Some(_), Some(since)) => now_ms().saturating_sub(since),
440        _ => meta.modified().ok().and_then(|t| t.elapsed().ok()).map_or(0, |d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX)),
441    };
442    Some(SeenLock { text, file: file_id(&meta), pid, age_ms })
443}
444
445impl SeenLock {
446    fn stale(&self) -> bool {
447        self.age_ms > LOCK_STALE_MS && !self.pid.is_some_and(running)
448    }
449}
450
451/// Whether process `pid` is running; where that cannot be told, it is taken as not running.
452fn running(pid: i64) -> bool {
453    #[cfg(target_os = "linux")]
454    {
455        Path::new("/proc").join(pid.to_string()).exists()
456    }
457    #[cfg(all(unix, not(target_os = "linux")))]
458    {
459        std::process::Command::new("ps").args(["-p", &pid.to_string(), "-o", "pid="]).output().is_ok_and(|o| o.status.success() && !o.stdout.trim_ascii().is_empty())
460    }
461    #[cfg(windows)]
462    {
463        let listed = std::process::Command::new("tasklist").args(["/FI", &format!("PID eq {pid}"), "/NH", "/FO", "CSV"]).output();
464        listed.is_ok_and(|o| o.status.success() && String::from_utf8_lossy(&o.stdout).contains(&format!("\"{pid}\"")))
465    }
466    #[cfg(not(any(unix, windows)))]
467    {
468        let _ = pid;
469        false
470    }
471}
472
473/// Replaces the stale lock `seen` with this writer's own. Breakers hold `ledger.lock.break` while
474/// they break, so one breaks at a time, and rename it over the lock only while the lock is still
475/// the file judged stale: a lock a peer took since is never moved. Returns whether this writer
476/// holds the lock.
477fn break_stale(path: &Path, seen: &SeenLock) -> bool {
478    let claim = path.with_file_name(format!("{LOCK}.break"));
479    let Ok(mut f) = open_new(&claim) else {
480        if read_lock(&claim).is_some_and(|left| left.stale()) {
481            let _ = fs::remove_file(&claim);
482        }
483        return false;
484    };
485    let _ = writeln!(f, "{} {}", std::process::id(), now_ms());
486    drop(f);
487    if read_lock(path).is_some_and(|current| current.file == seen.file && current.text == seen.text) && fs::rename(&claim, path).is_ok() {
488        return true;
489    }
490    let _ = fs::remove_file(&claim);
491    false
492}
493
494/// The last line of the ledger, or None for an empty or absent one; an unterminated last line is
495/// refused rather than extended.
496fn ledger_tail(path: &Path) -> io::Result<Option<String>> {
497    // nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- the ledger path the run was given
498    let mut f = match File::open(path) {
499        Ok(f) => f,
500        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
501        Err(e) => return Err(e),
502    };
503    let size = f.metadata()?.len();
504    if size == 0 {
505        return Ok(None);
506    }
507    let window = size.min(65536);
508    f.seek(SeekFrom::Start(size - window))?;
509    let mut buf = Vec::new();
510    f.take(window).read_to_end(&mut buf)?;
511    let text = String::from_utf8_lossy(&buf);
512    let body = text.strip_suffix('\n').ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "ledger.jsonl ends in a partial line"))?;
513    match body.rsplit_once('\n') {
514        Some((_, last)) => Ok(Some(last.to_string())),
515        None if window < size => Err(io::Error::new(io::ErrorKind::InvalidData, "the ledger's last line is longer than any ledger record")),
516        None => Ok(Some(body.to_string())),
517    }
518}
519
520/// The value of a top-level string or integer field in one canonical record line.
521fn field_of(line: &str, key: &str) -> Option<String> {
522    let needle = format!("\"{key}\":");
523    let at = line.find(&needle)? + needle.len();
524    let rest = &line[at..];
525    if let Some(s) = rest.strip_prefix('"') {
526        return s.find('"').map(|end| s[..end].to_string());
527    }
528    Some(rest.chars().take_while(|c| c.is_ascii_digit()).collect())
529}
530
531fn append_ledger(dir: &Path, run: &str, journal: &Chain, broken: Option<Broken>) -> io::Result<()> {
532    let path = dir.join(LEDGER);
533    refuse_link(&path)?;
534    let mut chain = match ledger_tail(&path)? {
535        Some(line) => {
536            let bad = || io::Error::new(io::ErrorKind::InvalidData, "the ledger's last line is not a ledger record");
537            let hex_of = |key: &str, len: usize| field_of(&line, key).filter(|v| v.len() == len && v.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)));
538            let seq: i64 = field_of(&line, "seq").and_then(|s| s.parse().ok()).ok_or_else(bad)?;
539            Chain { chain: hex_of("chain", 32).ok_or_else(bad)?, seq: seq + 1, prev: hex_of("hash", 64).ok_or_else(bad)? }
540        }
541        None => Chain { chain: hex(&random(16)), seq: 0, prev: ZERO.into() },
542    };
543    let mut out = String::new();
544    let now = iso_now();
545    if chain.seq == 0 {
546        out.push_str(&chain.record("genesis", fields([("createdAt", now.clone().into())]), &now)?);
547    }
548    if let Some(b) = broken {
549        out.push_str(&chain.record("lock-broken", fields([("holderPid", b.pid.map_or(Value::Null, Value::Int)), ("ageMs", Value::Int(b.age_ms))]), &now)?);
550    }
551    out.push_str(&chain.record(
552        "run",
553        fields([("run", run.into()), ("runChain", journal.chain.clone().into()), ("runLength", Value::Int(journal.seq)), ("runTip", journal.prev.clone().into())]),
554        &now,
555    )?);
556    let mut f = OpenOptions::new().append(true).create(true).open(&path)?;
557    f.write_all(out.as_bytes())?;
558    f.sync_all()
559}
560
561#[cfg(test)]
562mod tests {
563    use super::*;
564
565    fn temp() -> PathBuf {
566        let p = std::env::temp_dir().join(format!("iw-evidence-{}", hex(&random(6))));
567        fs::create_dir_all(&p).unwrap();
568        p
569    }
570
571    #[test]
572    fn canonical_form_matches_javascript() {
573        let v = Value::Obj(fields([("b", Value::Int(2)), ("a", "q\"\\\n\u{1}é".into()), ("Z", Value::Arr(vec![Value::Null, Value::Bool(true)]))]));
574        assert_eq!(canonical(&v), "{\"Z\":[null,true],\"a\":\"q\\\"\\\\\\n\\u0001é\",\"b\":2}");
575    }
576
577    #[test]
578    fn the_hash_is_over_the_domain_tag_and_the_record_without_its_hash() {
579        let r = fields([("kind", "abend".into()), ("code", "S0C7".into()), ("hash", "ignored".into())]);
580        assert_eq!(record_hash(&r), hex(&sha256(b"cobolwork-evidence/v1\n{\"code\":\"S0C7\",\"kind\":\"abend\"}")));
581    }
582
583    #[test]
584    fn a_closed_journal_chains_and_reaches_the_ledger() {
585        let dir = temp();
586        let mut j = Journal::create(&dir.join("ev"), &[], "run", &["run".into()], "0.0.0").unwrap();
587        j.append("dd", fields([("dd", "IN".into()), ("event", "open".into()), ("mode", "INPUT".into()), ("sha256", hex(&sha256(b"x")).into()), ("bytes", Value::Int(1))])).unwrap();
588        let path = j.path.clone();
589        let id = j.id.clone();
590        assert_eq!(j.close(Some(0)).unwrap(), Ledger::Recorded);
591        let lines: Vec<String> = fs::read_to_string(&path).unwrap().lines().map(String::from).collect();
592        assert_eq!(lines.len(), 3);
593        let mut prev = ZERO.to_string();
594        for (i, line) in lines.iter().enumerate() {
595            assert_eq!(field_of(line, "seq").unwrap(), i.to_string());
596            assert_eq!(field_of(line, "prev").unwrap(), prev);
597            prev = field_of(line, "hash").unwrap();
598        }
599        let ledger = fs::read_to_string(dir.join("ev").join(LEDGER)).unwrap();
600        let run = ledger.lines().last().unwrap();
601        assert_eq!(field_of(run, "run").unwrap(), id);
602        assert_eq!(field_of(run, "runTip").unwrap(), prev);
603        assert_eq!(field_of(run, "runLength").unwrap(), "3");
604        fs::remove_dir_all(dir).unwrap();
605    }
606
607    #[test]
608    fn an_unknown_field_is_refused() {
609        let dir = temp();
610        let mut j = Journal::create(&dir.join("ev"), &[], "run", &[], "0.0.0").unwrap();
611        assert!(j.append("dd", fields([("dd", "IN".into()), ("event", "open".into()), ("record", "SECRET".into())])).is_err());
612        fs::remove_dir_all(dir).unwrap();
613    }
614
615    /// The rows of one of cobolwork's evidence tables (fixtures/cobolwork/evidence).
616    fn cobolwork_table(name: &str) -> Vec<String> {
617        let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/cobolwork/evidence").join(name);
618        let table = fs::read_to_string(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
619        table.lines().filter(|l| !l.is_empty() && !l.starts_with('#')).map(String::from).collect()
620    }
621
622    /// cobolwork's verifier refuses a kind it does not know, a field it does not list, and a record
623    /// without a field it requires.
624    #[test]
625    fn every_kind_ironwork_writes_is_one_cobolworks_verifier_accepts() {
626        let table = cobolwork_table("kinds.tsv");
627        let words = |cell: &str| cell.split(' ').filter(|w| !w.is_empty()).map(String::from).collect::<Vec<_>>();
628        let theirs: BTreeMap<&str, (&str, Vec<String>, Vec<String>)> = table
629            .iter()
630            .map(|l| match l.split('\t').collect::<Vec<_>>()[..] {
631                [kind, file, fields, required] => (kind, (file, words(fields), words(required))),
632                ref row => panic!("a row has four cells: {row:?}"),
633            })
634            .collect();
635        for (kind, file, allowed, required) in KINDS {
636            let (their_file, their_fields, their_required) = theirs.get(kind).unwrap_or_else(|| panic!("cobolwork's verifier knows no {kind} record"));
637            assert_eq!(file, *their_file, "{kind}: cobolwork reads it from the {their_file}");
638            for field in allowed {
639                assert!(their_fields.iter().any(|f| f == field), "{kind}: cobolwork's verifier refuses {field}");
640            }
641            for field in their_required {
642                assert!(required.contains(&field.as_str()), "{kind}: cobolwork's verifier requires {field}, which ironwork may leave out");
643            }
644        }
645    }
646
647    /// A sink record joins a cobolwork finding by its kind.
648    #[test]
649    fn every_sink_kind_ironwork_raises_is_one_cobolwork_names() {
650        use crate::cics::Sink;
651        let theirs = cobolwork_table("sinks.tsv");
652        for kind in crate::unit::SINK_KINDS {
653            assert!(theirs.iter().any(|k| k == kind), "cobolwork names no {kind} sink");
654        }
655        let cics = [Sink::DynamicTransfer, Sink::RecordKey, Sink::RecordUpdate, Sink::Log, Sink::Screen, Sink::WebResponse, Sink::HttpHeader, Sink::OutboundHost, Sink::OutboundHttp, Sink::QueueName, Sink::Sysid];
656        for sink in cics {
657            // No wildcard: a new variant does not compile here until it is listed in `cics`.
658            let (Sink::DynamicTransfer | Sink::RecordKey | Sink::RecordUpdate | Sink::Log | Sink::Screen | Sink::WebResponse | Sink::HttpHeader | Sink::OutboundHost | Sink::OutboundHttp | Sink::QueueName | Sink::Sysid) = sink;
659            assert!(crate::unit::SINK_KINDS.contains(&sink.kind()), "{} is not in SINK_KINDS", sink.kind());
660        }
661    }
662
663    #[test]
664    fn an_evidence_directory_inside_a_tree_read_is_refused() {
665        let dir = temp();
666        assert!(prepare(&dir.join("src").join("ev"), std::slice::from_ref(&dir)).is_err());
667        assert!(!dir.join("src").exists());
668        fs::remove_dir_all(dir).unwrap();
669    }
670
671    fn closed(ev: &Path) -> Ledger {
672        Journal::create(ev, &[], "run", &[], "0.0.0").unwrap().close(Some(0)).unwrap()
673    }
674
675    fn ledger_lines(ev: &Path) -> Vec<String> {
676        fs::read_to_string(ev.join(LEDGER)).unwrap().lines().map(String::from).collect()
677    }
678
679    #[test]
680    fn an_empty_lock_is_aged_from_its_modification_time_and_names_no_holder() {
681        let dir = temp();
682        let ev = dir.join("ev");
683        prepare(&ev, &[]).unwrap();
684        let lock = File::create(ev.join(LOCK)).unwrap();
685        lock.set_modified(SystemTime::now() - Duration::from_secs(120)).unwrap();
686        drop(lock);
687        assert_eq!(closed(&ev), Ledger::Recorded);
688        assert!(!ev.join(LOCK).exists());
689        let lines = ledger_lines(&ev);
690        assert_eq!(lines.iter().map(|l| field_of(l, "kind").unwrap()).collect::<Vec<_>>(), ["genesis", "lock-broken", "run"]);
691        assert!(lines[1].contains("\"holderPid\":null"), "{}", lines[1]);
692        fs::remove_dir_all(dir).unwrap();
693    }
694
695    #[test]
696    fn a_stale_lock_is_aged_from_the_time_it_holds_and_names_its_holder() {
697        let dir = temp();
698        let ev = dir.join("ev");
699        prepare(&ev, &[]).unwrap();
700        let mut gone = std::process::Command::new(std::env::current_exe().unwrap()).arg("--list").stdout(std::process::Stdio::null()).spawn().unwrap();
701        gone.wait().unwrap();
702        fs::write(ev.join(LOCK), format!("{} {}\n", gone.id(), now_ms() - 120_000)).unwrap();
703        assert_eq!(closed(&ev), Ledger::Recorded);
704        let lines = ledger_lines(&ev);
705        assert_eq!(field_of(&lines[1], "kind").unwrap(), "lock-broken");
706        assert_eq!(field_of(&lines[1], "holderPid").unwrap(), gone.id().to_string());
707        fs::remove_dir_all(dir).unwrap();
708    }
709
710    #[test]
711    fn a_stale_lock_whose_holder_is_running_is_waited_for() {
712        let dir = temp();
713        let lock = dir.join(LOCK);
714        let held = format!("{} {}\n", std::process::id(), now_ms() - 120_000);
715        fs::write(&lock, &held).unwrap();
716        assert!(take_lock(&lock, Duration::from_millis(100)).is_err());
717        assert_eq!(fs::read_to_string(&lock).unwrap(), held);
718        fs::remove_dir_all(dir).unwrap();
719    }
720
721    fn locks(dir: &Path) -> Vec<String> {
722        fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).filter(|n| n.starts_with(LOCK)).collect()
723    }
724
725    /// A file's inode and change time. A rename or a link changes the change time, so an unchanged
726    /// pair shows the file never left its path.
727    #[cfg(unix)]
728    fn identity(path: &Path) -> (u64, i64, i64) {
729        use std::os::unix::fs::MetadataExt;
730        let meta = fs::metadata(path).unwrap();
731        (meta.ino(), meta.ctime(), meta.ctime_nsec())
732    }
733
734    #[cfg(unix)]
735    #[test]
736    fn a_lock_taken_after_a_stale_one_was_read_is_left_alone() {
737        let dir = temp();
738        let lock = dir.join(LOCK);
739        fs::write(&lock, format!("999999 {}\n", now_ms() - 120_000)).unwrap();
740        let stale = read_lock(&lock).unwrap();
741        fs::remove_file(&lock).unwrap();
742        let peer = format!("{} {}\n", std::process::id(), now_ms());
743        fs::write(&lock, &peer).unwrap();
744        let before = identity(&lock);
745        assert!(!break_stale(&lock, &stale));
746        assert_eq!(identity(&lock), before);
747        assert_eq!(fs::read_to_string(&lock).unwrap(), peer);
748        assert_eq!(locks(&dir), [LOCK]);
749        assert!(break_stale(&lock, &read_lock(&lock).unwrap()));
750        assert_ne!(identity(&lock).0, before.0);
751        assert_eq!(locks(&dir), [LOCK]);
752        fs::remove_dir_all(dir).unwrap();
753    }
754
755    #[test]
756    fn a_breakers_claim_is_waited_for_while_held_and_removed_once_stale() {
757        let dir = temp();
758        let ev = dir.join("ev");
759        prepare(&ev, &[]).unwrap();
760        let mut gone = std::process::Command::new(std::env::current_exe().unwrap()).arg("--list").stdout(std::process::Stdio::null()).spawn().unwrap();
761        gone.wait().unwrap();
762        let stale = format!("{} {}\n", gone.id(), now_ms() - 120_000);
763        fs::write(ev.join(LOCK), &stale).unwrap();
764        let claim = ev.join(format!("{LOCK}.break"));
765        fs::write(&claim, format!("{} {}\n", std::process::id(), now_ms())).unwrap();
766        assert!(take_lock(&ev.join(LOCK), Duration::from_millis(100)).is_err());
767        assert_eq!(fs::read_to_string(ev.join(LOCK)).unwrap(), stale);
768        fs::write(&claim, &stale).unwrap();
769        assert_eq!(closed(&ev), Ledger::Recorded);
770        assert_eq!(field_of(&ledger_lines(&ev)[1], "kind").unwrap(), "lock-broken");
771        assert!(locks(&ev).is_empty());
772        fs::remove_dir_all(dir).unwrap();
773    }
774
775    #[test]
776    fn a_ledger_whose_last_line_is_no_ledger_record_is_not_extended() {
777        let dir = temp();
778        let ev = dir.join("ev");
779        prepare(&ev, &[]).unwrap();
780        for tail in ["{\"seq\":0}\n".to_string(), format!("{{\"chain\":\"{}\",\"hash\":\"{}\",\"seq\":0}}\n", "a".repeat(31), "b".repeat(64)), format!("{}\n", "x".repeat(70_000))] {
781            fs::write(ev.join(LEDGER), &tail).unwrap();
782            assert!(matches!(closed(&ev), Ledger::Unrecorded(_)), "{}", &tail[..20]);
783            assert_eq!(fs::read_to_string(ev.join(LEDGER)).unwrap(), tail);
784        }
785        fs::remove_dir_all(dir).unwrap();
786    }
787}