use crate::cli::AddArgs;
use crate::error::{AppError, AppResult};
use crate::output::{self, Meta};
use crate::redact::{evidence_delimiter, rewrite_home_paths};
use crate::store;
use crate::{Evidence, LogEvent, Origin, compute_id, format_timestamp, resolve_agent_checked};
use jiff::Timestamp;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
#[cfg(not(unix))]
use std::fs::File;
#[cfg(unix)]
use std::fs::OpenOptions;
use std::io::{IsTerminal, Read};
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
pub(crate) const SECRET_MARKER: &str = "<redacted>";
const STDERR_INPUT_LIMIT: u64 = 1024 * 1024;
const STDIN_INPUT_LIMIT: u64 = 1024 * 1024;
#[derive(Debug, Serialize, Deserialize)]
pub struct AddData {
pub changed: bool,
pub record: LogEvent,
}
pub fn run(args: AddArgs, file: Option<PathBuf>, pretty: bool, now: Timestamp) -> AppResult<i32> {
let resolved = store::discover(file)?;
let home = store::home_dir(&resolved.cwd);
let evidence = build_evidence(&args, home.as_deref())?;
let text = rewrite_home_paths(&read_text(args.text, "cut", "add")?, home.as_deref());
validate_text(&text, "cut")?;
let (agent, source) = resolve_agent_checked(args.agent, true)?;
let mut tags = args.tags;
tags.sort();
tags.dedup();
let mut warnings = resolved.warnings.clone();
let ts = format_timestamp(now);
let supplied_evidence = evidence.is_some();
let resolution_text =
text.trim_start().starts_with("RESOLUTION") || text.trim_start().starts_with("RESOLVED");
let record = LogEvent::Cut {
id: compute_id(&ts, &agent, &text, args.impact, &tags),
ts,
agent,
text,
tags,
impact: args.impact,
cwd: store::record_cwd(&resolved.cwd, resolved.cwd_repo(), home.as_deref()),
origin: Some(Origin::agent()),
evidence,
};
if resolution_text {
warnings.push(
"resolution_text: this looks like a resolution; use `blotter resolve <id>` for an existing cut".into(),
);
}
let (changed, record) = store::append_unique(&resolved.path, record, args.dry_run)?;
if args.dry_run {
warnings.push("dry run; no record appended".into());
} else if !changed {
warnings.push(
if supplied_evidence {
"duplicate_cut: existing record returned; later evidence was not stored"
} else {
"duplicate cut; existing record returned"
}
.into(),
);
}
let mut meta = Meta::new();
meta.file = Some(resolved.path.to_string_lossy().into_owned());
meta.agent_source = Some(source.into());
meta.warnings = warnings;
output::write_success(AddData { changed, record }, pretty, meta)
.map_err(|error| AppError::from_io(error, std::path::Path::new("stdout")))?;
Ok(0)
}
fn build_evidence(args: &AddArgs, home: Option<&Path>) -> AppResult<Option<Evidence>> {
let stderr = args.stderr_file.as_deref().map(read_stderr).transpose()?;
if args.cmd.is_none() && args.exit_code.is_none() && stderr.is_none() && args.evidence.is_none()
{
return Ok(None);
}
Ok(Some(Evidence {
cmd: args
.cmd
.as_deref()
.map(|value| redact_evidence(value, home)),
exit: args.exit_code,
stderr: stderr.map(|value| redact_and_truncate(&value, 4096, home)),
note: args
.evidence
.as_deref()
.map(|value| redact_evidence(value, home)),
}))
}
fn read_stderr(path: &std::path::Path) -> AppResult<String> {
#[cfg(unix)]
let mut file = OpenOptions::new()
.read(true)
.custom_flags(libc::O_NONBLOCK)
.open(path)
.map_err(|error| AppError::from_evidence_file(error, path))?;
#[cfg(not(unix))]
let mut file = File::open(path).map_err(|error| AppError::from_evidence_file(error, path))?;
let metadata = file
.metadata()
.map_err(|error| AppError::from_evidence_file(error, path))?;
if !metadata.is_file() {
return Err(AppError::invalid_input(
format!(
"stderr evidence path is not a regular file: {}",
path.display()
),
"Pass a regular UTF-8 file to --stderr-file PATH; FIFOs and devices are not accepted.",
));
}
if metadata.len() > STDERR_INPUT_LIMIT {
return Err(AppError::invalid_input(
format!(
"stderr evidence file exceeds the {}-byte read limit: {}",
STDERR_INPUT_LIMIT,
path.display()
),
"Pass a smaller stderr file to --stderr-file PATH; stored sanitized stderr is capped at 4096 bytes.",
));
}
let mut bytes = Vec::new();
file.by_ref()
.take(STDERR_INPUT_LIMIT + 1)
.read_to_end(&mut bytes)
.map_err(|error| AppError::from_evidence_file(error, path))?;
if bytes.len() > STDERR_INPUT_LIMIT as usize {
return Err(AppError::invalid_input(
format!(
"stderr evidence file exceeds the {}-byte read limit: {}",
STDERR_INPUT_LIMIT,
path.display()
),
"Pass a smaller stderr file to --stderr-file PATH; stored sanitized stderr is capped at 4096 bytes.",
));
}
String::from_utf8(bytes).map_err(|_| {
AppError::invalid_input(
format!("stderr file is not valid UTF-8: {}", path.display()),
"Pass a UTF-8 stderr file with --stderr-file PATH.",
)
})
}
pub(crate) fn redact_and_truncate(value: &str, max_bytes: usize, home: Option<&Path>) -> String {
let (redacted, markers) = redact_evidence_marked(value, home);
if redacted.len() <= max_bytes {
return redacted;
}
rewrite_home_paths(&truncate_utf8(&redacted, max_bytes, &markers), home)
}
fn truncate_utf8(value: &str, max_bytes: usize, markers: &[(usize, usize)]) -> String {
if value.len() <= max_bytes {
return value.to_owned();
}
let mut end = max_bytes;
while !value.is_char_boundary(end) {
end -= 1;
}
if let Some((start, _)) = markers
.iter()
.find(|(start, marker_end)| *start < end && end < *marker_end)
{
end = *start;
}
value[..end].to_owned()
}
const SENSITIVE_KEYS: &str = "accesskey apikey authorization authtoken bearer clientsecret dbpassword key passwd password secret token";
fn word(s: &str, i: usize) -> bool {
s.as_bytes()
.get(i)
.is_some_and(|b| b.is_ascii_alphanumeric())
}
fn assignment_value_span(input: &str, end: usize) -> Option<(usize, usize)> {
let rest = input[end..].trim_start_matches('"').trim_start();
let separator = rest.chars().next().filter(|c| matches!(c, '=' | ':'))?;
let rest = rest[separator.len_utf8()..].trim_start();
let rest = rest.trim_start_matches(['"', '\'']);
let start = input.len() - rest.len();
let end = rest
.find(|character: char| evidence_delimiter(character))
.map_or(input.len(), |offset| start + offset);
(start < end).then_some((start, end))
}
fn extend_one_token(input: &str, end: usize) -> usize {
let rest = &input[end..];
let trimmed = rest.trim_start_matches([' ', '\t']);
if trimmed.len() == rest.len() || trimmed.is_empty() {
return end;
}
let start = input.len() - trimmed.len();
trimmed
.find(|character: char| evidence_delimiter(character))
.map_or(input.len(), |offset| start + offset)
}
pub(crate) fn redact_evidence(input: &str, home: Option<&Path>) -> String {
redact_evidence_marked(input, home).0
}
fn redact_evidence_marked(input: &str, home: Option<&Path>) -> (String, Vec<(usize, usize)>) {
let rewritten = rewrite_home_paths(input, home);
let input = rewritten.as_str();
let lower = input.to_ascii_lowercase();
let mut spans = Vec::new();
for key in SENSITIVE_KEYS.split_ascii_whitespace() {
for (start, _) in lower.match_indices(key) {
let end = start + key.len();
if word(input, start.wrapping_sub(1)) || word(input, end) {
continue;
}
if let Some((value_start, value_end)) = assignment_value_span(input, end) {
let value_end = if key == "authorization" {
extend_one_token(input, value_end)
} else {
value_end
};
spans.push((value_start, value_end));
}
}
}
for (start, scheme) in lower
.match_indices("http://")
.chain(lower.match_indices("https://"))
{
let authority_start = start + scheme.len();
let authority_end = input[authority_start..]
.find(|character: char| "/?#\"' \t\r\n".contains(character))
.map_or(input.len(), |offset| authority_start + offset);
if let Some(at) = input[authority_start..authority_end].rfind('@') {
spans.push((authority_start, authority_start + at));
}
}
let mut token_start = None;
for (end, character) in input
.char_indices()
.chain(std::iter::once((input.len(), ' ')))
{
if character.is_ascii_alphanumeric() || "_-./+=".contains(character) {
token_start.get_or_insert(end);
continue;
}
let Some(start) = token_start.take() else {
continue;
};
let token = &input[start..end];
let unique = token.bytes().collect::<HashSet<_>>().len();
let mixed = token.bytes().any(|byte| byte.is_ascii_lowercase())
&& token.bytes().any(|byte| byte.is_ascii_uppercase())
&& token.bytes().any(|byte| byte.is_ascii_digit());
if token.len() >= 24 && unique >= 12 && mixed {
spans.push((start, end));
}
}
spans.sort_unstable();
let mut merged: Vec<(usize, usize)> = Vec::new();
for (start, end) in spans {
match merged.last_mut() {
Some((_, last_end)) if start <= *last_end => *last_end = (*last_end).max(end),
_ => merged.push((start, end)),
}
}
let mut output = String::with_capacity(input.len());
let mut markers = Vec::with_capacity(merged.len());
let mut cursor = 0;
for (start, end) in merged {
output.push_str(&input[cursor..start]);
let marker_start = output.len();
output.push_str(SECRET_MARKER);
markers.push((marker_start, output.len()));
cursor = end;
}
output.push_str(&input[cursor..]);
(output, markers)
}
pub(crate) fn read_text(
text: Option<String>,
record_name: &str,
command_name: &str,
) -> AppResult<String> {
let use_stdin =
text.as_deref() == Some("-") || (text.is_none() && !std::io::stdin().is_terminal());
let mut text = if use_stdin {
let mut input = Vec::new();
std::io::stdin()
.lock()
.take(STDIN_INPUT_LIMIT + 1)
.read_to_end(&mut input)
.map_err(|error| AppError::from_io(error, std::path::Path::new("stdin")))?;
if input.len() > STDIN_INPUT_LIMIT as usize {
return Err(AppError::invalid_input(
format!(
"{record_name} text from stdin exceeds the {STDIN_INPUT_LIMIT}-byte read limit"
),
format!("Pipe at most {STDIN_INPUT_LIMIT} bytes to `blotter {command_name} -`."),
));
}
String::from_utf8(input).map_err(|_| {
AppError::invalid_input(
format!("{record_name} text from stdin is not valid UTF-8"),
format!("Pipe UTF-8 text to `blotter {command_name} -`."),
)
})?
} else {
text.ok_or_else(|| {
AppError::invalid_argument(
format!("{command_name} requires TEXT when stdin is a terminal"),
format!(
"Run `blotter {command_name} \"text\"` or pipe text to `blotter {command_name} -`."
),
)
})?
};
while text.ends_with('\n') || text.ends_with('\r') {
text.pop();
}
Ok(text)
}
pub(crate) fn validate_text(text: &str, record_name: &str) -> AppResult<()> {
if text.trim().is_empty() {
return Err(AppError::invalid_input(
format!("{record_name} text cannot be empty or whitespace-only"),
"Pass non-empty TEXT or pipe it on stdin.",
));
}
if text.len() > 10_000 {
return Err(AppError::invalid_input(
format!(
"{record_name} text is {} bytes; the maximum is 10000",
text.len()
),
format!("Shorten the {record_name} text to at most 10000 UTF-8 bytes."),
));
}
Ok(())
}