Skip to main content

strop_trace/
export.rs

1//! Readers over a finished trace file: the privacy-preserving metadata
2//! export and the forensic node extractor. Both enforce the same physical
3//! contract the writer guarantees — schema, contiguous sequence, a single
4//! terminal marker — and never trust a truncated file.
5use std::io::{self, BufRead, Read, Write};
6
7use serde::Deserialize;
8use serde_json::Value;
9
10use crate::{EventKind, MAX_CAPTURE_BYTES, MAX_RECORD_BYTES, SCHEMA_VERSION};
11
12/// One physical trace line as written by the writer.
13#[derive(Deserialize)]
14pub struct Row {
15    pub schema_version: u32,
16    pub seq: u64,
17    pub event: EventKind,
18    pub fields: Value,
19}
20
21/// Bounded line size for the reader: a physical line may never exceed the
22/// writer's per-record cap plus the envelope the writer adds around it.
23const LINE_LIMIT: usize = MAX_RECORD_BYTES + 1024;
24/// Reader total: the writer's hard capture bound. A bigger "trace file"
25/// is not a trace this crate ever produced.
26const TOTAL_LIMIT: usize = MAX_CAPTURE_BYTES;
27
28/// Stream the rows of a trace. Verifies schema, sequence continuity, a
29/// single terminal `TraceEnd` and bounded size. Returns whether the file
30/// ended complete (`TraceEnd` present with `complete: true`).
31///
32/// Schema 2 and 3 are both accepted (homogeneously per file). Schema-3
33/// chunk carriers are reassembled before delivery: visitors see logical
34/// records only, an assembled record keeps its first chunk's sequence, and
35/// every corruption of a chunk run is an explicit error. A run abandoned
36/// by the stream makes the trace incomplete — an error when the terminal
37/// marker claims otherwise — and its partial bytes are never delivered.
38pub fn scan(
39    mut input: impl BufRead,
40    mut visit: impl FnMut(Row) -> io::Result<()>,
41) -> io::Result<bool> {
42    let (mut total, mut seq, mut ended, mut complete) = (0usize, 1u64, false, false);
43    let mut version: Option<u32> = None;
44    let mut chunks = crate::chunk::Assembler::new();
45    loop {
46        let mut bytes = Vec::new();
47        let read = Read::by_ref(&mut input)
48            .take(LINE_LIMIT as u64 + 1)
49            .read_until(b'\n', &mut bytes)?;
50        if read == 0 {
51            break;
52        }
53        total = total
54            .checked_add(read)
55            .ok_or_else(|| io::Error::other("input limit"))?;
56        if read > LINE_LIMIT || total > TOTAL_LIMIT || bytes.last() != Some(&b'\n') {
57            return Err(io::Error::other("trace truncated or exceeds input limit"));
58        }
59        if ended {
60            return Err(io::Error::other("records after trace terminal"));
61        }
62        let row: Row =
63            serde_json::from_slice(&bytes).map_err(|_| io::Error::other("invalid trace record"))?;
64        // Schemas 2 and 3 decode identically apart from chunk records, and
65        // a file never mixes versions.
66        if version.is_none() {
67            version = Some(row.schema_version);
68        }
69        let supported = row.schema_version == SCHEMA_VERSION || row.schema_version == 2;
70        if !supported || version != Some(row.schema_version) || row.seq != seq {
71            return Err(io::Error::other("unsupported schema or missing sequence"));
72        }
73        seq = seq
74            .checked_add(1)
75            .ok_or_else(|| io::Error::other("sequence overflow"))?;
76        if row.event == EventKind::ReplayChunk {
77            if row.schema_version < 3 {
78                return Err(io::Error::other("chunk record in pre-chunk schema"));
79            }
80            if let Some((first, event, fields)) = chunks.accept(row.seq, row.fields)? {
81                visit(Row {
82                    schema_version: row.schema_version,
83                    seq: first,
84                    event,
85                    fields,
86                })?;
87            }
88            continue;
89        }
90        if row.event == EventKind::TraceEnd {
91            ended = true;
92            complete = row.fields["complete"] == true;
93            if complete && chunks.is_open() {
94                return Err(io::Error::other(
95                    "complete trace cannot abandon a chunk run",
96                ));
97            }
98        }
99        visit(row)?;
100    }
101    Ok(ended && complete)
102}
103
104/// Metadata export: a POSITIVE projection of the trace. Only the sequence
105/// number and the closed `EventKind` category survive — no keys, paste or
106/// file text, paths or native byte arrays, argv, command strings, messages,
107/// errors, backtraces, protocol packets, identities, content hashes or
108/// arbitrary nested fields. It records that categories occurred, nothing
109/// else, and is explicitly not replayable.
110pub fn metadata(input: impl BufRead, mut out: impl Write) -> io::Result<()> {
111    writeln!(
112        out,
113        "{{\"schema\":\"strop-metadata-export-v1\",\"replayable\":false}}"
114    )?;
115    let complete = scan(input, |row| {
116        serde_json::to_writer(
117            &mut out,
118            &serde_json::json!({"seq": row.seq, "category": row.event}),
119        )?;
120        out.write_all(b"\n")
121    })?;
122    serde_json::to_writer(
123        &mut out,
124        &serde_json::json!({"export_end":true,"source_complete":complete,"replayable":false}),
125    )?;
126    out.write_all(b"\n")
127}
128
129/// Extract the forensic node stream. Requires a complete full-content
130/// capture — a capped, failed or metadata trace is never replayed as a
131/// valid prefix — and the nodes must run `Seed`…`End`.
132pub fn replay_nodes(input: impl BufRead) -> io::Result<Vec<crate::replay::Node>> {
133    let mut nodes = Vec::new();
134    let mut full = false;
135    let complete = scan(input, |row| {
136        if row.seq == 1 {
137            full = row.event == EventKind::SessionStart && row.fields["full_content"] == true;
138        }
139        if row.event == EventKind::Replay {
140            nodes.push(
141                serde_json::from_value(row.fields)
142                    .map_err(|_| io::Error::other("invalid forensic record"))?,
143            );
144        }
145        Ok(())
146    })?;
147    if !full || !complete {
148        return Err(io::Error::other(
149            "full replay requires complete full-content capture",
150        ));
151    }
152    if !matches!(nodes.first(), Some(crate::replay::Node::Seed { .. }))
153        || !matches!(nodes.last(), Some(crate::replay::Node::End))
154    {
155        return Err(io::Error::other("missing forensic seed or end"));
156    }
157    Ok(nodes)
158}