forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
7.6 KiB
1//! The per-job source checkout: a fresh `work_dir/<job_id>` holding exactly the job's commit.
2
3use std::path::{Path, PathBuf};
4use std::process::Stdio;
5
6use base64::Engine;
7use forge_ci::proto::JobAssignment;
8use tokio::process::Command;
9
10/// Errors while preparing or removing a workspace.
11#[derive(Debug, thiserror::Error)]
12pub 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/// Job ids come from the forge. They become directory names and container names, so keep them
22/// to a safe alphabet.
23pub 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/// The exact commit to check out: 40 (SHA-1) or 64 (SHA-256) lowercase hex digits.
37pub 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/// Whether `file://` clone URLs are accepted. Only test builds may fetch from a local path.
50#[cfg(any(test, feature = "test-host-executor"))]
51const fn local_file_urls_allowed() -> bool {
52 true
53}
54
55/// Whether `file://` clone URLs are accepted. Production builds never accept them.
56#[cfg(not(any(test, feature = "test-host-executor")))]
57const fn local_file_urls_allowed() -> bool {
58 false
59}
60
61/// Clone URLs must be a URL with a plain scheme. A leading `-` would be read as a git option.
62///
63/// Production accepts `https://` only, plus `http://` when the forge itself is `http://`.
64/// `file://` is accepted only in test builds (`test-host-executor` or `cfg(test)`).
65pub 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/// The directory a job's workspace lives in.
79pub 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/// Environment that carries the job credential to git. The credential never goes in argv.
85pub 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/// Argv for `git fetch`. It holds no credential.
100pub 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/// Creates a fresh workspace for `job` and checks out its commit (detached).
111pub 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/// Removes a workspace. A missing directory is not an error.
149pub 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
157async 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)]
185mod 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 // This test builds with cfg(test), so file:// is allowed here. Production builds refuse it.
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}