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
19pub(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 #[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 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 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
236fn 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 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 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 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}