| 1 | |
| 2 | |
| 3 | use std::path::{Path, PathBuf}; |
| 4 | use std::process::Stdio; |
| 5 | |
| 6 | use base64::Engine; |
| 7 | use forge_ci::proto::JobAssignment; |
| 8 | use tokio::process::Command; |
| 9 | |
| 10 | |
| 11 | #[derive(Debug, thiserror::Error)] |
| 12 | pub enum WorkspaceError { |
| 13 | #[error("workspace io: {0}")] |
| 14 | Io(#[from] std::io::Error), |
| 15 | #[error("git {step} failed: {stderr}")] |
| 16 | Git { step: &'static str, stderr: String }, |
| 17 | #[error("refusing job: {0}")] |
| 18 | Invalid(String), |
| 19 | } |
| 20 | |
| 21 | |
| 22 | |
| 23 | pub fn validate_job_id(job_id: &str) -> Result<(), WorkspaceError> { |
| 24 | let ok = !job_id.is_empty() |
| 25 | && job_id.len() <= 128 |
| 26 | && job_id |
| 27 | .bytes() |
| 28 | .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'); |
| 29 | if ok { |
| 30 | Ok(()) |
| 31 | } else { |
| 32 | Err(WorkspaceError::Invalid(format!("bad job id {job_id:?}"))) |
| 33 | } |
| 34 | } |
| 35 | |
| 36 | |
| 37 | pub fn validate_commit(commit: &str) -> Result<(), WorkspaceError> { |
| 38 | let ok = (commit.len() == 40 || commit.len() == 64) |
| 39 | && commit |
| 40 | .bytes() |
| 41 | .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)); |
| 42 | if ok { |
| 43 | Ok(()) |
| 44 | } else { |
| 45 | Err(WorkspaceError::Invalid(format!("bad commit {commit:?}"))) |
| 46 | } |
| 47 | } |
| 48 | |
| 49 | |
| 50 | #[cfg(any(test, feature = "test-host-executor"))] |
| 51 | const fn local_file_urls_allowed() -> bool { |
| 52 | true |
| 53 | } |
| 54 | |
| 55 | |
| 56 | #[cfg(not(any(test, feature = "test-host-executor")))] |
| 57 | const fn local_file_urls_allowed() -> bool { |
| 58 | false |
| 59 | } |
| 60 | |
| 61 | |
| 62 | |
| 63 | |
| 64 | |
| 65 | pub fn validate_clone_url(url: &str, forge_url: &str) -> Result<(), WorkspaceError> { |
| 66 | let allowed = url.starts_with("https://") |
| 67 | || (url.starts_with("http://") && forge_url.starts_with("http://")) |
| 68 | || (url.starts_with("file://") && local_file_urls_allowed()); |
| 69 | if allowed && !url.contains(char::is_whitespace) { |
| 70 | Ok(()) |
| 71 | } else { |
| 72 | Err(WorkspaceError::Invalid( |
| 73 | "clone_url must be https, or http when the forge is http".into(), |
| 74 | )) |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | |
| 79 | pub fn job_dir(work_dir: &Path, job_id: &str) -> Result<PathBuf, WorkspaceError> { |
| 80 | validate_job_id(job_id)?; |
| 81 | Ok(work_dir.join(job_id)) |
| 82 | } |
| 83 | |
| 84 | |
| 85 | pub fn git_auth_env(job_id: &str, job_token: &str) -> Vec<(String, String)> { |
| 86 | let basic = |
| 87 | base64::engine::general_purpose::STANDARD.encode(format!("ci-job:{job_id}:{job_token}")); |
| 88 | vec![ |
| 89 | ("GIT_CONFIG_COUNT".to_owned(), "1".to_owned()), |
| 90 | ("GIT_CONFIG_KEY_0".to_owned(), "http.extraHeader".to_owned()), |
| 91 | ( |
| 92 | "GIT_CONFIG_VALUE_0".to_owned(), |
| 93 | format!("Authorization: Basic {basic}"), |
| 94 | ), |
| 95 | ("GIT_TERMINAL_PROMPT".to_owned(), "0".to_owned()), |
| 96 | ] |
| 97 | } |
| 98 | |
| 99 | |
| 100 | pub fn fetch_args(clone_url: &str, commit: &str) -> Vec<String> { |
| 101 | vec![ |
| 102 | "fetch".to_owned(), |
| 103 | "--depth=1".to_owned(), |
| 104 | "--no-tags".to_owned(), |
| 105 | clone_url.to_owned(), |
| 106 | commit.to_owned(), |
| 107 | ] |
| 108 | } |
| 109 | |
| 110 | |
| 111 | pub async fn prepare( |
| 112 | work_dir: &Path, |
| 113 | job: &JobAssignment, |
| 114 | forge_url: &str, |
| 115 | ) -> Result<PathBuf, WorkspaceError> { |
| 116 | validate_clone_url(&job.clone_url, forge_url)?; |
| 117 | validate_commit(&job.commit)?; |
| 118 | let dir = job_dir(work_dir, &job.job_id)?; |
| 119 | if tokio::fs::try_exists(&dir).await? { |
| 120 | tokio::fs::remove_dir_all(&dir).await?; |
| 121 | } |
| 122 | tokio::fs::create_dir_all(&dir).await?; |
| 123 | |
| 124 | let env = git_auth_env(&job.job_id, &job.job_token); |
| 125 | git(&dir, &env, "init", &["init", "--quiet"]).await?; |
| 126 | git( |
| 127 | &dir, |
| 128 | &env, |
| 129 | "fetch", |
| 130 | &fetch_args(&job.clone_url, &job.commit), |
| 131 | ) |
| 132 | .await?; |
| 133 | git( |
| 134 | &dir, |
| 135 | &env, |
| 136 | "checkout", |
| 137 | &[ |
| 138 | "checkout".to_owned(), |
| 139 | "--quiet".to_owned(), |
| 140 | "--detach".to_owned(), |
| 141 | job.commit.clone(), |
| 142 | ], |
| 143 | ) |
| 144 | .await?; |
| 145 | Ok(dir) |
| 146 | } |
| 147 | |
| 148 | |
| 149 | pub async fn cleanup(dir: &Path) -> Result<(), WorkspaceError> { |
| 150 | match tokio::fs::remove_dir_all(dir).await { |
| 151 | Ok(()) => Ok(()), |
| 152 | Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), |
| 153 | Err(e) => Err(e.into()), |
| 154 | } |
| 155 | } |
| 156 | |
| 157 | async fn git( |
| 158 | dir: &Path, |
| 159 | env: &[(String, String)], |
| 160 | step: &'static str, |
| 161 | args: &[impl AsRef<str>], |
| 162 | ) -> Result<(), WorkspaceError> { |
| 163 | let output = Command::new("git") |
| 164 | .arg("-C") |
| 165 | .arg(dir) |
| 166 | .args(args.iter().map(AsRef::as_ref)) |
| 167 | .envs(env.iter().map(|(k, v)| (k.as_str(), v.as_str()))) |
| 168 | .stdin(Stdio::null()) |
| 169 | .stdout(Stdio::null()) |
| 170 | .stderr(Stdio::piped()) |
| 171 | .kill_on_drop(true) |
| 172 | .output() |
| 173 | .await?; |
| 174 | if output.status.success() { |
| 175 | return Ok(()); |
| 176 | } |
| 177 | let stderr: String = String::from_utf8_lossy(&output.stderr) |
| 178 | .chars() |
| 179 | .take(2000) |
| 180 | .collect(); |
| 181 | Err(WorkspaceError::Git { step, stderr }) |
| 182 | } |
| 183 | |
| 184 | #[cfg(test)] |
| 185 | mod tests { |
| 186 | use super::*; |
| 187 | |
| 188 | #[test] |
| 189 | fn fetch_argv_never_holds_the_token() { |
| 190 | let token = "tok-SECRET-value-123"; |
| 191 | let args = fetch_args("https://forge.example/o/r.git", &"a".repeat(40)); |
| 192 | assert!(args.iter().all(|a| !a.contains(token))); |
| 193 | let env = git_auth_env("job_1", token); |
| 194 | assert!( |
| 195 | env.iter() |
| 196 | .any(|(_, v)| v.starts_with("Authorization: Basic ")) |
| 197 | ); |
| 198 | } |
| 199 | |
| 200 | #[test] |
| 201 | fn job_ids_and_commits_are_checked() { |
| 202 | assert!(validate_job_id("01J-abc_9").is_ok()); |
| 203 | assert!(validate_job_id("../etc").is_err()); |
| 204 | assert!(validate_job_id("").is_err()); |
| 205 | assert!(validate_commit(&"f".repeat(40)).is_ok()); |
| 206 | assert!(validate_commit("--upload-pack=x").is_err()); |
| 207 | assert!(validate_commit(&"A".repeat(40)).is_err()); |
| 208 | } |
| 209 | |
| 210 | #[test] |
| 211 | fn clone_urls_cannot_look_like_options() { |
| 212 | let https = "https://forge.example"; |
| 213 | assert!(validate_clone_url("https://forge.example/o/r.git", https).is_ok()); |
| 214 | assert!(validate_clone_url("--upload-pack=sh", https).is_err()); |
| 215 | assert!(validate_clone_url("ext::sh -c id", https).is_err()); |
| 216 | assert!(validate_clone_url("https://a b/x", https).is_err()); |
| 217 | } |
| 218 | |
| 219 | #[test] |
| 220 | fn http_clone_urls_need_an_http_forge() { |
| 221 | assert!(validate_clone_url("http://forge.example/o/r.git", "http://forge.example").is_ok()); |
| 222 | assert!( |
| 223 | validate_clone_url("http://forge.example/o/r.git", "https://forge.example").is_err() |
| 224 | ); |
| 225 | } |
| 226 | |
| 227 | #[test] |
| 228 | fn file_urls_are_accepted_in_test_builds_only() { |
| 229 | |
| 230 | assert!(validate_clone_url("file:///nonexistent/repo", "https://forge.example").is_ok()); |
| 231 | assert!(local_file_urls_allowed()); |
| 232 | } |
| 233 | |
| 234 | #[tokio::test] |
| 235 | async fn cleanup_of_missing_dir_is_ok() { |
| 236 | let dir = tempfile::tempdir().expect("tempdir"); |
| 237 | cleanup(&dir.path().join("gone")).await.expect("cleanup"); |
| 238 | } |
| 239 | } |