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 #[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";
174const EVIDENCE_DELIMITERS: &str = ",;)]}&#\"'";
177const 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 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 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 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 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 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}