//! The per-job source checkout: a fresh `work_dir/` holding exactly the job's commit. use std::path::{Path, PathBuf}; use std::process::Stdio; use base64::Engine; use forge_ci::proto::JobAssignment; use tokio::process::Command; /// Errors while preparing or removing a workspace. #[derive(Debug, thiserror::Error)] pub enum WorkspaceError { #[error("workspace io: {0}")] Io(#[from] std::io::Error), #[error("git {step} failed: {stderr}")] Git { step: &'static str, stderr: String }, #[error("refusing job: {0}")] Invalid(String), } /// Job ids come from the forge. They become directory names and container names, so keep them /// to a safe alphabet. pub fn validate_job_id(job_id: &str) -> Result<(), WorkspaceError> { let ok = !job_id.is_empty() && job_id.len() <= 128 && job_id .bytes() .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'); if ok { Ok(()) } else { Err(WorkspaceError::Invalid(format!("bad job id {job_id:?}"))) } } /// The exact commit to check out: 40 (SHA-1) or 64 (SHA-256) lowercase hex digits. pub fn validate_commit(commit: &str) -> Result<(), WorkspaceError> { let ok = (commit.len() == 40 || commit.len() == 64) && commit .bytes() .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)); if ok { Ok(()) } else { Err(WorkspaceError::Invalid(format!("bad commit {commit:?}"))) } } /// Whether `file://` clone URLs are accepted. Only test builds may fetch from a local path. #[cfg(any(test, feature = "test-host-executor"))] const fn local_file_urls_allowed() -> bool { true } /// Whether `file://` clone URLs are accepted. Production builds never accept them. #[cfg(not(any(test, feature = "test-host-executor")))] const fn local_file_urls_allowed() -> bool { false } /// Clone URLs must be a URL with a plain scheme. A leading `-` would be read as a git option. /// /// Production accepts `https://` only, plus `http://` when the forge itself is `http://`. /// `file://` is accepted only in test builds (`test-host-executor` or `cfg(test)`). pub fn validate_clone_url(url: &str, forge_url: &str) -> Result<(), WorkspaceError> { let allowed = url.starts_with("https://") || (url.starts_with("http://") && forge_url.starts_with("http://")) || (url.starts_with("file://") && local_file_urls_allowed()); if allowed && !url.contains(char::is_whitespace) { Ok(()) } else { Err(WorkspaceError::Invalid( "clone_url must be https, or http when the forge is http".into(), )) } } /// The directory a job's workspace lives in. pub fn job_dir(work_dir: &Path, job_id: &str) -> Result { validate_job_id(job_id)?; Ok(work_dir.join(job_id)) } /// Environment that carries the job credential to git. The credential never goes in argv. pub fn git_auth_env(job_id: &str, job_token: &str) -> Vec<(String, String)> { let basic = base64::engine::general_purpose::STANDARD.encode(format!("ci-job:{job_id}:{job_token}")); vec![ ("GIT_CONFIG_COUNT".to_owned(), "1".to_owned()), ("GIT_CONFIG_KEY_0".to_owned(), "http.extraHeader".to_owned()), ( "GIT_CONFIG_VALUE_0".to_owned(), format!("Authorization: Basic {basic}"), ), ("GIT_TERMINAL_PROMPT".to_owned(), "0".to_owned()), ] } /// Argv for `git fetch`. It holds no credential. pub fn fetch_args(clone_url: &str, commit: &str) -> Vec { vec![ "fetch".to_owned(), "--depth=1".to_owned(), "--no-tags".to_owned(), clone_url.to_owned(), commit.to_owned(), ] } /// Creates a fresh workspace for `job` and checks out its commit (detached). pub async fn prepare( work_dir: &Path, job: &JobAssignment, forge_url: &str, ) -> Result { validate_clone_url(&job.clone_url, forge_url)?; validate_commit(&job.commit)?; let dir = job_dir(work_dir, &job.job_id)?; if tokio::fs::try_exists(&dir).await? { tokio::fs::remove_dir_all(&dir).await?; } tokio::fs::create_dir_all(&dir).await?; let env = git_auth_env(&job.job_id, &job.job_token); git(&dir, &env, "init", &["init", "--quiet"]).await?; git( &dir, &env, "fetch", &fetch_args(&job.clone_url, &job.commit), ) .await?; git( &dir, &env, "checkout", &[ "checkout".to_owned(), "--quiet".to_owned(), "--detach".to_owned(), job.commit.clone(), ], ) .await?; Ok(dir) } /// Removes a workspace. A missing directory is not an error. pub async fn cleanup(dir: &Path) -> Result<(), WorkspaceError> { match tokio::fs::remove_dir_all(dir).await { Ok(()) => Ok(()), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(e) => Err(e.into()), } } async fn git( dir: &Path, env: &[(String, String)], step: &'static str, args: &[impl AsRef], ) -> Result<(), WorkspaceError> { let output = Command::new("git") .arg("-C") .arg(dir) .args(args.iter().map(AsRef::as_ref)) .envs(env.iter().map(|(k, v)| (k.as_str(), v.as_str()))) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::piped()) .kill_on_drop(true) .output() .await?; if output.status.success() { return Ok(()); } let stderr: String = String::from_utf8_lossy(&output.stderr) .chars() .take(2000) .collect(); Err(WorkspaceError::Git { step, stderr }) } #[cfg(test)] mod tests { use super::*; #[test] fn fetch_argv_never_holds_the_token() { let token = "tok-SECRET-value-123"; let args = fetch_args("https://forge.example/o/r.git", &"a".repeat(40)); assert!(args.iter().all(|a| !a.contains(token))); let env = git_auth_env("job_1", token); assert!( env.iter() .any(|(_, v)| v.starts_with("Authorization: Basic ")) ); } #[test] fn job_ids_and_commits_are_checked() { assert!(validate_job_id("01J-abc_9").is_ok()); assert!(validate_job_id("../etc").is_err()); assert!(validate_job_id("").is_err()); assert!(validate_commit(&"f".repeat(40)).is_ok()); assert!(validate_commit("--upload-pack=x").is_err()); assert!(validate_commit(&"A".repeat(40)).is_err()); } #[test] fn clone_urls_cannot_look_like_options() { let https = "https://forge.example"; assert!(validate_clone_url("https://forge.example/o/r.git", https).is_ok()); assert!(validate_clone_url("--upload-pack=sh", https).is_err()); assert!(validate_clone_url("ext::sh -c id", https).is_err()); assert!(validate_clone_url("https://a b/x", https).is_err()); } #[test] fn http_clone_urls_need_an_http_forge() { assert!(validate_clone_url("http://forge.example/o/r.git", "http://forge.example").is_ok()); assert!( validate_clone_url("http://forge.example/o/r.git", "https://forge.example").is_err() ); } #[test] fn file_urls_are_accepted_in_test_builds_only() { // This test builds with cfg(test), so file:// is allowed here. Production builds refuse it. assert!(validate_clone_url("file:///nonexistent/repo", "https://forge.example").is_ok()); assert!(local_file_urls_allowed()); } #[tokio::test] async fn cleanup_of_missing_dir_is_ok() { let dir = tempfile::tempdir().expect("tempdir"); cleanup(&dir.path().join("gone")).await.expect("cleanup"); } }