1use 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";
30pub const PACKAGE: &str = "@link-foundation/sensitive-data-sanitizer@^0.14.0";
32pub const MINIMUM_VERSION: (u64, u64, u64) = (0, 14, 0);
34pub const OVERRIDE: &str = "START_SENSITIVE_DATA_SANITIZER";
36const OPTIONS: [&str; 3] = ["--credentials-only", "--env-secrets", "--gh-config"];
37
38fn 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
51enum 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 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
115pub fn find_sanitizer() -> Option<PathBuf> {
117 resolve(&Environment::Inherit)
118}
119
120pub 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
140fn 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
151pub 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
239pub 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
251pub 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 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 .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
365pub 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}