sley_protocol/
promisor.rs1use sley_core::{GitError, Result};
2
3#[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
12pub 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
49pub 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
102pub 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}