Skip to main content

blotter/commands/
add.rs

1use crate::cli::AddArgs;
2use crate::error::{AppError, AppResult};
3use crate::output::{self, Meta};
4use crate::store;
5use crate::{Evidence, LogEvent, compute_id, format_timestamp, resolve_agent_checked};
6use jiff::Timestamp;
7use serde::{Deserialize, Serialize};
8use std::collections::HashSet;
9#[cfg(not(unix))]
10use std::fs::File;
11#[cfg(unix)]
12use std::fs::OpenOptions;
13use std::io::{IsTerminal, Read};
14#[cfg(unix)]
15use std::os::unix::fs::OpenOptionsExt;
16use std::path::{Path, PathBuf};
17
18const STDERR_INPUT_LIMIT: u64 = 1024 * 1024;
19
20#[derive(Debug, Serialize, Deserialize)]
21pub struct AddData {
22    pub changed: bool,
23    pub record: LogEvent,
24}
25
26pub fn run(args: AddArgs, file: Option<PathBuf>, pretty: bool, now: Timestamp) -> AppResult<i32> {
27    let resolved = store::discover(file)?;
28    let cwd = std::env::current_dir()
29        .map_err(|error| AppError::from_io(error, std::path::Path::new(".")))?;
30    let home = store::home_dir(&cwd);
31    let evidence = build_evidence(&args, home.as_deref())?;
32    let text = rewrite_home_paths(&read_text(args.text, "cut", "add")?, home.as_deref());
33    validate_text(&text, "cut")?;
34    let (agent, source) = resolve_agent_checked(args.agent, true)?;
35    let mut tags = args.tags;
36    tags.sort();
37    tags.dedup();
38    let mut warnings = resolved.warnings.clone();
39    let ts = format_timestamp(now);
40    let supplied_evidence = evidence.is_some();
41    let resolution_text =
42        text.trim_start().starts_with("RESOLUTION") || text.trim_start().starts_with("RESOLVED");
43    let record = LogEvent::Cut {
44        id: compute_id(&ts, &agent, &text, args.severity, &tags),
45        ts,
46        agent,
47        text,
48        tags,
49        severity: args.severity,
50        cwd: store::record_cwd(&cwd, resolved.cwd_repo(), home.as_deref()),
51        source: None,
52        evidence,
53    };
54    if resolution_text {
55        warnings.push(
56            "resolution_text: this looks like a resolution; use `blotter resolve <id>` for an existing cut".into(),
57        );
58    }
59
60    let (changed, record) = store::append_unique(&resolved.path, record, args.dry_run)?;
61    if args.dry_run {
62        warnings.push("dry run; no record appended".into());
63    } else if !changed {
64        warnings.push(
65            if supplied_evidence {
66                "duplicate_cut: existing record returned; later evidence was not stored"
67            } else {
68                "duplicate cut; existing record returned"
69            }
70            .into(),
71        );
72    }
73    let mut meta = Meta::new();
74    meta.file = Some(resolved.path.to_string_lossy().into_owned());
75    meta.agent_source = Some(source.into());
76    meta.warnings = warnings;
77    output::write_success(AddData { changed, record }, pretty, meta)
78        .map_err(|error| AppError::from_io(error, std::path::Path::new("stdout")))?;
79    Ok(0)
80}
81
82fn build_evidence(args: &AddArgs, home: Option<&Path>) -> AppResult<Option<Evidence>> {
83    let stderr = args.stderr_file.as_deref().map(read_stderr).transpose()?;
84    if args.cmd.is_none() && args.exit_code.is_none() && stderr.is_none() && args.evidence.is_none()
85    {
86        return Ok(None);
87    }
88    Ok(Some(Evidence {
89        cmd: args
90            .cmd
91            .as_deref()
92            .map(|value| redact_evidence(value, home)),
93        exit: args.exit_code,
94        stderr: stderr.map(|value| redact_and_truncate(&value, 4096, home)),
95        note: args
96            .evidence
97            .as_deref()
98            .map(|value| redact_evidence(value, home)),
99    }))
100}
101
102fn read_stderr(path: &std::path::Path) -> AppResult<String> {
103    // Opening first makes the handle, rather than a path lookup, the object we validate.
104    // OpenOptions follows symlinks, preserving the accepted symlink-to-regular-file policy.
105    #[cfg(unix)]
106    let mut file = OpenOptions::new()
107        .read(true)
108        .custom_flags(libc::O_NONBLOCK)
109        .open(path)
110        .map_err(|error| AppError::from_evidence_file(error, path))?;
111    #[cfg(not(unix))]
112    let mut file = File::open(path).map_err(|error| AppError::from_evidence_file(error, path))?;
113    let metadata = file
114        .metadata()
115        .map_err(|error| AppError::from_evidence_file(error, path))?;
116    if !metadata.is_file() {
117        return Err(AppError::invalid_input(
118            format!(
119                "stderr evidence path is not a regular file: {}",
120                path.display()
121            ),
122            "Pass a regular UTF-8 file to --stderr-file PATH; FIFOs and devices are not accepted.",
123        ));
124    }
125    if metadata.len() > STDERR_INPUT_LIMIT {
126        return Err(AppError::invalid_input(
127            format!(
128                "stderr evidence file exceeds the {}-byte read limit: {}",
129                STDERR_INPUT_LIMIT,
130                path.display()
131            ),
132            "Pass a smaller stderr file to --stderr-file PATH; stored sanitized stderr is capped at 4096 bytes.",
133        ));
134    }
135    let mut bytes = Vec::new();
136    file.by_ref()
137        .take(STDERR_INPUT_LIMIT + 1)
138        .read_to_end(&mut bytes)
139        .map_err(|error| AppError::from_evidence_file(error, path))?;
140    if bytes.len() > STDERR_INPUT_LIMIT as usize {
141        return Err(AppError::invalid_input(
142            format!(
143                "stderr evidence file exceeds the {}-byte read limit: {}",
144                STDERR_INPUT_LIMIT,
145                path.display()
146            ),
147            "Pass a smaller stderr file to --stderr-file PATH; stored sanitized stderr is capped at 4096 bytes.",
148        ));
149    }
150    String::from_utf8(bytes).map_err(|_| {
151        AppError::invalid_input(
152            format!("stderr file is not valid UTF-8: {}", path.display()),
153            "Pass a UTF-8 stderr file with --stderr-file PATH.",
154        )
155    })
156}
157
158pub(crate) fn redact_and_truncate(value: &str, max_bytes: usize, home: Option<&Path>) -> String {
159    truncate_utf8(&redact_evidence(value, home), max_bytes)
160}
161
162fn truncate_utf8(value: &str, max_bytes: usize) -> String {
163    if value.len() <= max_bytes {
164        return value.to_owned();
165    }
166    let mut end = max_bytes;
167    while !value.is_char_boundary(end) {
168        end -= 1;
169    }
170    value[..end].to_owned()
171}
172
173const SENSITIVE_KEYS: &str = "accesskey apikey authorization authtoken bearer clientsecret dbpassword key passwd password secret token";
174// Keep this token-boundary class mirrored in `commands::doctor` for raw leak scans.
175// A slash is a path parent, not a delimiter.
176const EVIDENCE_DELIMITERS: &str = ",;)]}&#\"'";
177// Home-path prefixes, in slash form and in the dash-encoded form that harness
178// scratchpad and session slugs embed, such as `-Users-<name>-<repo>`.
179const HOME_PREFIXES: [&str; 4] = ["/Users/", "/home/", "-Users-", "-home-"];
180
181fn word(s: &str, i: usize) -> bool {
182    s.as_bytes()
183        .get(i)
184        .is_some_and(|b| b.is_ascii_alphanumeric())
185}
186
187fn evidence_delimiter(character: char) -> bool {
188    character.is_ascii_whitespace() || EVIDENCE_DELIMITERS.contains(character)
189}
190
191fn assignment_value_span(input: &str, end: usize) -> Option<(usize, usize)> {
192    let rest = input[end..].trim_start_matches('"').trim_start();
193    let separator = rest.chars().next().filter(|c| matches!(c, '=' | ':'))?;
194    let rest = rest[separator.len_utf8()..].trim_start();
195    let rest = rest.trim_start_matches(['"', '\'']);
196    let start = input.len() - rest.len();
197    let end = rest
198        .find(|character: char| evidence_delimiter(character))
199        .map_or(input.len(), |offset| start + offset);
200    (start < end).then_some((start, end))
201}
202
203fn extend_one_token(input: &str, end: usize) -> usize {
204    let rest = &input[end..];
205    let trimmed = rest.trim_start_matches([' ', '\t']);
206    if trimmed.len() == rest.len() || trimmed.is_empty() {
207        return end;
208    }
209    let start = input.len() - trimmed.len();
210    trimmed
211        .find(|character: char| evidence_delimiter(character))
212        .map_or(input.len(), |offset| start + offset)
213}
214
215fn path_prefix_boundary(input: &str, end: usize, separator: char) -> bool {
216    input[end..].chars().next().is_none_or(|character| {
217        character == '/' || character == separator || evidence_delimiter(character)
218    })
219}
220
221fn dash_start_boundary(input: &str, start: usize) -> bool {
222    start == 0
223        || input[..start]
224            .chars()
225            .next_back()
226            .is_some_and(|character| evidence_delimiter(character) || character == '/')
227}
228
229fn generic_home_prefix_end(input: &str, start: usize) -> Option<usize> {
230    let prefix = HOME_PREFIXES
231        .into_iter()
232        .find(|prefix| input[start..].starts_with(prefix))?;
233    let separator = prefix.chars().next().expect("prefixes are non-empty");
234    // Generic aliases only start a token. Unlike exact $HOME matching, a
235    // preceding slash makes the slash form a nested path such as
236    // /tmp/Users/alice; a dash-encoded slug normally does follow a slash.
237    if start != 0
238        && !input[..start].chars().next_back().is_some_and(|character| {
239            evidence_delimiter(character) || (separator == '-' && character == '/')
240        })
241    {
242        return None;
243    }
244    let component_start = start + prefix.len();
245    let component_end = input[component_start..]
246        .char_indices()
247        .find_map(|(offset, character)| {
248            (character == '/' || character == separator || evidence_delimiter(character))
249                .then_some(component_start + offset)
250        })
251        .unwrap_or(input.len());
252    (component_end > component_start && path_prefix_boundary(input, component_end, separator))
253        .then_some(component_end)
254}
255
256fn token_end(input: &str, start: usize) -> usize {
257    input[start..]
258        .find(|character: char| evidence_delimiter(character))
259        .map_or(input.len(), |offset| start + offset)
260}
261
262pub(crate) fn rewrite_home_paths(input: &str, home: Option<&Path>) -> String {
263    let home = home.and_then(Path::to_str);
264    // Exact current home in dash-encoded form. This must win over the generic
265    // dash rule: a dash inside the username would otherwise truncate the
266    // rewrite after its first dash-separated component.
267    let dash_home = home.map(|home| home.replace('/', "-"));
268    let mut output = String::with_capacity(input.len());
269    let mut copied = 0;
270    let mut index = 0;
271    while index < input.len() {
272        let character = input[index..]
273            .chars()
274            .next()
275            .expect("index stays on a character boundary");
276        if character != '/' && character != '-' {
277            index += character.len_utf8();
278            continue;
279        }
280        let end = home
281            .filter(|home| input[index..].starts_with(home))
282            .map(|home| index + home.len())
283            .filter(|end| path_prefix_boundary(input, *end, '/'))
284            .or_else(|| {
285                dash_home
286                    .as_deref()
287                    .filter(|_| dash_start_boundary(input, index))
288                    .filter(|dash| input[index..].starts_with(dash))
289                    .map(|dash| index + dash.len())
290                    .filter(|end| path_prefix_boundary(input, *end, '-'))
291            })
292            .or_else(|| generic_home_prefix_end(input, index));
293        if let Some(end) = end {
294            output.push_str(&input[copied..index]);
295            output.push('~');
296            copied = end;
297            // A match replaces only the home prefix. Keep the rest of this path
298            // token verbatim rather than finding and rewriting nested segments.
299            index = token_end(input, end);
300        } else {
301            index += character.len_utf8();
302        }
303    }
304    if copied == 0 {
305        input.into()
306    } else {
307        output.push_str(&input[copied..]);
308        output
309    }
310}
311
312pub(crate) fn redact_evidence(input: &str, home: Option<&Path>) -> String {
313    let rewritten = rewrite_home_paths(input, home);
314    let input = rewritten.as_str();
315    let lower = input.to_ascii_lowercase();
316    let mut spans = Vec::new();
317    for key in SENSITIVE_KEYS.split_ascii_whitespace() {
318        for (start, _) in lower.match_indices(key) {
319            let end = start + key.len();
320            if word(input, start.wrapping_sub(1)) || word(input, end) {
321                continue;
322            }
323            if let Some((value_start, value_end)) = assignment_value_span(input, end) {
324                // "Authorization: Bearer <credential>": the first token is the
325                // scheme; the credential follows it. Cover one more token so the
326                // secret, not just the scheme word, is redacted.
327                let value_end = if key == "authorization" {
328                    extend_one_token(input, value_end)
329                } else {
330                    value_end
331                };
332                spans.push((value_start, value_end));
333            }
334        }
335    }
336    for (start, scheme) in lower
337        .match_indices("http://")
338        .chain(lower.match_indices("https://"))
339    {
340        let authority_start = start + scheme.len();
341        let authority_end = input[authority_start..]
342            .find(|character: char| "/?#\"' \t\r\n".contains(character))
343            .map_or(input.len(), |offset| authority_start + offset);
344        if let Some(at) = input[authority_start..authority_end].rfind('@') {
345            spans.push((authority_start, authority_start + at));
346        }
347    }
348    let mut token_start = None;
349    for (end, character) in input
350        .char_indices()
351        .chain(std::iter::once((input.len(), ' ')))
352    {
353        if character.is_ascii_alphanumeric() || "_-./+=".contains(character) {
354            token_start.get_or_insert(end);
355            continue;
356        }
357        let Some(start) = token_start.take() else {
358            continue;
359        };
360        let token = &input[start..end];
361        let unique = token.bytes().collect::<HashSet<_>>().len();
362        let mixed = token.bytes().any(|byte| byte.is_ascii_lowercase())
363            && token.bytes().any(|byte| byte.is_ascii_uppercase())
364            && token.bytes().any(|byte| byte.is_ascii_digit());
365        if token.len() >= 24 && unique >= 12 && mixed {
366            spans.push((start, end));
367        }
368    }
369    spans.sort_unstable();
370    // Merge overlaps before emitting: dropping an overlapping span whole would
371    // leak the part of a secret that extends past the previous span's end.
372    let mut merged: Vec<(usize, usize)> = Vec::new();
373    for (start, end) in spans {
374        match merged.last_mut() {
375            Some((_, last_end)) if start <= *last_end => *last_end = (*last_end).max(end),
376            _ => merged.push((start, end)),
377        }
378    }
379    let mut output = String::with_capacity(input.len());
380    let mut cursor = 0;
381    for (start, end) in merged {
382        output.push_str(&input[cursor..start]);
383        output.push_str("<redacted>");
384        cursor = end;
385    }
386    output.push_str(&input[cursor..]);
387    output
388}
389
390pub(crate) fn read_text(
391    text: Option<String>,
392    record_name: &str,
393    command_name: &str,
394) -> AppResult<String> {
395    let use_stdin =
396        text.as_deref() == Some("-") || (text.is_none() && !std::io::stdin().is_terminal());
397    let mut text = if use_stdin {
398        let mut input = Vec::new();
399        std::io::stdin()
400            .lock()
401            .read_to_end(&mut input)
402            .map_err(|error| AppError::from_io(error, std::path::Path::new("stdin")))?;
403        String::from_utf8(input).map_err(|_| {
404            AppError::invalid_input(
405                format!("{record_name} text from stdin is not valid UTF-8"),
406                format!("Pipe UTF-8 text to `blotter {command_name} -`."),
407            )
408        })?
409    } else {
410        text.ok_or_else(|| {
411            AppError::invalid_argument(
412                format!("{command_name} requires TEXT when stdin is a terminal"),
413                format!(
414                    "Run `blotter {command_name} \"text\"` or pipe text to `blotter {command_name} -`."
415                ),
416            )
417        })?
418    };
419    while text.ends_with('\n') || text.ends_with('\r') {
420        text.pop();
421    }
422    Ok(text)
423}
424
425pub(crate) fn validate_text(text: &str, record_name: &str) -> AppResult<()> {
426    if text.trim().is_empty() {
427        return Err(AppError::invalid_input(
428            format!("{record_name} text cannot be empty or whitespace-only"),
429            "Pass non-empty TEXT or pipe it on stdin.",
430        ));
431    }
432    if text.len() > 10_000 {
433        return Err(AppError::invalid_input(
434            format!(
435                "{record_name} text is {} bytes; the maximum is 10000",
436                text.len()
437            ),
438            format!("Shorten the {record_name} text to at most 10000 UTF-8 bytes."),
439        ));
440    }
441    Ok(())
442}