Skip to main content

start_command/
log_sanitizer.rs

1//! Prepares logs and issue text for publication with
2//! @link-foundation/sensitive-data-sanitizer, the sanitizer shared by
3//! start-command, gh-upload-log and hive-mind (issue #250).
4//!
5//! The library CLI runs with `--credentials-only`, `--env-secrets` and
6//! `--gh-config`; it streams the log from stdin, keeps invalid UTF-8 bytes and
7//! writes a new private file atomically (`--output`). Its error messages are
8//! value-free and only their `ERR_*` code is kept here.
9//!
10//! Since 0.12.1 the library masks only the scheme and token of an
11//! `Authorization: <scheme> <value>` header, so a logged one-line JSON prompt
12//! keeps the rest of its line (issue #249, sensitive-data-sanitizer#45); the
13//! pre-pass that did this here is gone (issue #253).
14//!
15//! The JavaScript package depends on ^0.14.0, the current release flagged by
16//! the dependency freshness check; [`PACKAGE`] and [`MINIMUM_VERSION`] require
17//! the same release (js/src/lib/log-sanitizer.js).
18
19use regex::bytes::Regex;
20use std::ffi::OsString;
21use std::fs::{self, DirBuilder, File};
22use std::io::{Read, Write};
23use std::path::{Path, PathBuf};
24use std::process::{Command, Stdio};
25use std::sync::OnceLock;
26
27pub const BLOCK_SIZE: usize = 64 * 1024;
28const MARKER: &[u8] = b"[REDACTED]";
29const COMMAND: &str = "sensitive-data-sanitizer";
30/// The library version the JavaScript package depends on.
31pub const PACKAGE: &str = "@link-foundation/sensitive-data-sanitizer@^0.14.0";
32/// Requires control-byte preservation, placeholders and bounded dense scanning.
33pub const MINIMUM_VERSION: (u64, u64, u64) = (0, 14, 0);
34/// Path of the library CLI to use instead of the one on PATH.
35pub const OVERRIDE: &str = "START_SENSITIVE_DATA_SANITIZER";
36const OPTIONS: [&str; 3] = ["--credentials-only", "--env-secrets", "--gh-config"];
37
38/// Value-free failure message: `code` is a fixed ERR_* name, never content.
39fn blocked(code: &str, subject: &str) -> String {
40    let action = if subject == "Log" {
41        "upload"
42    } else {
43        "publication"
44    };
45    format!(
46        "{} sanitization failed ({}); {} blocked.",
47        subject, code, action
48    )
49}
50
51/// The environment the library runs with: inherited, or exactly the given
52/// variables (so tests control which credential-named values it masks).
53enum Environment {
54    Inherit,
55    Explicit(Vec<(String, String)>),
56}
57
58impl Environment {
59    fn get(&self, name: &str) -> Option<OsString> {
60        match self {
61            Self::Inherit => std::env::var_os(name),
62            Self::Explicit(values) => values
63                .iter()
64                .find(|(key, _)| {
65                    if cfg!(windows) {
66                        key.eq_ignore_ascii_case(name)
67                    } else {
68                        key == name
69                    }
70                })
71                .map(|(_, value)| value.into()),
72        }
73    }
74
75    fn apply(&self, command: &mut Command) {
76        if let Self::Explicit(values) = self {
77            command.env_clear().envs(values.iter().cloned());
78        }
79    }
80}
81
82fn is_executable(path: &Path) -> bool {
83    let Ok(metadata) = fs::metadata(path) else {
84        return false;
85    };
86    #[cfg(unix)]
87    {
88        use std::os::unix::fs::PermissionsExt;
89        metadata.is_file() && metadata.permissions().mode() & 0o111 != 0
90    }
91    #[cfg(not(unix))]
92    metadata.is_file()
93}
94
95fn resolve(env: &Environment) -> Option<PathBuf> {
96    if let Some(command) = env.get(OVERRIDE).filter(|value| !value.is_empty()) {
97        return Some(PathBuf::from(command));
98    }
99    // npm installs a .cmd shim on Windows next to a POSIX script of the bare
100    // name; Rust runs .exe/.cmd/.bat files, with argument escaping for batch.
101    let names: &[&str] = if cfg!(windows) {
102        &[".exe", ".cmd", ".bat"]
103    } else {
104        &[""]
105    };
106    let path = env.get("PATH")?;
107    std::env::split_paths(&path).find_map(|directory| {
108        names
109            .iter()
110            .map(|extension| directory.join(format!("{}{}", COMMAND, extension)))
111            .find(|candidate| is_executable(candidate))
112    })
113}
114
115/// The library CLI: START_SENSITIVE_DATA_SANITIZER, else sensitive-data-sanitizer on PATH.
116pub fn find_sanitizer() -> Option<PathBuf> {
117    resolve(&Environment::Inherit)
118}
119
120/// `major.minor.patch` from `--version` output such as `0.12.1`.
121pub fn parse_version(text: &str) -> Option<(u64, u64, u64)> {
122    let mut parts = text.trim().split(['.', '-', '+']);
123    let mut next = || parts.next()?.parse().ok();
124    Some((next()?, next()?, next()?))
125}
126
127fn version_of(program: &Path) -> Option<(u64, u64, u64)> {
128    let output = Command::new(program)
129        .arg("--version")
130        .stdin(Stdio::null())
131        .stderr(Stdio::null())
132        .output()
133        .ok()?;
134    if !output.status.success() {
135        return None;
136    }
137    parse_version(&String::from_utf8_lossy(&output.stdout))
138}
139
140/// A CLI that understands every option used here; an explicit override is
141/// used as it is.
142fn current_sanitizer() -> bool {
143    if std::env::var_os(OVERRIDE).is_some_and(|value| !value.is_empty()) {
144        return true;
145    }
146    find_sanitizer()
147        .and_then(|program| version_of(&program))
148        .is_some_and(|version| version >= MINIMUM_VERSION)
149}
150
151/// Install the library CLI with an available JavaScript package manager when
152/// it is missing or older than MINIMUM_VERSION.
153pub fn ensure_sanitizer_available() -> Result<(), String> {
154    if current_sanitizer() {
155        return Ok(());
156    }
157    let state = if find_sanitizer().is_some() {
158        "is older than the required version"
159    } else {
160        "not found"
161    };
162    let npm = if cfg!(windows) { "npm.cmd" } else { "npm" };
163    for command in ["bun", npm] {
164        let available = Command::new(command)
165            .arg("--version")
166            .stdout(Stdio::null())
167            .stderr(Stdio::null())
168            .status()
169            .map(|status| status.success())
170            .unwrap_or(false);
171        if !available {
172            continue;
173        }
174        let args = ["install", "-g", PACKAGE];
175        println!(
176            "{} {}; installing with: {} {}",
177            COMMAND,
178            state,
179            command,
180            args.join(" ")
181        );
182        let installed = Command::new(command)
183            .args(args)
184            .status()
185            .map(|status| status.success())
186            .unwrap_or(false);
187        if installed && current_sanitizer() {
188            return Ok(());
189        }
190    }
191    let (major, minor, patch) = MINIMUM_VERSION;
192    Err(format!(
193        "{} {}.{}.{} or later is not installed and automatic installation did not make it available on PATH.",
194        COMMAND, major, minor, patch
195    ))
196}
197
198fn error_code(stderr: &[u8]) -> String {
199    static CODE: OnceLock<Regex> = OnceLock::new();
200    CODE.get_or_init(|| Regex::new(r"\bERR_[A-Z_]+\b").expect("static code rule"))
201        .find(stderr)
202        .map(|code| String::from_utf8_lossy(code.as_bytes()).into_owned())
203        .unwrap_or_else(|| "ERR_SANITIZER".to_string())
204}
205
206fn sanitizer_command(env: &Environment, subject: &str) -> Result<Command, String> {
207    let program = resolve(env).ok_or_else(|| blocked("ERR_UNAVAILABLE", subject))?;
208    let mut command = Command::new(program);
209    env.apply(&mut command);
210    command.arg("redact").arg("-");
211    Ok(command)
212}
213
214fn text_with(text: &str, env: &Environment) -> Result<String, String> {
215    let fail = |code: &str| blocked(code, "Text");
216    let input = text.as_bytes().to_vec();
217    let mut child = sanitizer_command(env, "Text")?
218        .args(OPTIONS)
219        .stdin(Stdio::piped())
220        .stdout(Stdio::piped())
221        .stderr(Stdio::piped())
222        .spawn()
223        .map_err(|_| fail("ERR_UNAVAILABLE"))?;
224    let mut stdin = child.stdin.take().ok_or_else(|| fail("ERR_SANITIZER"))?;
225    let writer = std::thread::spawn(move || stdin.write_all(&input));
226    let output = child
227        .wait_with_output()
228        .map_err(|_| fail("ERR_SANITIZER"))?;
229    let written = writer.join().map(|result| result.is_ok()).unwrap_or(false);
230    if !output.status.success() {
231        return Err(fail(&error_code(&output.stderr)));
232    }
233    if !written {
234        return Err(fail("ERR_SANITIZER"));
235    }
236    String::from_utf8(output.stdout).map_err(|_| fail("ERR_SANITIZER"))
237}
238
239/// Sanitize a short text, such as the failing command quoted in an issue.
240pub fn sanitize_text(text: &str) -> Result<String, String> {
241    text_with(text, &Environment::Inherit)
242}
243
244pub fn sanitize_text_with_env(
245    text: &str,
246    env: impl IntoIterator<Item = (String, String)>,
247) -> Result<String, String> {
248    text_with(text, &Environment::Explicit(env.into_iter().collect()))
249}
250
251/// Private temporary copy; dropping it removes its file and directory.
252pub struct PreparedLog {
253    pub path: PathBuf,
254    directory: PathBuf,
255}
256
257impl Drop for PreparedLog {
258    fn drop(&mut self) {
259        let _ = fs::remove_dir_all(&self.directory);
260    }
261}
262
263fn count_markers(path: &Path) -> std::io::Result<u64> {
264    let mut file = File::open(path)?;
265    let mut buffer = vec![0; BLOCK_SIZE];
266    let mut text = Vec::new();
267    let mut count = 0;
268    loop {
269        let length = file.read(&mut buffer)?;
270        text.extend_from_slice(&buffer[..length]);
271        let mut next = 0;
272        while let Some(index) = text[next..]
273            .windows(MARKER.len())
274            .position(|window| window == MARKER)
275        {
276            count += 1;
277            next += index + MARKER.len();
278        }
279        if length == 0 {
280            return Ok(count);
281        }
282        let keep = next.max(text.len().saturating_sub(MARKER.len() - 1));
283        text.drain(..keep);
284    }
285}
286
287fn private_directory() -> Result<PathBuf, String> {
288    let directory = std::env::temp_dir().join(format!("start-sanitized-{}", uuid::Uuid::new_v4()));
289    let builder = DirBuilder::new();
290    #[cfg(unix)]
291    let builder = {
292        use std::os::unix::fs::DirBuilderExt;
293        let mut builder = builder;
294        builder.mode(0o700);
295        builder
296    };
297    builder
298        .create(&directory)
299        .map_err(|_| blocked("ERR_PRIVATE_FILE", "Log"))?;
300    Ok(directory)
301}
302
303fn log_with(source_path: &Path, env: &Environment, verbose: bool) -> Result<PreparedLog, String> {
304    // Static messages keep paths and system error details out of the output.
305    let directory = private_directory()?;
306    let prepared = PreparedLog {
307        path: directory.join("execution.log"),
308        directory,
309    };
310    let target = prepared.path.clone();
311    let source_error = |_| blocked("ERR_SOURCE", "Log");
312    let source = File::open(source_path).map_err(source_error)?;
313    let metadata = source.metadata().map_err(source_error)?;
314    if !metadata.is_file() {
315        return Err(blocked("ERR_SOURCE", "Log"));
316    }
317    let bytes = metadata.len();
318    let output = sanitizer_command(env, "Log")?
319        .arg("--stream")
320        // A NUL byte in a terminal transcript must not block the upload.
321        .arg("--allow-binary")
322        .args(OPTIONS)
323        .arg("--max-bytes")
324        .arg(bytes.max(1).to_string())
325        .arg("--output")
326        .arg(&target)
327        .stdin(Stdio::from(source))
328        .stdout(Stdio::null())
329        .stderr(Stdio::piped())
330        .output()
331        .map_err(|_| blocked("ERR_UNAVAILABLE", "Log"))?;
332    if !output.status.success() {
333        return Err(blocked(&error_code(&output.stderr), "Log"));
334    }
335    let metadata = fs::symlink_metadata(&target).map_err(|_| blocked("ERR_OUTPUT", "Log"))?;
336    #[cfg(unix)]
337    let private = {
338        use std::os::unix::fs::PermissionsExt;
339        metadata.permissions().mode() & 0o077 == 0
340    };
341    #[cfg(not(unix))]
342    let private = true;
343    if !metadata.is_file() || !private {
344        return Err(blocked("ERR_OUTPUT", "Log"));
345    }
346    if verbose {
347        let redactions = count_markers(&target)
348            .unwrap_or(0)
349            .saturating_sub(count_markers(source_path).unwrap_or(0));
350        println!(
351            "Prepared private sanitized log: {} bytes scanned, {} redactions.",
352            bytes, redactions
353        );
354    }
355    Ok(prepared)
356}
357
358fn report(result: Result<PreparedLog, String>, verbose: bool) -> Result<PreparedLog, String> {
359    if let (Err(message), true) = (&result, verbose) {
360        eprintln!("{}", message);
361    }
362    result
363}
364
365/// Create a private sanitized copy of a log. The source is never modified;
366/// any failure removes the copy and returns a value-free message.
367pub fn sanitize_log_to_temp(source_path: &Path, verbose: bool) -> Result<PreparedLog, String> {
368    report(
369        log_with(source_path, &Environment::Inherit, verbose),
370        verbose,
371    )
372}
373
374pub fn sanitize_log_to_temp_with_env(
375    source_path: &Path,
376    env: impl IntoIterator<Item = (String, String)>,
377    verbose: bool,
378) -> Result<PreparedLog, String> {
379    let env = Environment::Explicit(env.into_iter().collect());
380    report(log_with(source_path, &env, verbose), verbose)
381}