use std::fs;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use tirith_core::policy::Policy;
use tirith_core::redact::{
redact_for_audience_with_custom, RedactReport, RedactionCount, ShareAudience,
};
#[derive(serde::Serialize)]
struct JsonOut<'a> {
redacted_content: &'a str,
redactions: &'a [RedactionCount],
}
pub fn share(
path: Option<&Path>,
out_path: Option<&Path>,
target: ShareAudience,
json: bool,
) -> i32 {
let input = match read_input(path) {
Ok(s) => s,
Err(e) => {
eprintln!("tirith share: failed to read {}: {e}", display_label(path));
return 1;
}
};
let customer_patterns = load_customer_id_patterns();
let report = redact_for_audience_with_custom(&input, target, &customer_patterns);
if json {
emit_json(&report, target, out_path)
} else {
if let Err(code) = write_output(out_path, report.redacted_content.as_bytes(), true) {
return code;
}
print_human_summary(&report, target);
0
}
}
pub fn redact_stdin(audience: ShareAudience, json: bool) -> i32 {
let input = match read_stdin() {
Ok(s) => s,
Err(e) => {
eprintln!("tirith redact: failed to read stdin: {e}");
return 1;
}
};
let customer_patterns = load_customer_id_patterns();
let report = redact_for_audience_with_custom(&input, audience, &customer_patterns);
if json {
emit_json(&report, audience, None)
} else {
if let Err(code) = write_output(None, report.redacted_content.as_bytes(), true) {
return code;
}
print_human_summary(&report, audience);
0
}
}
fn load_customer_id_patterns() -> Vec<String> {
Policy::discover_partial(None).share.customer_id_patterns
}
fn read_input(path: Option<&Path>) -> std::io::Result<String> {
match path {
None => read_stdin(),
Some(p) if p.as_os_str() == "-" => read_stdin(),
Some(p) => fs::read_to_string(p),
}
}
fn read_stdin() -> std::io::Result<String> {
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?;
Ok(buf)
}
fn display_label(path: Option<&Path>) -> String {
match path {
None => "<stdin>".to_string(),
Some(p) if p.as_os_str() == "-" => "<stdin>".to_string(),
Some(p) => p.display().to_string(),
}
}
fn write_output(
out_path: Option<&Path>,
content: &[u8],
append_stdout_newline: bool,
) -> Result<(), i32> {
match out_path {
Some(p) => {
if let Err(e) = write_output_file(p, content) {
eprintln!("tirith share: failed to write {}: {e}", p.display());
return Err(1);
}
Ok(())
}
None => {
let mut stdout = std::io::stdout().lock();
if stdout.write_all(content).is_err() {
eprintln!("tirith share: failed to write to stdout (broken pipe?)");
return Err(1);
}
if append_stdout_newline && !content.ends_with(b"\n") && writeln!(stdout).is_err() {
eprintln!("tirith share: failed to write to stdout (broken pipe?)");
return Err(1);
}
if stdout.flush().is_err() {
eprintln!("tirith share: failed to write to stdout (broken pipe?)");
return Err(1);
}
Ok(())
}
}
}
fn write_output_file(path: &Path, content: &[u8]) -> std::io::Result<()> {
let absolute = std::path::absolute(path)?;
let root = absolute.parent().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"share output must name a file",
)
})?;
let destination = tirith_core::util::ContainedAtomicFile::prepare(root, &absolute, false)?;
match destination.read_capped(0) {
Ok(_) | Err(tirith_core::util::OpenRegularError::TooLarge) => {}
Err(tirith_core::util::OpenRegularError::NotFound) => {}
Err(tirith_core::util::OpenRegularError::NotRegularFile) => {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"refusing symlinked or non-regular output destination {}",
path.display()
),
));
}
Err(tirith_core::util::OpenRegularError::Io(error)) => return Err(error),
}
destination.write_atomic(content, true)
}
fn emit_json(report: &RedactReport, audience: ShareAudience, out_path: Option<&Path>) -> i32 {
let _ = audience; let out = JsonOut {
redacted_content: &report.redacted_content,
redactions: &report.redactions,
};
let mut encoded = match serde_json::to_vec_pretty(&out) {
Ok(encoded) => encoded,
Err(error) => {
eprintln!("tirith share: failed to serialize JSON output: {error}");
return 1;
}
};
encoded.push(b'\n');
write_output(out_path, &encoded, false).map_or_else(|code| code, |()| 0)
}
fn print_human_summary(report: &RedactReport, audience: ShareAudience) {
let target = match audience {
ShareAudience::GithubIssue => "github-issue",
ShareAudience::Slack => "slack",
ShareAudience::Llm => "llm",
ShareAudience::PublicPaste => "public-paste",
ShareAudience::Generic => "generic",
};
if report.redactions.is_empty() {
eprintln!("tirith share: target={target}; no redactions applied");
return;
}
let parts: Vec<String> = report
.redactions
.iter()
.map(|r| format!("{} {}", r.count, r.label))
.collect();
eprintln!(
"tirith share: target={target}; removed {}",
parts.join(", ")
);
}
pub fn parse_audience(s: &str) -> Result<ShareAudience, String> {
ShareAudience::parse_cli(s).ok_or_else(|| {
format!(
"invalid audience '{s}' (expected one of: {})",
ShareAudience::cli_values().join(", ")
)
})
}
pub fn resolve_out_path(s: Option<&str>) -> Option<PathBuf> {
match s {
None => None,
Some("-") => None,
Some(p) => Some(PathBuf::from(p)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn share_audience_parser_accepts_canonical_names() {
assert!(parse_audience("github-issue").is_ok());
assert!(parse_audience("slack").is_ok());
assert!(parse_audience("llm").is_ok());
assert!(parse_audience("public-paste").is_ok());
assert!(parse_audience("generic").is_ok());
}
#[test]
fn share_audience_parser_rejects_unknown_with_listing() {
let err = parse_audience("zoom").unwrap_err();
assert!(err.contains("expected one of"));
assert!(err.contains("github-issue"));
}
#[test]
fn share_writes_to_out_path_when_given() {
let dir = tempdir().unwrap();
let input = dir.path().join("in.log");
let out = dir.path().join("out.log");
fs::write(&input, "key=AKIAIOSFODNN7EXAMPLE done\n").unwrap();
let code = share(Some(&input), Some(&out), ShareAudience::Llm, false);
assert_eq!(code, 0);
let written = fs::read_to_string(&out).unwrap();
assert!(!written.contains("AKIAIOSFODNN7EXAMPLE"));
}
#[test]
fn share_json_writes_documented_envelope_to_out_path() {
let dir = tempdir().unwrap();
let input = dir.path().join("in.log");
let out = dir.path().join("out.json");
fs::write(&input, "key=AKIAIOSFODNN7EXAMPLE done\n").unwrap();
fs::write(&out, "stale output that must be replaced").unwrap();
let code = share(Some(&input), Some(&out), ShareAudience::Llm, true);
assert_eq!(code, 0);
let bytes = fs::read(&out).unwrap();
assert!(bytes.ends_with(b"\n"));
let envelope: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(envelope.get("redacted_content").is_some());
assert!(envelope.get("redactions").is_some());
assert!(!String::from_utf8_lossy(&bytes).contains("AKIAIOSFODNN7EXAMPLE"));
}
#[cfg(unix)]
#[test]
fn share_refuses_symlinked_output_and_preserves_its_target() {
let dir = tempdir().unwrap();
let input = dir.path().join("in.log");
let victim = dir.path().join("victim.log");
let output = dir.path().join("out.log");
fs::write(&input, "share-safe input\n").unwrap();
fs::write(&victim, "victim sentinel\n").unwrap();
std::os::unix::fs::symlink(&victim, &output).unwrap();
let code = share(Some(&input), Some(&output), ShareAudience::Llm, true);
assert_eq!(code, 1);
assert_eq!(fs::read_to_string(&victim).unwrap(), "victim sentinel\n");
assert!(fs::symlink_metadata(&output)
.unwrap()
.file_type()
.is_symlink());
}
#[cfg(unix)]
#[test]
fn share_refuses_fifo_output_without_blocking_or_replacing_it() {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt as _;
use std::os::unix::fs::FileTypeExt as _;
let dir = tempdir().unwrap();
let input = dir.path().join("in.log");
let output = dir.path().join("out.fifo");
fs::write(&input, "share-safe input\n").unwrap();
let encoded = CString::new(output.as_os_str().as_bytes()).unwrap();
assert_eq!(unsafe { libc::mkfifo(encoded.as_ptr(), 0o600) }, 0);
let code = share(Some(&input), Some(&output), ShareAudience::Llm, false);
assert_eq!(code, 1);
assert!(fs::symlink_metadata(&output).unwrap().file_type().is_fifo());
}
#[test]
fn resolve_out_path_treats_dash_as_stdout() {
assert!(resolve_out_path(None).is_none());
assert!(resolve_out_path(Some("-")).is_none());
assert_eq!(
resolve_out_path(Some("/tmp/foo.txt")),
Some(PathBuf::from("/tmp/foo.txt"))
);
}
}