diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index ef66bdc7..4867f918 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -1598,10 +1598,18 @@ The CLI is implemented using clap's derive API in `src/cli.rs`. Clap's `default_value_t` attribute marks `Build` as the default subcommand, so invoking `netsuke` with no explicit command still triggers a build. CLI execution and dispatch live in `src/runner.rs`, keeping `main.rs` focused on -parsing. The working directory flag mirrors Ninja's `-C` option but is resolved -internally; Netsuke changes directory before spawning Ninja rather than -forwarding the flag. Error scenarios are validated using clap's `ErrorKind` -enumeration in unit tests and via Cucumber steps for behavioural coverage. +parsing. Process management, Ninja invocation, argument redaction, and the +temporary file helpers reside in `src/runner/process.rs`, allowing the runner +entry point to delegate low-level concerns. The working directory flag mirrors +Ninja's `-C` option but is resolved internally; Netsuke changes directory +before spawning Ninja rather than forwarding the flag. Error scenarios are +validated using clap's `ErrorKind` enumeration in unit tests and via Cucumber +steps for behavioural coverage. + +The Ninja executable may be overridden via the `NINJA_ENV` environment +variable. For example, `NINJA_ENV=/opt/ninja/bin/ninja netsuke build` forces +Netsuke to execute the specified binary while preserving the default when the +variable is unset or invalid. ### 8.5 Manual Pages diff --git a/src/runner.rs b/src/runner.rs index 59af4204..b0d9f594 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -7,11 +7,10 @@ use crate::cli::{BuildArgs, Cli, Commands}; use crate::{ir::BuildGraph, manifest, ninja_gen}; use anyhow::{Context, Result}; -use serde_json; +use camino::Utf8PathBuf; use std::borrow::Cow; -use std::fs; -use std::path::{Path, PathBuf}; -use tempfile::{Builder, NamedTempFile}; +use std::path::Path; +use tempfile::NamedTempFile; use tracing::{debug, info}; /// Default Ninja executable to invoke. @@ -20,7 +19,7 @@ pub const NINJA_PROGRAM: &str = "ninja"; pub use ninja_env::NINJA_ENV; mod process; -#[doc(hidden)] +#[cfg(doctest)] pub use process::doc; pub use process::run_ninja; @@ -85,7 +84,7 @@ pub fn run(cli: &Cli) -> Result<()> { Commands::Build(args) => handle_build(cli, &args), Commands::Manifest { file } => { let ninja = generate_ninja(cli)?; - write_ninja_file(&file, &ninja)?; + process::write_ninja_file(&file, &ninja)?; Ok(()) } Commands::Clean => { @@ -121,22 +120,18 @@ fn handle_build(cli: &Cli, args: &BuildArgs) -> Result<()> { // duration of the Ninja invocation. Borrow the emitted path when provided // to avoid unnecessary allocation. let build_path: Cow; - let mut tmp_file: Option = None; + let _tmp_file_guard: Option; if let Some(path) = &args.emit { - write_ninja_file(path, &ninja)?; + process::write_ninja_file(path, &ninja)?; build_path = Cow::Borrowed(path.as_path()); + _tmp_file_guard = None; } else { - let tmp = create_temp_ninja_file(&ninja)?; - tmp_file = Some(tmp); - build_path = Cow::Borrowed( - tmp_file - .as_ref() - .expect("temporary Ninja file should exist") - .path(), - ); + let tmp = process::create_temp_ninja_file(&ninja)?; + build_path = Cow::Owned(tmp.path().to_path_buf()); + _tmp_file_guard = Some(tmp); } - let program = resolve_ninja_program(); + let program = process::resolve_ninja_program(); run_ninja(program.as_path(), cli, build_path.as_ref(), &targets).with_context(|| { format!( "running {} with build file {}", @@ -144,53 +139,6 @@ fn handle_build(cli: &Cli, args: &BuildArgs) -> Result<()> { build_path.display() ) })?; - drop(tmp_file); - Ok(()) -} - -/// Create a temporary Ninja file on disk containing `content`. -/// -/// # Errors -/// -/// Returns an error if the file cannot be created or written. -/// -/// # Examples -/// ```ignore -/// use netsuke::runner::{create_temp_ninja_file, NinjaContent}; -/// let tmp = create_temp_ninja_file(&NinjaContent::new("".into())).unwrap(); -/// assert!(tmp.path().to_string_lossy().ends_with(".ninja")); -/// ``` -fn create_temp_ninja_file(content: &NinjaContent) -> Result { - let tmp = Builder::new() - .prefix("netsuke.") - .suffix(".ninja") - .tempfile() - .context("create temp file")?; - write_ninja_file(tmp.path(), content)?; - Ok(tmp) -} - -/// Write `content` to `path` and log the file's location. -/// -/// # Errors -/// -/// Returns an error if the file cannot be written. -/// -/// # Examples -/// ```ignore -/// let content = NinjaContent::new("rule cc\n".to_string()); -/// write_ninja_file(Path::new("out.ninja"), &content).unwrap(); -/// ``` -fn write_ninja_file(path: &Path, content: &NinjaContent) -> Result<()> { - // Ensure the parent directory exists; guard against empty components so we - // do not attempt to create the current directory on some platforms. - if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) { - fs::create_dir_all(parent) - .with_context(|| format!("failed to create parent directory {}", parent.display()))?; - } - fs::write(path, content.as_str()) - .with_context(|| format!("failed to write Ninja file to {}", path.display()))?; - info!("Generated Ninja file at {}", path.display()); Ok(()) } @@ -216,8 +164,8 @@ fn write_ninja_file(path: &Path, content: &NinjaContent) -> Result<()> { /// ``` fn generate_ninja(cli: &Cli) -> Result { let manifest_path = resolve_manifest_path(cli); - let manifest = manifest::from_path(&manifest_path) - .with_context(|| format!("loading manifest at {}", manifest_path.display()))?; + let manifest = manifest::from_path(manifest_path.as_std_path()) + .with_context(|| format!("loading manifest at {manifest_path}"))?; if tracing::enabled!(tracing::Level::DEBUG) { let ast_json = serde_json::to_string_pretty(&manifest).context("serialising manifest")?; debug!("AST:\n{ast_json}"); @@ -234,17 +182,17 @@ fn generate_ninja(cli: &Cli) -> Result { /// use crate::cli::Cli; /// use crate::runner::resolve_manifest_path; /// let cli = Cli { file: "Netsukefile".into(), directory: None, jobs: None, verbose: false, command: None }; -/// assert!(resolve_manifest_path(&cli).ends_with("Netsukefile")); +/// assert!(resolve_manifest_path(&cli).as_str().ends_with("Netsukefile")); /// ``` #[must_use] -fn resolve_manifest_path(cli: &Cli) -> std::path::PathBuf { - cli.directory - .as_ref() - .map_or_else(|| cli.file.clone(), |dir| dir.join(&cli.file)) -} - -/// Determine which Ninja executable to invoke. -#[must_use] -fn resolve_ninja_program() -> PathBuf { - std::env::var_os(NINJA_ENV).map_or_else(|| PathBuf::from(NINJA_PROGRAM), PathBuf::from) +fn resolve_manifest_path(cli: &Cli) -> Utf8PathBuf { + let file = + Utf8PathBuf::from_path_buf(cli.file.clone()).expect("manifest path must be valid UTF-8"); + if let Some(dir) = &cli.directory { + let base = Utf8PathBuf::from_path_buf(dir.clone()) + .expect("manifest directory must be valid UTF-8"); + base.join(file) + } else { + file + } } diff --git a/src/runner/process.rs b/src/runner/process.rs index dbb4216d..be18936b 100644 --- a/src/runner/process.rs +++ b/src/runner/process.rs @@ -1,115 +1,116 @@ -use super::BuildTargets; +//! Process helpers for Ninja file lifecycle, argument redaction, and subprocess I/O. +//! Internal to `runner`; public API is defined in `runner.rs`. + +use super::{BuildTargets, NINJA_PROGRAM}; use crate::cli::Cli; +use camino::Utf8PathBuf; +use ninja_env::NINJA_ENV; use std::{ - fs, - io::{self, BufRead, BufReader, Write}, - path::Path, - process::{Command, Stdio}, + env, + ffi::OsString, + io::{self, BufRead, BufReader, ErrorKind, Write}, + path::{Path, PathBuf}, + process::{Child, Command, ExitStatus, Stdio}, thread, }; use tracing::info; -#[derive(Debug, Clone)] -pub struct CommandArg(String); -impl CommandArg { - #[must_use] - pub fn new(arg: String) -> Self { - Self(arg) - } - #[must_use] - pub fn as_str(&self) -> &str { - &self.0 - } -} +mod file_io; +mod paths; +mod redaction; + +pub use file_io::*; +pub use paths::*; +// Re-export redaction helpers for doctests without leaking unused imports in release builds. +#[cfg_attr( + not(doctest), + allow(unused_imports, reason = "retain doctest re-exports") +)] +pub use redaction::*; + +use redaction::{CommandArg, redact_sensitive_args}; // Public helpers for doctests only. This exposes internal helpers as a stable // testing surface without exporting them in release builds. -#[doc(hidden)] +#[cfg(doctest)] pub mod doc { - pub use super::CommandArg; + pub use super::{ + CommandArg, contains_sensitive_keyword, create_temp_ninja_file, is_sensitive_arg, + redact_argument, redact_sensitive_args, resolve_ninja_program, resolve_ninja_program_utf8, + write_ninja_file, write_ninja_file_utf8, + }; +} - #[must_use] - pub fn contains_sensitive_keyword(arg: &CommandArg) -> bool { - super::contains_sensitive_keyword(arg) - } - #[must_use] - pub fn is_sensitive_arg(arg: &CommandArg) -> bool { - super::is_sensitive_arg(arg) - } - #[must_use] - pub fn redact_argument(arg: &CommandArg) -> CommandArg { - super::redact_argument(arg) - } - #[must_use] - pub fn redact_sensitive_args(args: &[CommandArg]) -> Vec { - super::redact_sensitive_args(args) - } +fn resolve_ninja_program_utf8_with(mut read_env: F) -> Utf8PathBuf +where + F: FnMut(&str) -> Option, +{ + read_env(NINJA_ENV) + .and_then(|value| { + let path = PathBuf::from(value); + Utf8PathBuf::from_path_buf(path).ok() + }) + .unwrap_or_else(|| Utf8PathBuf::from(NINJA_PROGRAM)) } -/// Check if `arg` contains a sensitive keyword. -/// -/// # Examples -/// ``` -/// # use netsuke::runner::doc::{CommandArg, contains_sensitive_keyword}; -/// assert!(contains_sensitive_keyword(&CommandArg::new("token=abc".into()))); -/// assert!(!contains_sensitive_keyword(&CommandArg::new("path=/tmp".into()))); -/// ``` -pub(crate) fn contains_sensitive_keyword(arg: &CommandArg) -> bool { - let lower = arg.as_str().to_lowercase(); - lower.contains("password") || lower.contains("token") || lower.contains("secret") +#[must_use] +pub fn resolve_ninja_program_utf8() -> Utf8PathBuf { + resolve_ninja_program_utf8_with(|key| env::var_os(key)) } -/// Determine whether the argument should be redacted. -/// Determine whether the argument should be redacted. -/// -/// # Examples -/// ``` -/// # use netsuke::runner::doc::{CommandArg, is_sensitive_arg}; -/// assert!(is_sensitive_arg(&CommandArg::new("password=123".into()))); -/// assert!(!is_sensitive_arg(&CommandArg::new("file=readme".into()))); -/// ``` -pub(crate) fn is_sensitive_arg(arg: &CommandArg) -> bool { - contains_sensitive_keyword(arg) +#[must_use] +pub fn resolve_ninja_program() -> PathBuf { + resolve_ninja_program_utf8().into() } -/// Redact sensitive information in a single argument. -/// -/// Sensitive values are replaced with `***REDACTED***`, preserving keys. -/// -/// # Examples -/// ``` -/// # use netsuke::runner::doc::{CommandArg, redact_argument}; -/// let arg = CommandArg::new("token=abc".into()); -/// assert_eq!(redact_argument(&arg).as_str(), "token=***REDACTED***"); -/// let arg = CommandArg::new("path=/tmp".into()); -/// assert_eq!(redact_argument(&arg).as_str(), "path=/tmp"); -/// ``` -pub(crate) fn redact_argument(arg: &CommandArg) -> CommandArg { - if is_sensitive_arg(arg) { - let redacted = arg.as_str().split_once('=').map_or_else( - || "***REDACTED***".to_string(), - |(key, _)| format!("{key}=***REDACTED***"), - ); - CommandArg::new(redacted) - } else { - arg.clone() +fn configure_ninja_command( + cmd: &mut Command, + cli: &Cli, + build_file: &Path, + targets: &BuildTargets<'_>, +) -> io::Result<()> { + if let Some(dir) = &cli.directory { + let canonical = canonicalize_utf8_path(dir.as_path())?; + cmd.current_dir(canonical.as_std_path()); } + if let Some(jobs) = cli.jobs { + cmd.arg("-j").arg(jobs.to_string()); + } + let build_file_path = canonicalize_utf8_path(build_file).or_else(|_| { + Utf8PathBuf::from_path_buf(build_file.to_path_buf()).map_err(|_| { + io::Error::new( + ErrorKind::InvalidData, + format!( + "build file path {} is not valid UTF-8", + build_file.display() + ), + ) + }) + })?; + cmd.arg("-f").arg(build_file_path.as_std_path()); + cmd.args(targets.as_slice()); + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + Ok(()) } -/// Redact sensitive information from all `args`. -/// -/// # Examples -/// ``` -/// # use netsuke::runner::doc::{CommandArg, redact_sensitive_args}; -/// let args = vec![ -/// CommandArg::new("ninja".into()), -/// CommandArg::new("token=abc".into()), -/// ]; -/// let redacted = redact_sensitive_args(&args); -/// assert_eq!(redacted[1].as_str(), "token=***REDACTED***"); -/// ``` -pub(crate) fn redact_sensitive_args(args: &[CommandArg]) -> Vec { - args.iter().map(redact_argument).collect() +fn log_command_execution(cmd: &Command) { + let program_path = PathBuf::from(cmd.get_program()); + let program_display = Utf8PathBuf::from_path_buf(program_path.clone()).map_or_else( + |_| program_path.to_string_lossy().into_owned(), + Utf8PathBuf::into_string, + ); + let args: Vec = cmd + .get_args() + .map(|a| CommandArg::new(a.to_string_lossy().into_owned())) + .collect(); + let redacted_args = redact_sensitive_args(&args); + let arg_strings: Vec<&str> = redacted_args.iter().map(CommandArg::as_str).collect(); + info!( + "Running command: {} {}", + program_display, + arg_strings.join(" ") + ); } /// Invoke the Ninja executable with the provided CLI settings. @@ -133,31 +134,14 @@ pub fn run_ninja( targets: &BuildTargets<'_>, ) -> io::Result<()> { let mut cmd = Command::new(program); - if let Some(dir) = &cli.directory { - let dir = fs::canonicalize(dir)?; - cmd.current_dir(dir); - } - if let Some(jobs) = cli.jobs { - cmd.arg("-j").arg(jobs.to_string()); - } - let build_file_path = build_file - .canonicalize() - .unwrap_or_else(|_| build_file.to_path_buf()); - cmd.arg("-f").arg(&build_file_path); - cmd.args(targets.as_slice()); - cmd.stdout(Stdio::piped()); - cmd.stderr(Stdio::piped()); - - let program = cmd.get_program().to_string_lossy().into_owned(); - let args: Vec = cmd - .get_args() - .map(|a| CommandArg::new(a.to_string_lossy().into_owned())) - .collect(); - let redacted_args = redact_sensitive_args(&args); - let arg_strings: Vec<&str> = redacted_args.iter().map(CommandArg::as_str).collect(); - info!("Running command: {} {}", program, arg_strings.join(" ")); + configure_ninja_command(&mut cmd, cli, build_file, targets)?; + log_command_execution(&cmd); + let child = cmd.spawn()?; + let status = spawn_and_stream_output(child)?; + check_exit_status(status) +} - let mut child = cmd.spawn()?; +fn spawn_and_stream_output(mut child: Child) -> io::Result { let stdout = child.stdout.take().expect("child stdout"); let stderr = child.stderr.take().expect("child stderr"); @@ -179,7 +163,10 @@ pub fn run_ninja( let status = child.wait()?; let _ = out_handle.join(); let _ = err_handle.join(); + Ok(status) +} +fn check_exit_status(status: ExitStatus) -> io::Result<()> { if status.success() { Ok(()) } else { @@ -193,3 +180,33 @@ pub fn run_ninja( )) } } + +#[cfg(test)] +mod tests { + use super::*; + use camino::Utf8PathBuf; + use std::ffi::OsString; + + #[test] + fn resolve_ninja_program_utf8_prefers_env_override() { + let resolved = resolve_ninja_program_utf8_with(|_| Some(OsString::from("/opt/ninja"))); + assert_eq!(resolved, Utf8PathBuf::from("/opt/ninja")); + } + + #[test] + fn resolve_ninja_program_utf8_defaults_without_override() { + let resolved = resolve_ninja_program_utf8_with(|_| None); + assert_eq!(resolved, Utf8PathBuf::from(NINJA_PROGRAM)); + } + + #[cfg(unix)] + #[test] + fn resolve_ninja_program_utf8_ignores_invalid_utf8_override() { + use std::os::unix::ffi::OsStringExt; + + let resolved = resolve_ninja_program_utf8_with(|_| { + Some(OsString::from_vec(vec![0xff, b'n', b'i', b'n', b'j', b'a'])) + }); + assert_eq!(resolved, Utf8PathBuf::from(NINJA_PROGRAM)); + } +} diff --git a/src/runner/process/file_io.rs b/src/runner/process/file_io.rs new file mode 100644 index 00000000..8b8bede0 --- /dev/null +++ b/src/runner/process/file_io.rs @@ -0,0 +1,146 @@ +//! File creation helpers for the Ninja runner. +//! Handles temporary build files and writes to capability-based directories. + +use crate::runner::NinjaContent; +use anyhow::{Context, Result as AnyResult, anyhow}; +use camino::{Utf8Path, Utf8PathBuf}; +use cap_std::{ambient_authority, fs as cap_fs}; +use std::io::Write; +use std::path::Path; +use tempfile::{Builder, NamedTempFile}; +use tracing::info; + +pub fn create_temp_ninja_file(content: &NinjaContent) -> AnyResult { + let mut tmp = Builder::new() + .prefix("netsuke.") + .suffix(".ninja") + .tempfile() + .context("create temp file")?; + { + let handle = tmp.as_file_mut(); + handle + .write_all(content.as_str().as_bytes()) + .context("write temp ninja file")?; + handle.flush().context("flush temp ninja file")?; + handle.sync_all().context("sync temp ninja file")?; + } + info!("Generated temporary Ninja file at {}", tmp.path().display()); + Ok(tmp) +} + +pub fn write_ninja_file_utf8( + dir: &cap_fs::Dir, + path: &Utf8Path, + content: &NinjaContent, +) -> AnyResult<()> { + if let Some(parent) = path.parent().filter(|p| !p.as_str().is_empty()) { + dir.create_dir_all(parent.as_str()) + .with_context(|| format!("failed to create parent directory {parent}"))?; + } + let mut file = dir + .create(path.as_str()) + .with_context(|| format!("failed to create Ninja file at {path}"))?; + file.write_all(content.as_str().as_bytes()) + .with_context(|| format!("failed to write Ninja file to {path}"))?; + file.flush() + .with_context(|| format!("failed to flush Ninja file at {path}"))?; + file.sync_all() + .with_context(|| format!("failed to sync Ninja file at {path}"))?; + Ok(()) +} + +fn derive_dir_and_relative(path: &Utf8Path) -> AnyResult<(cap_fs::Dir, Utf8PathBuf)> { + if path.is_relative() { + let dir = cap_fs::Dir::open_ambient_dir(".", ambient_authority()) + .context("open ambient directory")?; + return Ok((dir, path.to_owned())); + } + + let mut ancestors = path.ancestors(); + ancestors.next(); + let (base, dir) = ancestors + .find_map(|candidate| { + cap_fs::Dir::open_ambient_dir(candidate.as_str(), ambient_authority()) + .ok() + .map(|dir| (candidate.to_owned(), dir)) + }) + .ok_or_else(|| anyhow!("no existing ancestor for {path}"))?; + let relative = path + .strip_prefix(&base) + .context("derive relative Ninja path")? + .to_owned(); + Ok((dir, relative)) +} + +pub fn write_ninja_file(path: &Path, content: &NinjaContent) -> AnyResult<()> { + let utf8_path = + Utf8Path::from_path(path).ok_or_else(|| anyhow!("non-UTF-8 path is not supported"))?; + let (dir, relative) = derive_dir_and_relative(utf8_path)?; + write_ninja_file_utf8(&dir, &relative, content)?; + info!("Generated Ninja file at {utf8_path}"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::runner::NinjaContent; + use camino::Utf8PathBuf; + use cap_std::{ambient_authority, fs as cap_fs}; + use std::io::{Read, Seek, SeekFrom}; + + #[test] + fn create_temp_ninja_file_supports_reopen() { + let content = NinjaContent::new(String::from("rule cc")); + let file = create_temp_ninja_file(&content).expect("create temp file"); + + let mut reopened = file.reopen().expect("reopen temp file"); + let mut written = String::new(); + reopened + .read_to_string(&mut written) + .expect("read reopened temp file"); + assert_eq!(written, content.as_str()); + + let metadata = std::fs::metadata(file.path()).expect("query temp file metadata"); + assert_eq!(metadata.len(), content.as_str().len() as u64); + assert!(file.path().to_string_lossy().ends_with(".ninja")); + + reopened + .seek(SeekFrom::Start(0)) + .expect("rewind reopened temp file"); + written.clear(); + reopened + .read_to_string(&mut written) + .expect("re-read reopened temp file"); + assert_eq!(written, content.as_str()); + } + + #[test] + fn write_ninja_file_utf8_creates_parent_directories() { + let temp = tempfile::tempdir().expect("create temp dir"); + let dir = + cap_fs::Dir::open_ambient_dir(temp.path(), ambient_authority()).expect("open temp dir"); + let nested = Utf8PathBuf::from("nested/build.ninja"); + let content = NinjaContent::new(String::from("build all: phony")); + + write_ninja_file_utf8(&dir, &nested, &content).expect("write ninja file"); + + let nested_path = temp.path().join("nested").join("build.ninja"); + let written = std::fs::read_to_string(&nested_path).expect("read nested file"); + assert_eq!(written, content.as_str()); + assert!(nested_path.parent().expect("parent path").exists()); + } + + #[test] + fn write_ninja_file_handles_absolute_paths() { + let temp = tempfile::tempdir().expect("create temp dir"); + let nested = temp.path().join("nested").join("build.ninja"); + let content = NinjaContent::new(String::from("build all: phony")); + + write_ninja_file(&nested, &content).expect("write ninja file"); + + let written = std::fs::read_to_string(&nested).expect("read nested file"); + assert_eq!(written, content.as_str()); + assert!(nested.parent().expect("parent path").exists()); + } +} diff --git a/src/runner/process/paths.rs b/src/runner/process/paths.rs new file mode 100644 index 00000000..02928809 --- /dev/null +++ b/src/runner/process/paths.rs @@ -0,0 +1,70 @@ +//! Path resolution helpers for the Ninja runner. +//! Canonicalises UTF-8 paths via capability-based handles. + +use camino::{Utf8Path, Utf8PathBuf}; +use cap_std::{ambient_authority, fs as cap_fs}; +use std::io::{self, ErrorKind}; +use std::path::{Path, PathBuf}; + +pub fn canonicalize_utf8_path(path: &Path) -> io::Result { + let utf8 = Utf8Path::from_path(path).ok_or_else(|| { + io::Error::new( + ErrorKind::InvalidData, + format!("path {} is not valid UTF-8", path.display()), + ) + })?; + + if utf8.as_str().is_empty() || utf8 == Utf8Path::new(".") { + return canonicalize_current_dir(); + } + + if utf8.parent().is_none() && utf8.file_name().is_none() { + return Ok(canonicalize_root_path(utf8)); + } + + if utf8.is_relative() { + return canonicalize_relative_path(utf8); + } + + canonicalize_absolute_path(utf8) +} + +fn canonicalize_current_dir() -> io::Result { + let dir = cap_fs::Dir::open_ambient_dir(".", ambient_authority())?; + let resolved = dir.canonicalize(Path::new("."))?; + convert_path_to_utf8(resolved, Utf8Path::new(".")) +} + +fn canonicalize_root_path(utf8: &Utf8Path) -> Utf8PathBuf { + utf8.to_path_buf() +} + +fn canonicalize_relative_path(utf8: &Utf8Path) -> io::Result { + let dir = cap_fs::Dir::open_ambient_dir(".", ambient_authority())?; + let resolved = dir.canonicalize(utf8.as_std_path())?; + convert_path_to_utf8(resolved, utf8) +} + +fn canonicalize_absolute_path(utf8: &Utf8Path) -> io::Result { + let parent = utf8.parent().unwrap_or_else(|| Utf8Path::new("/")); + let handle = cap_fs::Dir::open_ambient_dir(parent.as_std_path(), ambient_authority())?; + let relative = utf8.strip_prefix(parent).unwrap_or(utf8); + let resolved = handle.canonicalize(relative.as_std_path())?; + let canonical = convert_path_to_utf8(resolved, relative)?; + if canonical.is_absolute() { + Ok(canonical) + } else { + let mut absolute = parent.to_path_buf(); + absolute.push(&canonical); + Ok(absolute) + } +} + +fn convert_path_to_utf8(buf: PathBuf, reference: &Utf8Path) -> io::Result { + Utf8PathBuf::from_path_buf(buf).map_err(|_| { + io::Error::new( + ErrorKind::InvalidData, + format!("canonical path for {reference} is not valid UTF-8"), + ) + }) +} diff --git a/src/runner/process/redaction.rs b/src/runner/process/redaction.rs new file mode 100644 index 00000000..3ea52772 --- /dev/null +++ b/src/runner/process/redaction.rs @@ -0,0 +1,135 @@ +//! Argument redaction helpers for the Ninja runner. +//! Provides the `CommandArg` wrapper used by doctests and logging. + +#[derive(Debug, Clone)] +pub struct CommandArg(String); +impl CommandArg { + #[must_use] + pub fn new(arg: String) -> Self { + Self(arg) + } + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +fn is_sensitive_key(key: &str) -> bool { + const SENSITIVE_KEYS: [&str; 7] = [ + "password", + "token", + "secret", + "api_key", + "apikey", + "auth", + "authorization", + ]; + SENSITIVE_KEYS + .iter() + .any(|candidate| key.eq_ignore_ascii_case(candidate)) +} + +/// Check if `arg` contains a sensitive keyword. +/// +/// # Examples +/// ``` +/// # #[cfg(doctest)] +/// # use netsuke::runner::doc::{CommandArg, contains_sensitive_keyword}; +/// assert!(contains_sensitive_keyword(&CommandArg::new("token=abc".into()))); +/// assert!(!contains_sensitive_keyword(&CommandArg::new("path=/tmp".into()))); +/// ``` +#[must_use] +pub fn contains_sensitive_keyword(arg: &CommandArg) -> bool { + arg.as_str() + .split_once('=') + .is_some_and(|(key, _)| is_sensitive_key(key.trim())) +} + +/// Determine whether the argument should be redacted. +/// +/// # Examples +/// ``` +/// # #[cfg(doctest)] +/// # use netsuke::runner::doc::{CommandArg, is_sensitive_arg}; +/// assert!(is_sensitive_arg(&CommandArg::new("password=123".into()))); +/// assert!(!is_sensitive_arg(&CommandArg::new("file=readme".into()))); +/// ``` +#[must_use] +pub fn is_sensitive_arg(arg: &CommandArg) -> bool { + contains_sensitive_keyword(arg) +} + +/// Redact sensitive information in a single argument. +/// +/// Sensitive values are replaced with `***REDACTED***`, preserving keys. +/// +/// # Examples +/// ``` +/// # #[cfg(doctest)] +/// # use netsuke::runner::doc::{CommandArg, redact_argument}; +/// let arg = CommandArg::new("token=abc".into()); +/// assert_eq!(redact_argument(&arg).as_str(), "token=***REDACTED***"); +/// let arg = CommandArg::new("path=/tmp".into()); +/// assert_eq!(redact_argument(&arg).as_str(), "path=/tmp"); +/// ``` +#[must_use] +pub fn redact_argument(arg: &CommandArg) -> CommandArg { + if is_sensitive_arg(arg) { + if let Some((key, _)) = arg.as_str().split_once('=') { + let trimmed = key.trim(); + return CommandArg::new(format!("{trimmed}=***REDACTED***")); + } + return CommandArg::new(String::from("***REDACTED***")); + } + arg.clone() +} + +/// Redact sensitive information from all `args`. +/// +/// # Examples +/// ``` +/// # #[cfg(doctest)] +/// # use netsuke::runner::doc::{CommandArg, redact_sensitive_args}; +/// let args = vec![ +/// CommandArg::new("ninja".into()), +/// CommandArg::new("token=abc".into()), +/// ]; +/// let redacted = redact_sensitive_args(&args); +/// assert_eq!(redacted[1].as_str(), "token=***REDACTED***"); +/// ``` +#[must_use] +pub fn redact_sensitive_args(args: &[CommandArg]) -> Vec { + args.iter().map(redact_argument).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn contains_sensitive_keyword_only_flags_known_keys() { + let token = CommandArg::new(String::from("token=abc")); + assert!(contains_sensitive_keyword(&token)); + + let positional = CommandArg::new(String::from("secrets.yml")); + assert!(!contains_sensitive_keyword(&positional)); + + let path_arg = CommandArg::new(String::from("path=/tmp/secrets.yml")); + assert!(!contains_sensitive_keyword(&path_arg)); + + let spaced = CommandArg::new(String::from(" PASSWORD = value ")); + assert!(contains_sensitive_keyword(&spaced)); + } + + #[test] + fn redact_argument_preserves_non_sensitive_pairs() { + let redacted = redact_argument(&CommandArg::new(String::from("auth = token123"))); + assert_eq!(redacted.as_str(), "auth=***REDACTED***"); + + let untouched = redact_argument(&CommandArg::new(String::from("path=/var/secrets"))); + assert_eq!(untouched.as_str(), "path=/var/secrets"); + + let positional = redact_argument(&CommandArg::new(String::from("secret"))); + assert_eq!(positional.as_str(), "secret"); + } +}