Skip to main content

grit_lib/
protocol.rs

1//! Protocol allow/deny policy and client wire-protocol version selection.
2//!
3//! Implements the `protocol.<name>.allow` config and `GIT_ALLOW_PROTOCOL`
4//! environment semantics without reading process-global state directly.
5
6use crate::config::ConfigSet;
7use crate::error::Error;
8use thiserror::Error;
9
10/// Errors returned when a transport protocol is not allowed.
11#[derive(Clone, Debug, Error, PartialEq, Eq)]
12pub enum ProtocolError {
13    /// Protocol is not included in `GIT_ALLOW_PROTOCOL`.
14    #[error("protocol '{protocol}' is not allowed by GIT_ALLOW_PROTOCOL")]
15    NotAllowedByEnvironment {
16        /// Protocol name that was denied.
17        protocol: String,
18    },
19    /// Protocol is denied by config or default policy.
20    #[error("protocol '{protocol}' is not allowed")]
21    NotAllowed {
22        /// Protocol name that was denied.
23        protocol: String,
24    },
25    /// Config contains an unknown allow value.
26    #[error("unknown protocol.allow value '{value}' for protocol '{protocol}'")]
27    UnknownAllowValue {
28        /// Protocol name whose policy was evaluated.
29        protocol: String,
30        /// Unknown config value.
31        value: String,
32    },
33}
34
35/// Explicit inputs for protocol allow/deny evaluation.
36#[derive(Clone, Debug, Default)]
37pub struct ProtocolPolicyInputs {
38    /// `GIT_ALLOW_PROTOCOL` value, when set.
39    pub git_allow_protocol: Option<String>,
40    /// `GIT_PROTOCOL_FROM_USER` value, when set.
41    pub git_protocol_from_user: Option<String>,
42    /// `protocol.<name>.allow` config value.
43    pub specific_allow: Option<String>,
44    /// `protocol.allow` config value.
45    pub blanket_allow: Option<String>,
46}
47
48/// Check whether a given protocol (e.g. `file`, `git`, `ssh`, `https`) is allowed.
49///
50/// Rules match Git's `transport.c` / `is_transport_allowed`:
51/// 1. `GIT_ALLOW_PROTOCOL` is a colon- or comma-separated whitelist.
52/// 2. `protocol.<name>.allow` overrides the blanket config.
53/// 3. `protocol.allow` supplies a blanket default.
54/// 4. Built-in defaults: `http`, `https`, `git`, and `ssh` are always allowed;
55///    `ext` is never allowed; any other protocol is `user`.
56pub fn check_protocol_allowed_with(
57    protocol: &str,
58    inputs: &ProtocolPolicyInputs,
59) -> Result<(), ProtocolError> {
60    if let Some(val) = inputs.git_allow_protocol.as_deref() {
61        let allowed: Vec<&str> = val
62            .split([':', ','])
63            .map(str::trim)
64            .filter(|s| !s.is_empty())
65            .collect();
66        if allowed.contains(&protocol) {
67            return Ok(());
68        }
69        return Err(ProtocolError::NotAllowedByEnvironment {
70            protocol: protocol.to_owned(),
71        });
72    }
73
74    if let Some(ref val) = inputs.specific_allow {
75        return check_allow_value(protocol, val, inputs.git_protocol_from_user.as_deref());
76    }
77
78    if let Some(ref val) = inputs.blanket_allow {
79        return check_allow_value(protocol, val, inputs.git_protocol_from_user.as_deref());
80    }
81
82    match protocol.to_ascii_lowercase().as_str() {
83        "http" | "https" | "git" | "ssh" => Ok(()),
84        "ext" => Err(ProtocolError::NotAllowed {
85            protocol: protocol.to_owned(),
86        }),
87        _ => check_allow_value(protocol, "user", inputs.git_protocol_from_user.as_deref()),
88    }
89}
90
91fn check_allow_value(
92    protocol: &str,
93    value: &str,
94    git_protocol_from_user: Option<&str>,
95) -> Result<(), ProtocolError> {
96    match value.to_lowercase().as_str() {
97        "always" => Ok(()),
98        "never" => Err(ProtocolError::NotAllowed {
99            protocol: protocol.to_owned(),
100        }),
101        "user" => {
102            if protocol_from_user(git_protocol_from_user) {
103                Ok(())
104            } else {
105                Err(ProtocolError::NotAllowed {
106                    protocol: protocol.to_owned(),
107                })
108            }
109        }
110        other => Err(ProtocolError::UnknownAllowValue {
111            protocol: protocol.to_owned(),
112            value: other.to_owned(),
113        }),
114    }
115}
116
117/// Whether `protocol.<name>.allow=user` should be considered allowed.
118#[must_use]
119pub fn protocol_from_user(raw: Option<&str>) -> bool {
120    match raw {
121        None => true,
122        Some(v) => {
123            let v = v.trim().to_ascii_lowercase();
124            v.is_empty() || !matches!(v.as_str(), "0" | "false" | "no" | "off")
125        }
126    }
127}
128
129/// Explicit inputs for selecting the client-side Git wire protocol version.
130#[derive(Clone, Debug, Default)]
131pub struct ClientProtocolVersionInputs {
132    /// `protocol.version` from command-line config overrides such as `-c`.
133    pub config_param_version: Option<String>,
134    /// `protocol.version` from repository config.
135    pub repo_config_version: Option<String>,
136    /// `GIT_TEST_PROTOCOL_VERSION`, when set.
137    pub git_test_protocol_version: Option<String>,
138}
139
140/// Parse a protocol version digit (`0`, `1`, or `2`).
141#[must_use]
142pub fn parse_protocol_version_digit(s: &str) -> Option<u8> {
143    match s.trim() {
144        "0" => Some(0),
145        "1" => Some(1),
146        "2" => Some(2),
147        _ => None,
148    }
149}
150
151/// Parse `protocol.version` from config (strict: invalid values are rejected).
152///
153/// Returns [`Error::Config`] when the value is present but not `0`, `1`, or `2`.
154pub fn try_protocol_version_from_config_value(raw: &str) -> Result<u8, Error> {
155    parse_protocol_version_digit(raw)
156        .ok_or_else(|| Error::Config(format!("bad protocol version '{raw}'").into()))
157}
158
159/// Select the effective client-side protocol version (strict).
160///
161/// Command-line config takes precedence over repository config, which takes precedence over
162/// `GIT_TEST_PROTOCOL_VERSION`; when none are set the default is `2`. Any configured value
163/// that is not `0`, `1`, or `2` is rejected.
164pub fn try_effective_client_protocol_version_from_inputs(
165    inputs: &ClientProtocolVersionInputs,
166) -> Result<u8, Error> {
167    if let Some(v) = inputs.config_param_version.as_deref() {
168        return try_protocol_version_from_config_value(v);
169    }
170    if let Some(v) = inputs.repo_config_version.as_deref() {
171        return try_protocol_version_from_config_value(v);
172    }
173    if let Some(raw) = inputs
174        .git_test_protocol_version
175        .as_deref()
176        .filter(|s| !s.is_empty())
177    {
178        return try_protocol_version_from_config_value(raw);
179    }
180    Ok(2)
181}
182
183/// Effective client-side protocol version from `protocol.version` in `config` only.
184///
185/// Does not read process environment; callers that honor `GIT_TEST_PROTOCOL_VERSION` must
186/// supply it via [`ClientProtocolVersionInputs`].
187pub fn try_effective_client_protocol_version_from_config(config: &ConfigSet) -> Result<u8, Error> {
188    try_effective_client_protocol_version_from_inputs(&ClientProtocolVersionInputs {
189        repo_config_version: config.get("protocol.version"),
190        ..Default::default()
191    })
192}
193
194/// Value for the HTTP `Git-Protocol` request header for a client protocol version.
195///
196/// Version `0` means no header (classic v0 advertisement); `1` and `2` send
197/// `version=1` / `version=2` respectively.
198#[must_use]
199pub fn client_git_protocol_header_value(version: u8) -> Option<String> {
200    match version {
201        0 => None,
202        1 => Some("version=1".to_string()),
203        _ => Some("version=2".to_string()),
204    }
205}
206
207/// `Git-Protocol` header value implied by `protocol.version` in `config`.
208///
209/// # Errors
210///
211/// Returns [`Error::Config`] when `protocol.version` is set to an invalid value.
212pub fn client_git_protocol_header_from_config(config: &ConfigSet) -> Result<Option<String>, Error> {
213    Ok(client_git_protocol_header_value(
214        try_effective_client_protocol_version_from_config(config)?,
215    ))
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    #[test]
223    fn client_git_protocol_header_matches_version() {
224        assert_eq!(client_git_protocol_header_value(0), None);
225        assert_eq!(
226            client_git_protocol_header_value(1).as_deref(),
227            Some("version=1")
228        );
229        assert_eq!(
230            client_git_protocol_header_value(2).as_deref(),
231            Some("version=2")
232        );
233    }
234
235    #[test]
236    fn protocol_version_from_config_defaults_to_v2_header() {
237        let config = ConfigSet::new();
238        assert_eq!(
239            client_git_protocol_header_from_config(&config)
240                .expect("header")
241                .as_deref(),
242            Some("version=2")
243        );
244    }
245
246    #[test]
247    fn protocol_version_from_config_honors_protocol_version_key() {
248        let mut config = ConfigSet::new();
249        config
250            .add_command_override("protocol.version", "1")
251            .expect("override");
252        assert_eq!(
253            client_git_protocol_header_from_config(&config)
254                .expect("header")
255                .as_deref(),
256            Some("version=1")
257        );
258    }
259
260    #[test]
261    fn protocol_version_rejects_invalid_config_value() {
262        let mut config = ConfigSet::new();
263        config
264            .add_command_override("protocol.version", "9")
265            .expect("override");
266        let err = client_git_protocol_header_from_config(&config).expect_err("invalid");
267        assert!(
268            matches!(err, Error::Config(_)),
269            "expected ConfigError, got {err:?}"
270        );
271    }
272
273    #[test]
274    fn protocol_version_inputs_honor_git_test_without_env_read_in_config_helper() {
275        let inputs = ClientProtocolVersionInputs {
276            git_test_protocol_version: Some("0".to_owned()),
277            ..Default::default()
278        };
279        assert_eq!(
280            try_effective_client_protocol_version_from_inputs(&inputs).expect("v0"),
281            0
282        );
283        let config = ConfigSet::new();
284        assert_eq!(
285            try_effective_client_protocol_version_from_config(&config).expect("default"),
286            2,
287            "config helper must not read GIT_TEST_PROTOCOL_VERSION from the environment"
288        );
289    }
290}