Skip to main content

blotter/commands/
add.rs

1use crate::cli::AddArgs;
2use crate::error::{AppError, AppResult};
3use crate::output::{self, Meta};
4use crate::redact::{evidence_delimiter, rewrite_home_paths};
5use crate::store;
6use crate::{Evidence, LogEvent, Origin, compute_id, format_timestamp, resolve_agent_checked};
7use jiff::Timestamp;
8use serde::{Deserialize, Serialize};
9use std::collections::HashSet;
10#[cfg(not(unix))]
11use std::fs::File;
12#[cfg(unix)]
13use std::fs::OpenOptions;
14use std::io::{IsTerminal, Read};
15#[cfg(unix)]
16use std::os::unix::fs::OpenOptionsExt;
17use std::path::{Path, PathBuf};
18
19/// What the secret pass writes in place of a redacted span. Load-bearing on
20/// both sides of the redactor/scanner mirror: `commands::doctor` accepts it
21/// behind a generic home prefix (r41), so the two must stay in sync.
22pub(crate) const SECRET_MARKER: &str = "<redacted>";
23
24const STDERR_INPUT_LIMIT: u64 = 1024 * 1024;
25const STDIN_INPUT_LIMIT: u64 = 1024 * 1024;
26
27#[derive(Debug, Serialize, Deserialize)]
28pub struct AddData {
29    pub changed: bool,
30    pub record: LogEvent,
31}
32
33pub fn run(args: AddArgs, file: Option<PathBuf>, pretty: bool, now: Timestamp) -> AppResult<i32> {
34    let resolved = store::discover(file)?;
35    let home = store::home_dir(&resolved.cwd);
36    let evidence = build_evidence(&args, home.as_deref())?;
37    let text = rewrite_home_paths(&read_text(args.text, "cut", "add")?, home.as_deref());
38    validate_text(&text, "cut")?;
39    let (agent, source) = resolve_agent_checked(args.agent, true)?;
40    let mut tags = args.tags;
41    tags.sort();
42    tags.dedup();
43    let mut warnings = resolved.warnings.clone();
44    let ts = format_timestamp(now);
45    let supplied_evidence = evidence.is_some();
46    let resolution_text =
47        text.trim_start().starts_with("RESOLUTION") || text.trim_start().starts_with("RESOLVED");
48    let record = LogEvent::Cut {
49        id: compute_id(&ts, &agent, &text, args.impact, &tags),
50        ts,
51        agent,
52        text,
53        tags,
54        impact: args.impact,
55        cwd: store::record_cwd(&resolved.cwd, resolved.cwd_repo(), home.as_deref()),
56        origin: Some(Origin::agent()),
57        evidence,
58    };
59    if resolution_text {
60        warnings.push(
61            "resolution_text: this looks like a resolution; use `blotter resolve <id>` for an existing cut".into(),
62        );
63    }
64
65    let (changed, record) = store::append_unique(&resolved.path, record, args.dry_run)?;
66    if args.dry_run {
67        warnings.push("dry run; no record appended".into());
68    } else if !changed {
69        warnings.push(
70            if supplied_evidence {
71                "duplicate_cut: existing record returned; later evidence was not stored"
72            } else {
73                "duplicate cut; existing record returned"
74            }
75            .into(),
76        );
77    }
78    let mut meta = Meta::new();
79    meta.file = Some(resolved.path.to_string_lossy().into_owned());
80    meta.agent_source = Some(source.into());
81    meta.warnings = warnings;
82    output::write_success(AddData { changed, record }, pretty, meta)
83        .map_err(|error| AppError::from_io(error, std::path::Path::new("stdout")))?;
84    Ok(0)
85}
86
87fn build_evidence(args: &AddArgs, home: Option<&Path>) -> AppResult<Option<Evidence>> {
88    let stderr = args.stderr_file.as_deref().map(read_stderr).transpose()?;
89    if args.cmd.is_none() && args.exit_code.is_none() && stderr.is_none() && args.evidence.is_none()
90    {
91        return Ok(None);
92    }
93    Ok(Some(Evidence {
94        cmd: args
95            .cmd
96            .as_deref()
97            .map(|value| redact_evidence(value, home)),
98        exit: args.exit_code,
99        stderr: stderr.map(|value| redact_and_truncate(&value, 4096, home)),
100        note: args
101            .evidence
102            .as_deref()
103            .map(|value| redact_evidence(value, home)),
104    }))
105}
106
107fn read_stderr(path: &std::path::Path) -> AppResult<String> {
108    // Opening first makes the handle, rather than a path lookup, the object we validate.
109    // OpenOptions follows symlinks, preserving the accepted symlink-to-regular-file policy.
110    #[cfg(unix)]
111    let mut file = OpenOptions::new()
112        .read(true)
113        .custom_flags(libc::O_NONBLOCK)
114        .open(path)
115        .map_err(|error| AppError::from_evidence_file(error, path))?;
116    #[cfg(not(unix))]
117    let mut file = File::open(path).map_err(|error| AppError::from_evidence_file(error, path))?;
118    let metadata = file
119        .metadata()
120        .map_err(|error| AppError::from_evidence_file(error, path))?;
121    if !metadata.is_file() {
122        return Err(AppError::invalid_input(
123            format!(
124                "stderr evidence path is not a regular file: {}",
125                path.display()
126            ),
127            "Pass a regular UTF-8 file to --stderr-file PATH; FIFOs and devices are not accepted.",
128        ));
129    }
130    if metadata.len() > STDERR_INPUT_LIMIT {
131        return Err(AppError::invalid_input(
132            format!(
133                "stderr evidence file exceeds the {}-byte read limit: {}",
134                STDERR_INPUT_LIMIT,
135                path.display()
136            ),
137            "Pass a smaller stderr file to --stderr-file PATH; stored sanitized stderr is capped at 4096 bytes.",
138        ));
139    }
140    let mut bytes = Vec::new();
141    file.by_ref()
142        .take(STDERR_INPUT_LIMIT + 1)
143        .read_to_end(&mut bytes)
144        .map_err(|error| AppError::from_evidence_file(error, path))?;
145    if bytes.len() > STDERR_INPUT_LIMIT as usize {
146        return Err(AppError::invalid_input(
147            format!(
148                "stderr evidence file exceeds the {}-byte read limit: {}",
149                STDERR_INPUT_LIMIT,
150                path.display()
151            ),
152            "Pass a smaller stderr file to --stderr-file PATH; stored sanitized stderr is capped at 4096 bytes.",
153        ));
154    }
155    String::from_utf8(bytes).map_err(|_| {
156        AppError::invalid_input(
157            format!("stderr file is not valid UTF-8: {}", path.display()),
158            "Pass a UTF-8 stderr file with --stderr-file PATH.",
159        )
160    })
161}
162
163pub(crate) fn redact_and_truncate(value: &str, max_bytes: usize, home: Option<&Path>) -> String {
164    let (redacted, markers) = redact_evidence_marked(value, home);
165    if redacted.len() <= max_bytes {
166        return redacted;
167    }
168    // r46: the cut manufactures an end-of-input boundary the home pass never
169    // judged, promoting a declined exact home into a match after the only pass
170    // is over. Re-run the home pass over exactly the bytes about to be stored.
171    // Only when the cap cuts: the pass is not idempotent, so running it on
172    // untruncated text would spell one input two ways across the evidence lanes.
173    rewrite_home_paths(&truncate_utf8(&redacted, max_bytes, &markers), home)
174}
175
176fn truncate_utf8(value: &str, max_bytes: usize, markers: &[(usize, usize)]) -> String {
177    if value.len() <= max_bytes {
178        return value.to_owned();
179    }
180    let mut end = max_bytes;
181    while !value.is_char_boundary(end) {
182        end -= 1;
183    }
184    // The cap is applied after redaction (r25), so the cut can land inside a
185    // marker the secret pass wrote and store a partial `<redacted>` — a
186    // component no decoded acceptance holds, and none can hold it without
187    // blinding the gate to a real `/Users/~<reda` (r43). Back the cut up to the
188    // span's start instead. The key is provenance, not spelling: authentic
189    // evidence that merely ends `~<red` was written by nobody's marker and
190    // keeps its bytes. At most nine further bytes are dropped, and only here.
191    if let Some((start, _)) = markers
192        .iter()
193        .find(|(start, marker_end)| *start < end && end < *marker_end)
194    {
195        end = *start;
196    }
197    value[..end].to_owned()
198}
199
200const SENSITIVE_KEYS: &str = "accesskey apikey authorization authtoken bearer clientsecret dbpassword key passwd password secret token";
201
202fn word(s: &str, i: usize) -> bool {
203    s.as_bytes()
204        .get(i)
205        .is_some_and(|b| b.is_ascii_alphanumeric())
206}
207
208fn assignment_value_span(input: &str, end: usize) -> Option<(usize, usize)> {
209    let rest = input[end..].trim_start_matches('"').trim_start();
210    let separator = rest.chars().next().filter(|c| matches!(c, '=' | ':'))?;
211    let rest = rest[separator.len_utf8()..].trim_start();
212    let rest = rest.trim_start_matches(['"', '\'']);
213    let start = input.len() - rest.len();
214    let end = rest
215        .find(|character: char| evidence_delimiter(character))
216        .map_or(input.len(), |offset| start + offset);
217    (start < end).then_some((start, end))
218}
219
220fn extend_one_token(input: &str, end: usize) -> usize {
221    let rest = &input[end..];
222    let trimmed = rest.trim_start_matches([' ', '\t']);
223    if trimmed.len() == rest.len() || trimmed.is_empty() {
224        return end;
225    }
226    let start = input.len() - trimmed.len();
227    trimmed
228        .find(|character: char| evidence_delimiter(character))
229        .map_or(input.len(), |offset| start + offset)
230}
231
232pub(crate) fn redact_evidence(input: &str, home: Option<&Path>) -> String {
233    redact_evidence_marked(input, home).0
234}
235
236/// `redact_evidence` plus the output spans at which the secret pass wrote
237/// `SECRET_MARKER`. Only the truncating stderr lane needs the spans, so the
238/// plumbing stays here and `dogear`/`resolve` keep the plain wrapper.
239fn redact_evidence_marked(input: &str, home: Option<&Path>) -> (String, Vec<(usize, usize)>) {
240    let rewritten = rewrite_home_paths(input, home);
241    let input = rewritten.as_str();
242    let lower = input.to_ascii_lowercase();
243    let mut spans = Vec::new();
244    for key in SENSITIVE_KEYS.split_ascii_whitespace() {
245        for (start, _) in lower.match_indices(key) {
246            let end = start + key.len();
247            if word(input, start.wrapping_sub(1)) || word(input, end) {
248                continue;
249            }
250            if let Some((value_start, value_end)) = assignment_value_span(input, end) {
251                // "Authorization: Bearer <credential>": the first token is the
252                // scheme; the credential follows it. Cover one more token so the
253                // secret, not just the scheme word, is redacted.
254                let value_end = if key == "authorization" {
255                    extend_one_token(input, value_end)
256                } else {
257                    value_end
258                };
259                spans.push((value_start, value_end));
260            }
261        }
262    }
263    for (start, scheme) in lower
264        .match_indices("http://")
265        .chain(lower.match_indices("https://"))
266    {
267        let authority_start = start + scheme.len();
268        let authority_end = input[authority_start..]
269            .find(|character: char| "/?#\"' \t\r\n".contains(character))
270            .map_or(input.len(), |offset| authority_start + offset);
271        if let Some(at) = input[authority_start..authority_end].rfind('@') {
272            spans.push((authority_start, authority_start + at));
273        }
274    }
275    let mut token_start = None;
276    for (end, character) in input
277        .char_indices()
278        .chain(std::iter::once((input.len(), ' ')))
279    {
280        if character.is_ascii_alphanumeric() || "_-./+=".contains(character) {
281            token_start.get_or_insert(end);
282            continue;
283        }
284        let Some(start) = token_start.take() else {
285            continue;
286        };
287        let token = &input[start..end];
288        let unique = token.bytes().collect::<HashSet<_>>().len();
289        let mixed = token.bytes().any(|byte| byte.is_ascii_lowercase())
290            && token.bytes().any(|byte| byte.is_ascii_uppercase())
291            && token.bytes().any(|byte| byte.is_ascii_digit());
292        if token.len() >= 24 && unique >= 12 && mixed {
293            spans.push((start, end));
294        }
295    }
296    spans.sort_unstable();
297    // Merge overlaps before emitting: dropping an overlapping span whole would
298    // leak the part of a secret that extends past the previous span's end.
299    let mut merged: Vec<(usize, usize)> = Vec::new();
300    for (start, end) in spans {
301        match merged.last_mut() {
302            Some((_, last_end)) if start <= *last_end => *last_end = (*last_end).max(end),
303            _ => merged.push((start, end)),
304        }
305    }
306    let mut output = String::with_capacity(input.len());
307    let mut markers = Vec::with_capacity(merged.len());
308    let mut cursor = 0;
309    for (start, end) in merged {
310        output.push_str(&input[cursor..start]);
311        let marker_start = output.len();
312        output.push_str(SECRET_MARKER);
313        markers.push((marker_start, output.len()));
314        cursor = end;
315    }
316    output.push_str(&input[cursor..]);
317    (output, markers)
318}
319
320pub(crate) fn read_text(
321    text: Option<String>,
322    record_name: &str,
323    command_name: &str,
324) -> AppResult<String> {
325    let use_stdin =
326        text.as_deref() == Some("-") || (text.is_none() && !std::io::stdin().is_terminal());
327    let mut text = if use_stdin {
328        // r25 validates the *redacted* text, so `validate_text`'s 10000-byte
329        // limit cannot gate the raw read: oversized input that redacts below it
330        // is accepted. Gate this lane at the sibling stdin/stderr scale instead,
331        // so an endless producer cannot grow the buffer without bound.
332        //
333        // The budget is on bytes *read*, so it is measured before the trailing
334        // newline is trimmed, exactly as `--stderr-file` measures the whole
335        // file. Trimming first would leave a hole at the boundary: a stream of
336        // exactly the limit, then a newline, then more data fills the reader to
337        // the cap, and the trim would drop that newline back to the limit and
338        // accept — silently discarding everything the reader never reached.
339        // Test length before decoding, too: a stream cut mid-codepoint at the
340        // cap would otherwise report a misleading UTF-8 error.
341        let mut input = Vec::new();
342        std::io::stdin()
343            .lock()
344            .take(STDIN_INPUT_LIMIT + 1)
345            .read_to_end(&mut input)
346            .map_err(|error| AppError::from_io(error, std::path::Path::new("stdin")))?;
347        if input.len() > STDIN_INPUT_LIMIT as usize {
348            return Err(AppError::invalid_input(
349                format!(
350                    "{record_name} text from stdin exceeds the {STDIN_INPUT_LIMIT}-byte read limit"
351                ),
352                format!("Pipe at most {STDIN_INPUT_LIMIT} bytes to `blotter {command_name} -`."),
353            ));
354        }
355        String::from_utf8(input).map_err(|_| {
356            AppError::invalid_input(
357                format!("{record_name} text from stdin is not valid UTF-8"),
358                format!("Pipe UTF-8 text to `blotter {command_name} -`."),
359            )
360        })?
361    } else {
362        text.ok_or_else(|| {
363            AppError::invalid_argument(
364                format!("{command_name} requires TEXT when stdin is a terminal"),
365                format!(
366                    "Run `blotter {command_name} \"text\"` or pipe text to `blotter {command_name} -`."
367                ),
368            )
369        })?
370    };
371    while text.ends_with('\n') || text.ends_with('\r') {
372        text.pop();
373    }
374    Ok(text)
375}
376
377pub(crate) fn validate_text(text: &str, record_name: &str) -> AppResult<()> {
378    if text.trim().is_empty() {
379        return Err(AppError::invalid_input(
380            format!("{record_name} text cannot be empty or whitespace-only"),
381            "Pass non-empty TEXT or pipe it on stdin.",
382        ));
383    }
384    if text.len() > 10_000 {
385        return Err(AppError::invalid_input(
386            format!(
387                "{record_name} text is {} bytes; the maximum is 10000",
388                text.len()
389            ),
390            format!("Shorten the {record_name} text to at most 10000 UTF-8 bytes."),
391        ));
392    }
393    Ok(())
394}