spec_driven_docs/adapters/
git.rs1use std::io::Read;
11use std::process::{Command, Stdio};
12use std::time::{Duration, Instant};
13
14use thiserror::Error;
15
16const TIMEOUT: Duration = Duration::from_secs(20);
18const MAX_OUTPUT: usize = 1024 * 1024;
20
21#[derive(Debug, Error)]
23pub enum GitError {
24 #[error("unsupported: {0}")]
26 Unsupported(String),
27 #[error("git is not available on PATH")]
29 MissingGit,
30 #[error("git ls-remote timed out")]
32 Timeout,
33 #[error("git ls-remote failed: {0}")]
35 Transport(String),
36 #[error("git ls-remote produced output this adapter cannot read")]
38 Malformed,
39}
40
41pub fn accept(repository: &str, reference: &str) -> Result<(), GitError> {
48 let refuse = |m: &str| Err(GitError::Unsupported(m.to_string()));
49 if repository.starts_with('-') || reference.starts_with('-') {
50 return refuse("an option-like value");
51 }
52 if repository.bytes().any(|b| b.is_ascii_control())
53 || reference.bytes().any(|b| b.is_ascii_control())
54 {
55 return refuse("a control character");
56 }
57 if !repository.starts_with("https://") {
58 return refuse("the repository is not an https:// URL");
59 }
60 let after_scheme = &repository["https://".len()..];
63 if after_scheme.contains('@') {
64 return refuse("the repository URL carries credentials");
65 }
66 if !reference.starts_with("refs/") {
67 return refuse("the reference is not a full refs/... name");
68 }
69 Ok(())
70}
71
72fn read_capped<R: Read>(stream: Option<R>) -> String {
73 let mut buf = Vec::new();
74 if let Some(s) = stream {
75 let _ = s.take(MAX_OUTPUT as u64).read_to_end(&mut buf);
76 }
77 String::from_utf8_lossy(&buf).into_owned()
78}
79
80pub fn ls_remote(repository: &str, reference: &str) -> Result<Option<String>, GitError> {
88 accept(repository, reference)?;
89
90 let mut child = Command::new("git")
91 .env("GIT_TERMINAL_PROMPT", "0")
93 .env("GIT_CONFIG_NOSYSTEM", "1")
94 .env("GIT_CONFIG_GLOBAL", "/dev/null")
95 .env("GIT_ASKPASS", "/bin/true")
96 .env_remove("GIT_CONFIG")
97 .arg("-c")
98 .arg("credential.helper=")
99 .arg("-c")
100 .arg("protocol.ext.allow=never")
101 .arg("-c")
102 .arg("protocol.file.allow=never")
103 .arg("ls-remote")
104 .arg("--")
106 .arg(repository)
107 .arg(reference)
108 .current_dir(std::env::temp_dir())
110 .stdin(Stdio::null())
111 .stdout(Stdio::piped())
112 .stderr(Stdio::piped())
113 .spawn()
114 .map_err(|e| {
115 if e.kind() == std::io::ErrorKind::NotFound {
116 GitError::MissingGit
117 } else {
118 GitError::Transport(e.to_string())
119 }
120 })?;
121
122 let start = Instant::now();
123 loop {
124 match child.try_wait() {
125 Ok(Some(_)) => break,
126 Ok(None) => {
127 if start.elapsed() > TIMEOUT {
128 let _ = child.kill();
129 let _ = child.wait();
130 return Err(GitError::Timeout);
131 }
132 std::thread::sleep(Duration::from_millis(25));
133 }
134 Err(e) => return Err(GitError::Transport(e.to_string())),
135 }
136 }
137
138 let stdout = read_capped(child.stdout.take());
139 let stderr = read_capped(child.stderr.take());
140 let status = child
141 .wait()
142 .map_err(|e| GitError::Transport(e.to_string()))?;
143
144 if !status.success() {
145 let _ = stderr;
148 return Err(GitError::Transport(
149 "the remote could not be reached".to_string(),
150 ));
151 }
152
153 let mut found = None;
156 for line in stdout.lines() {
157 let Some((sha, name)) = line.split_once('\t') else {
158 return Err(GitError::Malformed);
159 };
160 let is_hex =
161 |s: &str| (s.len() == 40 || s.len() == 64) && s.bytes().all(|b| b.is_ascii_hexdigit());
162 if !is_hex(sha) {
163 return Err(GitError::Malformed);
164 }
165 if name == reference {
166 if found.is_some() {
167 return Err(GitError::Malformed);
168 }
169 found = Some(sha.to_lowercase());
170 }
171 }
172 Ok(found)
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178
179 #[test]
180 fn accepts_a_plain_https_url_and_a_full_ref() {
181 assert!(accept("https://github.com/o/r", "refs/tags/v1").is_ok());
182 }
183
184 #[test]
185 fn rejects_every_non_allowlisted_input() {
186 for (repo, reference) in [
187 ("http://github.com/o/r", "refs/tags/v1"),
188 ("https://user:pass@github.com/o/r", "refs/tags/v1"),
189 ("ext::sh -c whoami", "refs/tags/v1"),
190 ("file:///etc", "refs/tags/v1"),
191 ("git@github.com:o/r", "refs/tags/v1"),
192 ("-oProxyCommand=x", "refs/tags/v1"),
193 ("https://github.com/o/r", "v1"),
194 ("https://github.com/o/r", "-x"),
195 ("https://github.com/o/r\n", "refs/tags/v1"),
196 ] {
197 assert!(
198 matches!(accept(repo, reference), Err(GitError::Unsupported(_))),
199 "accepted {repo:?} {reference:?}"
200 );
201 }
202 }
203}