1use 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";
26pub const PACKAGE: &str = "@link-foundation/sensitive-data-sanitizer@^0.12.2";
28pub const MINIMUM_VERSION: (u64, u64, u64) = (0, 12, 2);
30pub const OVERRIDE: &str = "START_SENSITIVE_DATA_SANITIZER";
32const OPTIONS: [&str; 3] = ["--credentials-only", "--env-secrets", "--gh-config"];
33
34fn 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
47enum 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 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
111pub fn find_sanitizer() -> Option<PathBuf> {
113 resolve(&Environment::Inherit)
114}
115
116pub 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
136fn 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
147pub 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
235pub 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
247pub 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 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 .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
361pub 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}