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