//! Build runner — dispatches and executes OTA builds via SSH to remote hosts. //! //! The scheduler calls `dispatch_pending_build()` each tick. If no build is //! running and one is pending, it spawns a `tokio::spawn` task that SSHes to //! the appropriate build host, clones, builds, signs, and uploads artifacts. use std::time::Duration; use crate::constants::{BUILD_MAX_LOG_BYTES, BUILD_TIMEOUT_SECS}; use crate::db::{self, BuildStatus, DbBuild, DbBuildConfig}; use crate::AppState; /// Post-receive hook script template. /// `__HMAC__` is replaced with a per-repo HMAC signature so the global token /// is never stored on disk. The server verifies via `HMAC(token, owner:repo)`. /// /// Both curl calls run backgrounded so they never block the git push, but /// their stdout+stderr is appended to `hooks/post-receive.log` next to this /// script. A non-zero curl exit also writes a "FAILED" line with the exit /// code, so a build that never triggers is diagnosable from the repo rather /// than from "why didn't anything happen." The log is append-only and grows /// unbounded; truncate or rotate via the host's logrotate. const POST_RECEIVE_HOOK_TEMPLATE: &str = r#"#!/bin/bash REPO_PATH="$(cd "$(dirname "$0")/.." && pwd)" LOG="$REPO_PATH/hooks/post-receive.log" REPO_NAME="$(basename "$REPO_PATH" .git)" OWNER="$(basename "$(dirname "$REPO_PATH")")" while read oldrev newrev refname; do case "$refname" in refs/tags/v[0-9]*) TAG="${refname#refs/tags/}" ( exec >>"$LOG" 2>&1 echo "[$(date -u +%FT%TZ)] tag-push $OWNER/$REPO_NAME tag=$TAG" curl -sf -X POST \ -H "Authorization: Bearer __HMAC__" \ -H "Content-Type: application/json" \ -d "{\"repo_owner\": \"$OWNER\", \"repo_name\": \"$REPO_NAME\", \"tag\": \"$TAG\"}" \ "http://localhost:3000/api/internal/builds/trigger" \ || echo "[$(date -u +%FT%TZ)] FAILED builds/trigger exit=$?" ) & ;; refs/heads/*) BRANCH="${refname#refs/heads/}" ( exec >>"$LOG" 2>&1 echo "[$(date -u +%FT%TZ)] branch-push $OWNER/$REPO_NAME branch=$BRANCH" curl -sf -X POST \ -H "Authorization: Bearer __HMAC__" \ -H "Content-Type: application/json" \ -d "{\"repo_owner\": \"$OWNER\", \"repo_name\": \"$REPO_NAME\", \"ref_name\": \"$BRANCH\", \"before\": \"$oldrev\", \"after\": \"$newrev\"}" \ "http://localhost:3000/api/internal/issues/process-push" \ || echo "[$(date -u +%FT%TZ)] FAILED issues/process-push exit=$?" ) & ;; esac done "#; /// Compute a per-repo HMAC so the global token never touches disk. pub fn repo_hmac(token: &str, owner: &str, repo: &str) -> String { use hmac::{Hmac, Mac}; use sha2::Sha256; let mut mac = Hmac::::new_from_slice(token.as_bytes()) .expect("HMAC accepts any key length"); mac.update(format!("{owner}:{repo}").as_bytes()); hex::encode(mac.finalize().into_bytes()) } /// Generate the post-receive hook script with a per-repo HMAC signature. pub fn post_receive_hook(token: &str, owner: &str, repo: &str) -> String { let hmac = repo_hmac(token, owner, repo); POST_RECEIVE_HOOK_TEMPLATE.replace("__HMAC__", &hmac) } /// Map (os, arch) to a Rust target triple. pub fn rust_target(os: &str, arch: &str) -> Option<&'static str> { match (os, arch) { ("linux", "x86_64") => Some("x86_64-unknown-linux-gnu"), ("linux", "aarch64") => Some("aarch64-unknown-linux-gnu"), ("darwin", "x86_64") => Some("x86_64-apple-darwin"), ("darwin", "aarch64") => Some("aarch64-apple-darwin"), _ => None, } } /// Get the SSH build host for a target OS from config. fn build_host_for_target<'a>(config: &'a crate::config::Config, os: &str) -> Option<&'a str> { match os { "linux" => config.build_host_linux.as_deref(), "darwin" => config.build_host_darwin.as_deref(), _ => None, } } /// Check for a pending build and spawn it if no build is currently running. /// /// Called from the scheduler loop. Non-blocking — spawns the build task and returns. #[tracing::instrument(skip_all, name = "build_runner::dispatch")] pub async fn dispatch_pending_build(state: &AppState) { // Recover from stale running builds (e.g. server crashed mid-build) match db::builds::fail_stale_running_builds(&state.db, BUILD_TIMEOUT_SECS as i64).await { Ok(n) if n > 0 => { tracing::warn!(count = n, "marked stale running builds as failed"); } Err(e) => { tracing::error!(error = ?e, "failed to check stale builds"); } _ => {} } let build = match db::builds::claim_pending_build(&state.db).await { Ok(Some(b)) => b, Ok(None) => return, Err(e) => { tracing::error!(error = ?e, "failed to claim pending build"); return; } }; let config = match db::builds::get_build_config_by_app(&state.db, build.app_id).await { Ok(Some(c)) => c, Ok(None) => { tracing::error!(build_id = %build.id, "build config not found for pending build"); if let Err(e) = db::builds::update_build_status( &state.db, build.id, BuildStatus::Failed, Some("Build config not found"), ) .await { tracing::error!(build_id = %build.id, error = ?e, "failed to mark build as failed (config not found)"); } return; } Err(e) => { tracing::error!(error = ?e, "failed to get build config"); return; } }; let state = state.clone(); tokio::spawn(async move { run_build(&state, &build, &config).await; }); } fn build_failure_message(succeeded: usize, failed: usize, first_error: Option<&str>) -> String { if succeeded == 0 { first_error.unwrap_or("no targets produced artifacts").to_string() } else { let total = succeeded + failed; format!("partial build failure ({succeeded}/{total} targets succeeded)") } } /// Execute a full build: iterate targets, SSH to hosts, build, upload artifacts. #[tracing::instrument(skip_all, name = "build_runner::run_build", fields(build_id = %build.id, version = %build.version))] async fn run_build(state: &AppState, build: &DbBuild, config: &DbBuildConfig) { let mut artifact_keys: Vec<(String, String, String, String)> = Vec::new(); // (target_os, arch, s3_key, signature) let mut failed_count: usize = 0; let mut first_error: Option = None; for target_str in &config.targets { let Some((target_os, arch)): Option<(&str, &str)> = target_str.split_once('/') else { let msg = format!("invalid target format: {target_str}\n"); let _ = append_log_bounded(state, build.id, &msg).await; failed_count += 1; if first_error.is_none() { first_error = Some(format!("invalid target format: {target_str}")); } continue; }; let host = match build_host_for_target(&state.config, target_os) { Some(h) => h, None => { let msg = format!("no build host for {target_os}, skipping {target_str}\n"); tracing::warn!("{}", msg.trim()); let _ = append_log_bounded(state, build.id, &msg).await; failed_count += 1; if first_error.is_none() { first_error = Some(format!("no build host for {target_os}")); } continue; } }; match execute_target(state, build, config, host, target_os, arch).await { Ok((s3_key, signature)) => { artifact_keys.push((target_os.to_string(), arch.to_string(), s3_key, signature)); } Err(e) => { let msg = format!("target {target_str} failed: {e}\n"); tracing::error!("{}", msg.trim()); let _ = append_log_bounded(state, build.id, &msg).await; failed_count += 1; if first_error.is_none() { first_error = Some(e); } } } } if artifact_keys.is_empty() || failed_count > 0 { let err_msg = build_failure_message(artifact_keys.len(), failed_count, first_error.as_deref()); if let Err(e) = db::builds::update_build_status( &state.db, build.id, BuildStatus::Failed, Some(&err_msg), ) .await { tracing::error!(build_id = %build.id, error = ?e, "failed to mark build as failed"); } if let Some(ref wam) = state.wam { let title = format!("Build failed: {} v{}", build.tag, build.version); wam.create_ticket(&title, Some(&err_msg), "high", "build-failed", Some(&build.id.to_string())).await; } return; } // Use signature from the first artifact that has one (release-level field) let release_signature = artifact_keys .iter() .find(|(_, _, _, sig)| !sig.is_empty()) .map(|(_, _, _, sig)| sig.as_str()) .unwrap_or(""); // Create OTA release (only for fully successful builds) let release = match db::ota::create_release( &state.db, build.app_id, &build.version, &format!("Automated build from tag {}", build.tag), release_signature, ) .await { Ok(r) => r, Err(e) => { let msg = format!("failed to create OTA release: {e}"); if let Err(e) = db::builds::update_build_status( &state.db, build.id, BuildStatus::Failed, Some(&msg), ) .await { tracing::error!(build_id = %build.id, error = ?e, "failed to mark build as failed (release creation)"); } return; } }; // Record artifacts for (target_os, arch, s3_key, _signature) in &artifact_keys { // Get file size from S3 via HEAD request (best-effort, use 0 if unavailable) let file_size = if let Some(s3) = state.synckit_s3.as_ref() { s3.object_size(s3_key).await.ok().flatten().unwrap_or(0) } else { 0 }; if let Err(e) = db::ota::create_artifact(&state.db, release.id, target_os, arch, s3_key, file_size) .await { tracing::error!(error = ?e, "failed to record artifact"); } } // Link build to release if let Err(e) = db::builds::set_build_release(&state.db, build.id, release.id).await { tracing::error!(build_id = %build.id, release_id = %release.id, error = ?e, "failed to link build to release"); } // All targets succeeded (partial failures return early above) if let Err(e) = db::builds::update_build_status( &state.db, build.id, BuildStatus::Succeeded, None, ) .await { tracing::error!(build_id = %build.id, error = ?e, "failed to mark build as succeeded"); } tracing::info!( build_id = %build.id, version = %build.version, artifacts = artifact_keys.len(), "build succeeded" ); } /// Execute a single target: SSH to host, clone, build, upload artifact. async fn execute_target( state: &AppState, build: &DbBuild, config: &DbBuildConfig, host: &str, target_os: &str, arch: &str, ) -> std::result::Result<(String, String), String> { let target = format!("{target_os}/{arch}"); let rust_triple = rust_target(target_os, arch) .ok_or_else(|| format!("unsupported target: {target}"))?; // Look up repo for clone URL let repo = db::git_repos::get_repo_by_id(&state.db, config.repo_id) .await .map_err(|e| format!("failed to look up repo: {e}"))? .ok_or("repo not found")?; let repo_owner = db::users::get_user_by_id(&state.db, repo.user_id) .await .map_err(|e| format!("failed to look up repo owner: {e}"))? .ok_or("repo owner not found")?; let git_root = state .config .git_repos_path .as_deref() .ok_or("git_repos_path not configured")?; let clone_path = format!("{git_root}/{}/{}.git", repo_owner.username, repo.name); let build_dir = format!("/tmp/mnw-build-{}", build.id); // Template substitution for build_command and artifact_path let build_cmd = config .build_command .replace("{target}", rust_triple) .replace("{version}", &build.version); let artifact_path = config .artifact_path .replace("{target}", rust_triple) .replace("{version}", &build.version); // Validate build_command and artifact_path before interpolation into shell validate_build_command(&build_cmd) .map_err(|e| format!("invalid build command: {e}"))?; validate_artifact_path(&artifact_path) .map_err(|e| format!("invalid artifact path: {e}"))?; // Build the SSH command sequence // Note: build_cmd is validated (no shell metacharacters beyond safe set) but // intentionally NOT shell-escaped since it must execute as a shell command. // artifact_path is validated AND shell-escaped since it's used as a file path. let remote_script = format!( "set -e && \ git clone --depth 1 --branch {tag} {clone_path} {build_dir} && \ cd {build_dir} && \ {build_cmd} && \ test -f {artifact_path}", tag = shell_escape(&build.tag), clone_path = shell_escape(&clone_path), build_dir = shell_escape(&build_dir), build_cmd = build_cmd, artifact_path = shell_escape(&artifact_path), ); let log_msg = format!("[{target}] building on {host}...\n"); let _ = append_log_bounded(state, build.id, &log_msg).await; // Execute via SSH with timeout let ssh_result = tokio::time::timeout( Duration::from_secs(BUILD_TIMEOUT_SECS), run_ssh_command(host, &remote_script), ) .await; let output = match ssh_result { Ok(Ok(output)) => output, Ok(Err(e)) => { // Cleanup remote build dir (best-effort) let _ = run_ssh_command(host, &format!("rm -rf {}", shell_escape(&build_dir))).await; return Err(format!("SSH command failed: {e}")); } Err(_) => { let _ = run_ssh_command(host, &format!("rm -rf {}", shell_escape(&build_dir))).await; return Err("build timed out".to_string()); } }; let _ = append_log_bounded(state, build.id, &format!("[{target}] {}\n", output.trim())).await; // SCP artifact back and upload to S3 let s3_key = format!("ota/{}/{}/{target_os}/{arch}/artifact", build.app_id, build.version); // Copy artifact from remote to local temp let local_tmp = format!("/tmp/mnw-artifact-{}-{target_os}-{arch}", build.id); let scp_remote_path = format!( "{}/{}", build_dir.trim_end_matches('/'), artifact_path.trim_start_matches('/') ); let scp_result = run_scp_download(host, &scp_remote_path, &local_tmp).await; // Best-effort: try to download the .sig file (Tauri builds produce one) let local_sig_tmp = format!("{local_tmp}.sig"); let scp_sig_result = run_scp_download(host, &format!("{scp_remote_path}.sig"), &local_sig_tmp).await; // Cleanup remote build dir let _ = run_ssh_command(host, &format!("rm -rf {}", shell_escape(&build_dir))).await; if let Err(e) = scp_result { // The main artifact failed, but the .sig sidecar may already be on disk // from its own scp above. Clean it up before bailing so a retry loop // doesn't accumulate orphaned .sig temp files (the main temp is removed // unconditionally further down, but on this early return it was never // created). let _ = tokio::fs::remove_file(&local_sig_tmp).await; return Err(format!("SCP download failed: {e}")); } // Read signature from .sig file if it was downloaded let signature = if scp_sig_result.is_ok() { let sig = tokio::fs::read_to_string(&local_sig_tmp) .await .unwrap_or_default(); let _ = tokio::fs::remove_file(&local_sig_tmp).await; sig } else { String::new() }; // Upload to S3 via multipart streaming from disk — the previous // implementation `tokio::fs::read` → `Vec` → `upload_object` pinned // the entire artifact (up to ~100 MB per build) in RAM during upload. // `upload_multipart` reads the file in chunks and lets the S3 SDK do // parallel part uploads, keeping memory bounded regardless of artifact // size. let synckit_s3 = state .synckit_s3 .as_ref() .ok_or("SyncKit storage not configured")?; let upload_result = synckit_s3 .upload_multipart( &s3_key, "application/octet-stream", std::path::Path::new(&local_tmp), ) .await .map_err(|e| format!("S3 multipart upload failed: {e}")); // Always remove the local temp file, even if the upload failed — leaving // it on disk fills the build runner's tmp directory across retries. let _ = tokio::fs::remove_file(&local_tmp).await; upload_result?; if !signature.is_empty() { let _ = append_log_bounded( state, build.id, &format!("[{target}] uploaded to {s3_key} (signed)\n"), ) .await; } else { let _ = append_log_bounded( state, build.id, &format!("[{target}] uploaded to {s3_key}\n"), ) .await; } Ok((s3_key, signature)) } /// Path to a known_hosts file for build SSH connections. /// When present, StrictHostKeyChecking=yes is used (pinned keys). /// When absent, StrictHostKeyChecking=accept-new (trust on first use). const BUILD_SSH_KNOWN_HOSTS: &str = "/etc/mnw/known_hosts"; /// Run a command on a remote host via SSH. async fn run_ssh_command(host: &str, command: &str) -> std::result::Result { let mut args = vec!["-o", "ConnectTimeout=10", "-o", "BatchMode=yes"]; let known_hosts_arg; if std::path::Path::new(BUILD_SSH_KNOWN_HOSTS).exists() { args.extend(["-o", "StrictHostKeyChecking=yes", "-o"]); known_hosts_arg = format!("UserKnownHostsFile={BUILD_SSH_KNOWN_HOSTS}"); args.push(&known_hosts_arg); } else { args.extend(["-o", "StrictHostKeyChecking=accept-new"]); } args.push(host); args.push(command); let output = tokio::process::Command::new("ssh") .args(&args) .output() .await .map_err(|e| format!("failed to spawn ssh: {e}"))?; if output.status.success() { Ok(String::from_utf8_lossy(&output.stdout).to_string()) } else { let stderr = String::from_utf8_lossy(&output.stderr); Err(format!( "exit code {}: {}", output.status.code().unwrap_or(-1), stderr.trim() )) } } /// Download a file from a remote host via SCP. async fn run_scp_download( host: &str, remote_path: &str, local_path: &str, ) -> std::result::Result<(), String> { let remote = format!("{host}:{remote_path}"); let mut args: Vec<&str> = vec!["-o", "ConnectTimeout=10", "-o", "BatchMode=yes"]; let known_hosts_arg; if std::path::Path::new(BUILD_SSH_KNOWN_HOSTS).exists() { args.extend(["-o", "StrictHostKeyChecking=yes", "-o"]); known_hosts_arg = format!("UserKnownHostsFile={BUILD_SSH_KNOWN_HOSTS}"); args.push(&known_hosts_arg); } else { args.extend(["-o", "StrictHostKeyChecking=accept-new"]); } args.push(&remote); args.push(local_path); let output = tokio::process::Command::new("scp") .args(&args) .output() .await .map_err(|e| format!("failed to spawn scp: {e}"))?; if output.status.success() { Ok(()) } else { let stderr = String::from_utf8_lossy(&output.stderr); Err(format!( "exit code {}: {}", output.status.code().unwrap_or(-1), stderr.trim() )) } } /// Append to build log, respecting the max log size. /// /// Probes `octet_length(log)` instead of fetching the whole row (the log /// column tops out at 5 MiB and is read on every line append). async fn append_log_bounded( state: &AppState, build_id: db::BuildId, line: &str, ) -> crate::error::Result<()> { const TRUNCATED: &str = "[log truncated]\n"; if let Some((current_len, already_truncated)) = db::builds::get_build_log_size(&state.db, build_id, TRUNCATED).await? && (current_len as usize) + line.len() > BUILD_MAX_LOG_BYTES { if !already_truncated { tracing::warn!(build_id = %build_id, "Build log exceeded {} bytes, truncating", BUILD_MAX_LOG_BYTES); db::builds::append_build_log(&state.db, build_id, TRUNCATED).await?; } return Ok(()); } let sanitized = strip_ansi_escapes(line); db::builds::append_build_log(&state.db, build_id, &sanitized).await } /// Strip ANSI escape sequences (e.g. color codes) from build output before /// storing it in the database. fn strip_ansi_escapes(s: &str) -> String { let mut result = String::with_capacity(s.len()); let mut chars = s.chars(); while let Some(c) = chars.next() { if c == '\x1b' { // Consume the next char; if it's '[' we have a CSI sequence // and we skip parameter/intermediate bytes up to the final byte. // Otherwise (OSC / other sequences) just drop the two-char escape. if let Some(next) = chars.next() && next == '[' { // CSI sequence: skip until we hit a letter (0x40..=0x7E). for tail in chars.by_ref() { if tail.is_ascii_alphabetic() { break; } } } } else { result.push(c); } } result } /// Validate a build command for shell safety. /// /// Rejects shell metacharacters that enable command chaining or redirection. /// Allowed: alphanumeric, spaces, hyphens, underscores, dots, slashes, equals, /// braces (for template vars), colons, commas, plus signs. pub fn validate_build_command(cmd: &str) -> std::result::Result<(), String> { if cmd.is_empty() { return Err("build command is empty".to_string()); } if cmd.len() > 1024 { return Err("build command too long (max 1024 chars)".to_string()); } for (i, c) in cmd.chars().enumerate() { match c { 'a'..='z' | 'A'..='Z' | '0'..='9' => {} ' ' | '-' | '_' | '.' | '/' | '=' | ':' | ',' | '+' | '{' | '}' | '@' => {} ';' | '&' | '|' | '$' | '`' | '(' | ')' | '<' | '>' | '!' | '\\' | '\'' | '"' | '\n' | '\r' | '\0' => { return Err(format!( "shell metacharacter '{}' at position {} is not allowed", c.escape_default(), i )); } _ => { return Err(format!( "unexpected character '{}' at position {} is not allowed", c.escape_default(), i )); } } } Ok(()) } /// Validate an artifact path for shell and path safety. /// /// Must be a relative path with no shell metacharacters or path traversal. pub fn validate_artifact_path(path: &str) -> std::result::Result<(), String> { if path.is_empty() { return Err("artifact path is empty".to_string()); } if path.len() > 512 { return Err("artifact path too long (max 512 chars)".to_string()); } if path.starts_with('/') { return Err("artifact path must be relative".to_string()); } if path.contains("..") { return Err("artifact path must not contain '..'".to_string()); } for (i, c) in path.chars().enumerate() { match c { 'a'..='z' | 'A'..='Z' | '0'..='9' => {} '-' | '_' | '.' | '/' | '{' | '}' | '+' => {} ';' | '&' | '|' | '$' | '`' | '(' | ')' | '<' | '>' | '!' | '\\' | '\'' | '"' | ' ' | '\n' | '\r' | '\0' => { return Err(format!( "character '{}' at position {} is not allowed in artifact path", c.escape_default(), i )); } _ => { return Err(format!( "unexpected character '{}' at position {} is not allowed in artifact path", c.escape_default(), i )); } } } Ok(()) } /// Escape a string for safe use in a shell command. fn shell_escape(s: &str) -> String { format!("'{}'", s.replace('\'', "'\\''")) } #[cfg(test)] mod tests { use super::*; #[test] fn build_failure_message_partial() { assert_eq!( build_failure_message(1, 2, Some("boom")), "partial build failure (1/3 targets succeeded)" ); assert_eq!( build_failure_message(2, 1, Some("boom")), "partial build failure (2/3 targets succeeded)" ); } #[test] fn build_failure_message_total_failure_uses_first_error() { assert_eq!(build_failure_message(0, 3, Some("ssh down")), "ssh down"); assert_eq!(build_failure_message(0, 0, None), "no targets produced artifacts"); } #[test] fn rust_target_mapping() { assert_eq!(rust_target("linux", "x86_64"), Some("x86_64-unknown-linux-gnu")); assert_eq!(rust_target("linux", "aarch64"), Some("aarch64-unknown-linux-gnu")); assert_eq!(rust_target("darwin", "x86_64"), Some("x86_64-apple-darwin")); assert_eq!(rust_target("darwin", "aarch64"), Some("aarch64-apple-darwin")); assert_eq!(rust_target("windows", "x86_64"), None); } #[test] fn hook_template_contains_hmac_not_raw_token() { let hook = post_receive_hook("secret-token-123", "alice", "myrepo"); let expected_hmac = repo_hmac("secret-token-123", "alice", "myrepo"); assert!(hook.contains(&expected_hmac), "hook should contain per-repo HMAC"); assert!(!hook.contains("secret-token-123"), "hook must not contain raw token"); assert!(!hook.contains("__HMAC__"), "placeholder should be replaced"); assert!(hook.contains("/api/internal/builds/trigger")); } #[test] fn repo_hmac_differs_per_repo() { let h1 = repo_hmac("token", "alice", "repo-a"); let h2 = repo_hmac("token", "alice", "repo-b"); assert_ne!(h1, h2, "different repos should produce different HMACs"); } #[test] fn shell_escape_basic() { assert_eq!(shell_escape("hello"), "'hello'"); assert_eq!(shell_escape("it's"), "'it'\\''s'"); } #[test] fn validate_build_command_accepts_safe_commands() { assert!(validate_build_command("cargo build --release --target x86_64-unknown-linux-gnu").is_ok()); assert!(validate_build_command("make -j4").is_ok()); assert!(validate_build_command("RUSTFLAGS=--cfg tokio_unstable cargo build").is_ok()); } #[test] fn validate_build_command_rejects_injection() { assert!(validate_build_command("cargo build; curl evil.com").is_err()); assert!(validate_build_command("cargo build && rm -rf /").is_err()); assert!(validate_build_command("cargo build | tee log").is_err()); assert!(validate_build_command("$(whoami)").is_err()); assert!(validate_build_command("`whoami`").is_err()); assert!(validate_build_command("cargo build > /dev/null").is_err()); assert!(validate_build_command("").is_err()); } #[test] fn validate_artifact_path_accepts_safe_paths() { assert!(validate_artifact_path("target/x86_64-unknown-linux-gnu/release/myapp").is_ok()); assert!(validate_artifact_path("dist/app-v0.1.0.tar.gz").is_ok()); } #[test] fn validate_artifact_path_rejects_unsafe() { assert!(validate_artifact_path("/etc/passwd").is_err()); assert!(validate_artifact_path("../../../etc/passwd").is_err()); assert!(validate_artifact_path("path with spaces").is_err()); assert!(validate_artifact_path("$(whoami)").is_err()); assert!(validate_artifact_path("").is_err()); } }