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::{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/// The fields each kind ironwork writes may carry, and those it must.
128fn kind_fields(kind: &str) -> Option<(&'static [&'static str], &'static [&'static str])> {
129    Some(match kind {
130        "open" => (&["tool", "toolVersion", "toolRevision", "command", "argv", "roots", "platform"], &["tool", "toolVersion", "command", "argv", "roots"]),
131        "input" => (&["root", "path", "sha256", "bytes"], &["root", "path", "sha256"]),
132        "dd" => (&["dd", "event", "mode", "sha256", "bytes"], &["dd", "event"]),
133        "call" => (&["program", "from", "sha256"], &["program"]),
134        "abend" => (&["code", "file", "line"], &["code"]),
135        "step" => (&["step", "pgm", "outcome"], &["step", "pgm", "outcome"]),
136        "sink" => (&["sink", "file", "line", "marker", "reached", "input"], &["sink", "file", "line"]),
137        "statement" => (&["file", "line", "capped"], &["file", "line"]),
138        "output" => (&["name", "sha256", "bytes", "path", "stdout"], &["name", "sha256"]),
139        "close" => (&["exit", "counts", "durationMs", "ledger"], &["exit"]),
140        "genesis" => (&["createdAt", "rotatedFrom"], &["createdAt"]),
141        "run" => (&["run", "runChain", "runLength", "runTip"], &["run", "runChain", "runLength", "runTip"]),
142        "lock-broken" => (&["holderPid", "ageMs"], &["holderPid", "ageMs"]),
143        _ => return None,
144    })
145}
146
147fn check(kind: &str, record: &BTreeMap<String, Value>) -> io::Result<()> {
148    let bad = |m: String| io::Error::new(io::ErrorKind::InvalidInput, m);
149    let (allowed, required) = kind_fields(kind).ok_or_else(|| bad(format!("no evidence record kind {kind}")))?;
150    for k in record.keys() {
151        if !allowed.contains(&k.as_str()) {
152            return Err(bad(format!("{kind}: no field {k}")));
153        }
154    }
155    for k in required {
156        if !record.contains_key(*k) {
157            return Err(bad(format!("{kind}: {k} is required")));
158        }
159    }
160    Ok(())
161}
162
163/// Now, as JavaScript's Date.prototype.toISOString writes it.
164pub fn iso_now() -> String {
165    let d = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default();
166    iso(d.as_secs() as i64, d.subsec_millis())
167}
168
169pub fn iso(seconds: i64, millis: u32) -> String {
170    let c = crate::calendar::civil(seconds);
171    format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z", c.year, c.month, c.day, c.hour, c.minute, c.second, millis)
172}
173
174/// `n` unpredictable bytes from the operating system, or, where it offers none, bytes that are
175/// unique to this process and moment: a chain id must not repeat, and needs no secrecy.
176fn random(n: usize) -> Vec<u8> {
177    let mut buf = vec![0u8; n];
178    if File::open("/dev/urandom").and_then(|mut f| f.read_exact(&mut buf)).is_ok() {
179        return buf;
180    }
181    use std::hash::{BuildHasher, Hasher};
182    let d = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default();
183    let mut keyed = std::collections::hash_map::RandomState::new().build_hasher();
184    keyed.write_u128(d.as_nanos());
185    let seed = format!("{}:{}:{:p}:{}", d.as_nanos(), std::process::id(), &buf, keyed.finish());
186    sha256(seed.as_bytes())[..n.min(32)].to_vec()
187}
188
189fn open_new(path: &Path) -> io::Result<File> {
190    let mut o = OpenOptions::new();
191    o.write(true).create_new(true);
192    #[cfg(unix)]
193    {
194        use std::os::unix::fs::OpenOptionsExt;
195        o.mode(0o600);
196    }
197    o.open(path)
198}
199
200fn refuse_link(path: &Path) -> io::Result<()> {
201    match fs::symlink_metadata(path) {
202        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()))),
203        _ => Ok(()),
204    }
205}
206
207/// The evidence directory with runs/ and seals/, refusing a link at any of the three, and a
208/// directory inside a tree the run reads.
209pub fn prepare(dir: &Path, reads: &[PathBuf]) -> io::Result<PathBuf> {
210    let absolute = std::path::absolute(dir)?;
211    let mut existing = absolute.clone();
212    let mut rest = Vec::new();
213    while !existing.exists() {
214        match (existing.file_name().map(|n| n.to_os_string()), existing.parent()) {
215            (Some(name), Some(parent)) => {
216                rest.push(name);
217                existing = parent.to_path_buf();
218            }
219            _ => break,
220        }
221    }
222    let mut intended = fs::canonicalize(&existing)?;
223    for name in rest.iter().rev() {
224        intended.push(name);
225    }
226    for root in reads {
227        let tree = if root.as_os_str().is_empty() { Path::new(".") } else { root };
228        if let Ok(r) = fs::canonicalize(tree)
229            && intended.starts_with(&r)
230        {
231            return Err(io::Error::new(io::ErrorKind::InvalidInput, format!("the evidence directory {} is inside {}, which this run reads", dir.display(), root.display())));
232        }
233    }
234    for p in [absolute.clone(), absolute.join("runs"), absolute.join("seals")] {
235        refuse_link(&p)?;
236        if !p.exists() {
237            fs::create_dir_all(&p)?;
238            #[cfg(unix)]
239            {
240                use std::os::unix::fs::PermissionsExt;
241                fs::set_permissions(&p, fs::Permissions::from_mode(0o700))?;
242            }
243        }
244        refuse_link(&p)?;
245    }
246    let made = fs::canonicalize(&absolute)?;
247    if made != intended {
248        return Err(io::Error::new(io::ErrorKind::InvalidInput, format!("the evidence directory {} resolved to {} while it was made", dir.display(), made.display())));
249    }
250    Ok(made)
251}
252
253struct Chain {
254    chain: String,
255    seq: i64,
256    prev: String,
257}
258
259impl Chain {
260    fn record(&mut self, kind: &str, mut fields: BTreeMap<String, Value>, at: &str) -> io::Result<String> {
261        check(kind, &fields)?;
262        fields.insert("v".into(), Value::Int(1));
263        fields.insert("chain".into(), Value::Str(self.chain.clone()));
264        fields.insert("seq".into(), Value::Int(self.seq));
265        fields.insert("at".into(), Value::Str(at.to_string()));
266        fields.insert("kind".into(), Value::Str(kind.to_string()));
267        fields.insert("prev".into(), Value::Str(self.prev.clone()));
268        let hash = record_hash(&fields);
269        fields.insert("hash".into(), Value::Str(hash.clone()));
270        self.prev = hash;
271        self.seq += 1;
272        Ok(format!("{}\n", canonical(&Value::Obj(fields))))
273    }
274}
275
276pub struct Journal {
277    pub id: String,
278    pub path: PathBuf,
279    dir: PathBuf,
280    file: File,
281    chain: Chain,
282    counts: BTreeMap<String, i64>,
283    started: Instant,
284}
285
286/// Where a closed run's tip went.
287#[derive(Debug, PartialEq, Eq)]
288pub enum Ledger {
289    Recorded,
290    Unrecorded(String),
291}
292
293impl Journal {
294    pub fn create(dir: &Path, reads: &[PathBuf], command: &str, argv: &[String], tool_version: &str) -> io::Result<Self> {
295        let dir = prepare(dir, reads)?;
296        let now = iso_now();
297        let stamp: String = now.chars().filter(|c| c.is_ascii_digit() || *c == 'T').take(15).collect();
298        let id = format!("{stamp}Z-{}", hex(&random(8)));
299        let path = dir.join("runs").join(format!("{id}.jsonl"));
300        let file = open_new(&path)?;
301        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() };
302        let roots = reads.iter().map(|r| Value::Str(r.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default())).collect();
303        journal.append_at(
304            "open",
305            fields([
306                ("tool", "ironwork".into()),
307                ("toolVersion", tool_version.into()),
308                ("command", command.into()),
309                ("argv", Value::Arr(argv.iter().map(|a| Value::Str(a.clone())).collect())),
310                ("roots", Value::Arr(roots)),
311                ("platform", std::env::consts::OS.into()),
312            ]),
313            &now,
314        )?;
315        Ok(journal)
316    }
317
318    fn append_at(&mut self, kind: &str, fields: BTreeMap<String, Value>, at: &str) -> io::Result<()> {
319        let line = self.chain.record(kind, fields, at)?;
320        self.file.write_all(line.as_bytes())?;
321        *self.counts.entry(kind.to_string()).or_default() += 1;
322        Ok(())
323    }
324
325    /// The hash of the last record written.
326    pub fn tip(&self) -> &str {
327        &self.chain.prev
328    }
329
330    pub fn append(&mut self, kind: &str, fields: BTreeMap<String, Value>) -> io::Result<()> {
331        self.append_at(kind, fields, &iso_now())
332    }
333
334    /// Writes close, then the ledger record carrying this journal's tip.
335    pub fn close(mut self, exit: Option<i64>) -> io::Result<Ledger> {
336        let lock = self.dir.join(LOCK);
337        let held = take_lock(&lock);
338        let counts = self.counts.iter().map(|(k, v)| (k.clone(), Value::Int(*v))).collect();
339        let ledger = if held.is_ok() { "recorded" } else { "unrecorded" };
340        let duration = i64::try_from(self.started.elapsed().as_millis()).unwrap_or(i64::MAX);
341        self.append("close", fields([("exit", exit.map_or(Value::Null, Value::Int)), ("counts", Value::Obj(counts)), ("durationMs", Value::Int(duration)), ("ledger", ledger.into())]))?;
342        self.file.sync_all()?;
343        let broken = match held {
344            Ok(broken) => broken,
345            Err(reason) => return Ok(Ledger::Unrecorded(reason)),
346        };
347        let result = append_ledger(&self.dir, &self.id, &self.chain, broken);
348        let _ = fs::remove_file(&lock);
349        match result {
350            Ok(()) => Ok(Ledger::Recorded),
351            Err(e) => Ok(Ledger::Unrecorded(e.to_string())),
352        }
353    }
354}
355
356/// A lock that was broken: the pid it names and its age.
357struct Broken {
358    pid: i64,
359    age_ms: i64,
360}
361
362/// The ledger lock. A lock older than a minute was left by a writer that died holding it, and is
363/// broken, as cobolwork breaks it; the break goes into the ledger.
364fn take_lock(path: &Path) -> Result<Option<Broken>, String> {
365    let mut broken = None;
366    let deadline = Instant::now() + std::time::Duration::from_secs(5);
367    loop {
368        match open_new(path) {
369            Ok(mut f) => {
370                let _ = writeln!(f, "{} {}", std::process::id(), SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis());
371                return Ok(broken);
372            }
373            Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
374                let age = fs::symlink_metadata(path).and_then(|m| m.modified()).ok().and_then(|t| t.elapsed().ok());
375                if let Some(age) = age.filter(|a| *a > std::time::Duration::from_secs(60)) {
376                    let pid = fs::read_to_string(path).ok().and_then(|t| t.split_whitespace().next().and_then(|p| p.parse().ok())).unwrap_or(0);
377                    if fs::remove_file(path).is_ok() {
378                        broken = Some(Broken { pid, age_ms: i64::try_from(age.as_millis()).unwrap_or(i64::MAX) });
379                        continue;
380                    }
381                }
382                if Instant::now() >= deadline {
383                    return Err("the ledger lock could not be had".into());
384                }
385                std::thread::sleep(std::time::Duration::from_millis(25));
386            }
387            Err(e) => return Err(e.to_string()),
388        }
389    }
390}
391
392/// The last line of the ledger, or None for an empty or absent one; an unterminated last line is
393/// refused rather than extended.
394fn ledger_tail(path: &Path) -> io::Result<Option<String>> {
395    // nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- the ledger path the run was given
396    let mut f = match File::open(path) {
397        Ok(f) => f,
398        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
399        Err(e) => return Err(e),
400    };
401    let size = f.metadata()?.len();
402    if size == 0 {
403        return Ok(None);
404    }
405    let window = size.min(65536);
406    f.seek(SeekFrom::Start(size - window))?;
407    let mut buf = Vec::new();
408    f.take(window).read_to_end(&mut buf)?;
409    let text = String::from_utf8_lossy(&buf);
410    let body = text.strip_suffix('\n').ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "ledger.jsonl ends in a partial line"))?;
411    match body.rsplit_once('\n') {
412        Some((_, last)) => Ok(Some(last.to_string())),
413        None if window < size => Err(io::Error::new(io::ErrorKind::InvalidData, "the ledger's last line is longer than any ledger record")),
414        None => Ok(Some(body.to_string())),
415    }
416}
417
418/// The value of a top-level string or integer field in one canonical record line.
419fn field_of(line: &str, key: &str) -> Option<String> {
420    let needle = format!("\"{key}\":");
421    let at = line.find(&needle)? + needle.len();
422    let rest = &line[at..];
423    if let Some(s) = rest.strip_prefix('"') {
424        return s.find('"').map(|end| s[..end].to_string());
425    }
426    Some(rest.chars().take_while(|c| c.is_ascii_digit()).collect())
427}
428
429fn append_ledger(dir: &Path, run: &str, journal: &Chain, broken: Option<Broken>) -> io::Result<()> {
430    let path = dir.join(LEDGER);
431    refuse_link(&path)?;
432    let mut chain = match ledger_tail(&path)? {
433        Some(line) => {
434            let bad = || io::Error::new(io::ErrorKind::InvalidData, "the ledger's last line is not a ledger record");
435            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)));
436            let seq: i64 = field_of(&line, "seq").and_then(|s| s.parse().ok()).ok_or_else(bad)?;
437            Chain { chain: hex_of("chain", 32).ok_or_else(bad)?, seq: seq + 1, prev: hex_of("hash", 64).ok_or_else(bad)? }
438        }
439        None => Chain { chain: hex(&random(16)), seq: 0, prev: ZERO.into() },
440    };
441    let mut out = String::new();
442    let now = iso_now();
443    if chain.seq == 0 {
444        out.push_str(&chain.record("genesis", fields([("createdAt", now.clone().into())]), &now)?);
445    }
446    if let Some(b) = broken {
447        out.push_str(&chain.record("lock-broken", fields([("holderPid", Value::Int(b.pid)), ("ageMs", Value::Int(b.age_ms))]), &now)?);
448    }
449    out.push_str(&chain.record(
450        "run",
451        fields([("run", run.into()), ("runChain", journal.chain.clone().into()), ("runLength", Value::Int(journal.seq)), ("runTip", journal.prev.clone().into())]),
452        &now,
453    )?);
454    let mut f = OpenOptions::new().append(true).create(true).open(&path)?;
455    f.write_all(out.as_bytes())?;
456    f.sync_all()
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462
463    fn temp() -> PathBuf {
464        let p = std::env::temp_dir().join(format!("iw-evidence-{}", hex(&random(6))));
465        fs::create_dir_all(&p).unwrap();
466        p
467    }
468
469    #[test]
470    fn canonical_form_matches_javascript() {
471        let v = Value::Obj(fields([("b", Value::Int(2)), ("a", "q\"\\\n\u{1}é".into()), ("Z", Value::Arr(vec![Value::Null, Value::Bool(true)]))]));
472        assert_eq!(canonical(&v), "{\"Z\":[null,true],\"a\":\"q\\\"\\\\\\n\\u0001é\",\"b\":2}");
473    }
474
475    #[test]
476    fn the_hash_is_over_the_domain_tag_and_the_record_without_its_hash() {
477        let r = fields([("kind", "abend".into()), ("code", "S0C7".into()), ("hash", "ignored".into())]);
478        assert_eq!(record_hash(&r), hex(&sha256(b"cobolwork-evidence/v1\n{\"code\":\"S0C7\",\"kind\":\"abend\"}")));
479    }
480
481    #[test]
482    fn a_closed_journal_chains_and_reaches_the_ledger() {
483        let dir = temp();
484        let mut j = Journal::create(&dir.join("ev"), &[], "run", &["run".into()], "0.0.0").unwrap();
485        j.append("dd", fields([("dd", "IN".into()), ("event", "open".into()), ("mode", "INPUT".into()), ("sha256", hex(&sha256(b"x")).into()), ("bytes", Value::Int(1))])).unwrap();
486        let path = j.path.clone();
487        let id = j.id.clone();
488        assert_eq!(j.close(Some(0)).unwrap(), Ledger::Recorded);
489        let lines: Vec<String> = fs::read_to_string(&path).unwrap().lines().map(String::from).collect();
490        assert_eq!(lines.len(), 3);
491        let mut prev = ZERO.to_string();
492        for (i, line) in lines.iter().enumerate() {
493            assert_eq!(field_of(line, "seq").unwrap(), i.to_string());
494            assert_eq!(field_of(line, "prev").unwrap(), prev);
495            prev = field_of(line, "hash").unwrap();
496        }
497        let ledger = fs::read_to_string(dir.join("ev").join(LEDGER)).unwrap();
498        let run = ledger.lines().last().unwrap();
499        assert_eq!(field_of(run, "run").unwrap(), id);
500        assert_eq!(field_of(run, "runTip").unwrap(), prev);
501        assert_eq!(field_of(run, "runLength").unwrap(), "3");
502        fs::remove_dir_all(dir).unwrap();
503    }
504
505    #[test]
506    fn an_unknown_field_is_refused() {
507        let dir = temp();
508        let mut j = Journal::create(&dir.join("ev"), &[], "run", &[], "0.0.0").unwrap();
509        assert!(j.append("dd", fields([("dd", "IN".into()), ("event", "open".into()), ("record", "SECRET".into())])).is_err());
510        fs::remove_dir_all(dir).unwrap();
511    }
512
513    #[test]
514    fn an_evidence_directory_inside_a_tree_read_is_refused() {
515        let dir = temp();
516        assert!(prepare(&dir.join("src").join("ev"), std::slice::from_ref(&dir)).is_err());
517        assert!(!dir.join("src").exists());
518        fs::remove_dir_all(dir).unwrap();
519    }
520
521    fn closed(ev: &Path) -> Ledger {
522        Journal::create(ev, &[], "run", &[], "0.0.0").unwrap().close(Some(0)).unwrap()
523    }
524
525    #[test]
526    fn a_lock_left_by_a_dead_writer_is_broken_after_a_minute() {
527        let dir = temp();
528        let ev = dir.join("ev");
529        prepare(&ev, &[]).unwrap();
530        let lock = File::create(ev.join(LOCK)).unwrap();
531        lock.set_modified(SystemTime::now() - std::time::Duration::from_secs(120)).unwrap();
532        drop(lock);
533        assert_eq!(closed(&ev), Ledger::Recorded);
534        assert!(!ev.join(LOCK).exists());
535        let kinds: Vec<String> = fs::read_to_string(ev.join(LEDGER)).unwrap().lines().map(|l| field_of(l, "kind").unwrap()).collect();
536        assert_eq!(kinds, ["genesis", "lock-broken", "run"]);
537        fs::remove_dir_all(dir).unwrap();
538    }
539
540    #[test]
541    fn a_ledger_whose_last_line_is_no_ledger_record_is_not_extended() {
542        let dir = temp();
543        let ev = dir.join("ev");
544        prepare(&ev, &[]).unwrap();
545        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))] {
546            fs::write(ev.join(LEDGER), &tail).unwrap();
547            assert!(matches!(closed(&ev), Ledger::Unrecorded(_)), "{}", &tail[..20]);
548            assert_eq!(fs::read_to_string(ev.join(LEDGER)).unwrap(), tail);
549        }
550        fs::remove_dir_all(dir).unwrap();
551    }
552}