Skip to main content

sley_protocol/
promisor.rs

1use sley_core::{GitError, Result};
2
3/// One remote described by the protocol-v2 `promisor-remote` capability.
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub struct PromisorRemoteAdvertisement {
6    pub name: String,
7    pub url: String,
8    pub partial_clone_filter: Option<String>,
9    pub token: Option<String>,
10}
11
12/// Encode the server capability value, preserving remote order and the
13/// mandatory `name,url` field order.
14pub fn encode_promisor_remote_advertisement(
15    remotes: &[PromisorRemoteAdvertisement],
16) -> Result<String> {
17    if remotes.is_empty() {
18        return Err(GitError::InvalidFormat(
19            "promisor-remote advertisement is empty".into(),
20        ));
21    }
22    remotes
23        .iter()
24        .map(|remote| {
25            if remote.name.is_empty() || remote.url.is_empty() {
26                return Err(GitError::InvalidFormat(
27                    "promisor remote requires a name and URL".into(),
28                ));
29            }
30            let mut fields = vec![
31                format!("name={}", percent_encode(&remote.name)),
32                format!("url={}", percent_encode(&remote.url)),
33            ];
34            if let Some(filter) = &remote.partial_clone_filter {
35                fields.push(format!(
36                    "partialCloneFilter={}",
37                    percent_encode_optional_field(filter)
38                ));
39            }
40            if let Some(token) = &remote.token {
41                fields.push(format!("token={}", percent_encode_optional_field(token)));
42            }
43            Ok(fields.join(","))
44        })
45        .collect::<Result<Vec<_>>>()
46        .map(|entries| entries.join(";"))
47}
48
49/// Parse a server `promisor-remote=<value>` capability. Unknown optional fields
50/// are ignored for forward compatibility; duplicate known fields are rejected.
51pub fn parse_promisor_remote_advertisement(
52    value: &str,
53) -> Result<Vec<PromisorRemoteAdvertisement>> {
54    if value.is_empty() {
55        return Err(GitError::InvalidFormat(
56            "promisor-remote advertisement is empty".into(),
57        ));
58    }
59    value
60        .split(';')
61        .map(|entry| {
62            let mut name = None;
63            let mut url = None;
64            let mut partial_clone_filter = None;
65            let mut token = None;
66            for field in entry.split(',') {
67                let Some((key, encoded)) = field.split_once('=') else {
68                    return Err(GitError::InvalidFormat(format!(
69                        "invalid promisor remote field {field}"
70                    )));
71                };
72                let decoded = percent_decode(encoded)?;
73                let slot = match key {
74                    "name" => &mut name,
75                    "url" => &mut url,
76                    "partialCloneFilter" => &mut partial_clone_filter,
77                    "token" => &mut token,
78                    _ => continue,
79                };
80                if slot.replace(decoded).is_some() {
81                    return Err(GitError::InvalidFormat(format!(
82                        "duplicate promisor remote field {key}"
83                    )));
84                }
85            }
86            let name = name.filter(|value| !value.is_empty()).ok_or_else(|| {
87                GitError::InvalidFormat("promisor remote is missing its name".into())
88            })?;
89            let url = url.filter(|value| !value.is_empty()).ok_or_else(|| {
90                GitError::InvalidFormat("promisor remote is missing its URL".into())
91            })?;
92            Ok(PromisorRemoteAdvertisement {
93                name,
94                url,
95                partial_clone_filter,
96                token,
97            })
98        })
99        .collect()
100}
101
102/// Encode the client reply value from accepted remote names.
103pub fn encode_promisor_remote_reply(names: &[String]) -> Result<Option<String>> {
104    if names.is_empty() {
105        return Ok(None);
106    }
107    if names.iter().any(|name| name.is_empty()) {
108        return Err(GitError::InvalidFormat(
109            "accepted promisor remote name is empty".into(),
110        ));
111    }
112    Ok(Some(
113        names
114            .iter()
115            .map(|name| percent_encode(name))
116            .collect::<Vec<_>>()
117            .join(";"),
118    ))
119}
120
121pub fn parse_promisor_remote_reply(value: &str) -> Result<Vec<String>> {
122    if value.is_empty() {
123        return Err(GitError::InvalidFormat(
124            "promisor-remote reply is empty".into(),
125        ));
126    }
127    value.split(';').map(percent_decode).collect()
128}
129
130fn percent_encode(value: &str) -> String {
131    percent_encode_with(value, false)
132}
133
134fn percent_encode_optional_field(value: &str) -> String {
135    percent_encode_with(value, true)
136}
137
138fn percent_encode_with(value: &str, allow_equals: bool) -> String {
139    let mut out = String::new();
140    for byte in value.bytes() {
141        if byte.is_ascii_alphanumeric()
142            || b"_.~/:-".contains(&byte)
143            || (allow_equals && byte == b'=')
144        {
145            out.push(byte as char);
146        } else {
147            out.push_str(&format!("%{byte:02X}"));
148        }
149    }
150    out
151}
152
153fn percent_decode(value: &str) -> Result<String> {
154    let bytes = value.as_bytes();
155    let mut out = Vec::with_capacity(bytes.len());
156    let mut index = 0;
157    while index < bytes.len() {
158        if bytes[index] != b'%' {
159            out.push(bytes[index]);
160            index += 1;
161            continue;
162        }
163        if index + 2 >= bytes.len() {
164            return Err(GitError::InvalidFormat(
165                "truncated percent escape in promisor remote".into(),
166            ));
167        }
168        let hex = std::str::from_utf8(&bytes[index + 1..index + 3])
169            .map_err(|err| GitError::InvalidFormat(err.to_string()))?;
170        let byte = u8::from_str_radix(hex, 16).map_err(|_| {
171            GitError::InvalidFormat("invalid percent escape in promisor remote".into())
172        })?;
173        out.push(byte);
174        index += 3;
175    }
176    String::from_utf8(out).map_err(|err| GitError::InvalidFormat(err.to_string()))
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn promisor_advertisement_round_trips_fields_and_escapes() {
185        let remotes = vec![PromisorRemoteAdvertisement {
186            name: "large,objects".into(),
187            url: "file:///tmp/space here;objects".into(),
188            partial_clone_filter: Some("blob:limit=5k".into()),
189            token: Some("a%b".into()),
190        }];
191        let encoded = encode_promisor_remote_advertisement(&remotes).expect("encode");
192        assert_eq!(
193            encoded,
194            "name=large%2Cobjects,url=file:///tmp/space%20here%3Bobjects,partialCloneFilter=blob:limit=5k,token=a%25b"
195        );
196        assert_eq!(
197            parse_promisor_remote_advertisement(&encoded).expect("parse"),
198            remotes
199        );
200    }
201
202    #[test]
203    fn promisor_reply_preserves_accepted_order() {
204        let names = vec!["second".into(), "first;fallback".into()];
205        let encoded = encode_promisor_remote_reply(&names)
206            .expect("encode")
207            .expect("non-empty reply");
208        assert_eq!(encoded, "second;first%3Bfallback");
209        assert_eq!(parse_promisor_remote_reply(&encoded).expect("parse"), names);
210    }
211}