Skip to main content

spec_driven_docs/adapters/
git.rs

1//! A narrow, hardened boundary to `git ls-remote`.
2//!
3//! The only network operation this binary performs. It reads one reference
4//! from one credential-free `https://` repository and returns the object ID
5//! the reference points at. Everything about it is bounded: the transport is
6//! allowlisted, the process runs with no shell, no credential prompt, and no
7//! inherited Git configuration, and its time and output are capped. It writes
8//! nothing and downloads no code — a reference is a name and a hash.
9
10use std::io::Read;
11use std::process::{Command, Stdio};
12use std::time::{Duration, Instant};
13
14use thiserror::Error;
15
16/// A bound on how long the lookup may run.
17const TIMEOUT: Duration = Duration::from_secs(20);
18/// A bound on the bytes read from the child's streams.
19const MAX_OUTPUT: usize = 1024 * 1024;
20
21/// Why a lookup could not produce a result.
22#[derive(Debug, Error)]
23pub enum GitError {
24    /// The repository URL or the reference is not one this adapter accepts.
25    #[error("unsupported: {0}")]
26    Unsupported(String),
27    /// `git` is not on `PATH`.
28    #[error("git is not available on PATH")]
29    MissingGit,
30    /// The lookup exceeded its time bound.
31    #[error("git ls-remote timed out")]
32    Timeout,
33    /// `git` failed or the transport did.
34    #[error("git ls-remote failed: {0}")]
35    Transport(String),
36    /// The output was not the `<sha>\t<ref>` shape expected.
37    #[error("git ls-remote produced output this adapter cannot read")]
38    Malformed,
39}
40
41/// Validate that a repository URL is a credential-free `https://` URL and a
42/// reference is a full `refs/...` name. Returns the reason on refusal.
43///
44/// # Errors
45///
46/// [`GitError::Unsupported`] naming what was rejected.
47pub 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    // `https://user:pass@host` carries credentials; `ext::`, `file://`, and
61    // scp-style `host:path` are other transports.
62    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
80/// The full object ID `reference` points at in `repository`, or `None` when
81/// the reference is absent.
82///
83/// # Errors
84///
85/// [`GitError`] for a refused input, a missing `git`, a timeout, a transport
86/// failure, or output this adapter cannot read.
87pub fn ls_remote(repository: &str, reference: &str) -> Result<Option<String>, GitError> {
88    accept(repository, reference)?;
89
90    let mut child = Command::new("git")
91        // No inherited config can rewrite the URL or install a helper.
92        .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        // End option parsing, so a repository value can never be read as a flag.
105        .arg("--")
106        .arg(repository)
107        .arg(reference)
108        // Run outside any repository, so a local checkout cannot answer.
109        .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        // Redact everything but a short, fixed reason: remote stderr can echo
146        // a URL or a credential prompt.
147        let _ = stderr;
148        return Err(GitError::Transport(
149            "the remote could not be reached".to_string(),
150        ));
151    }
152
153    // A present reference is one `<40|64 hex>\t<ref>` line. No line means the
154    // reference is absent, which is a report state, not an error.
155    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}