1use mkit_core::repo_identity::{Namespace, RepositoryIdentity};
7
8use crate::error::ServerError;
9use crate::policy::NamespacePolicy;
10
11pub(crate) const MAX_REPO_NAME_BYTES: usize = 255;
14
15const DEPLOYMENT_DEFAULT_NAMESPACE: &str = "root";
21
22#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
25pub struct RepoName(String);
26
27impl RepoName {
28 pub fn new(name: impl Into<String>) -> Result<Self, ServerError> {
34 let name = name.into();
35 if name.is_empty()
36 || name.len() > MAX_REPO_NAME_BYTES
37 || !name.bytes().all(|b| (0x21..=0x7e).contains(&b))
38 {
39 return Err(ServerError::invalid_argument(
40 "repository name must be 1-255 printable ASCII bytes without whitespace",
41 ));
42 }
43 Ok(Self(name))
44 }
45
46 #[must_use]
48 pub fn as_str(&self) -> &str {
49 &self.0
50 }
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
57pub struct NamespaceKey(String);
58
59impl NamespaceKey {
60 #[must_use]
64 pub fn deployment_default() -> Self {
65 Self(DEPLOYMENT_DEFAULT_NAMESPACE.to_owned())
66 }
67
68 #[must_use]
70 pub fn as_str(&self) -> &str {
71 &self.0
72 }
73
74 #[must_use]
76 pub fn from_namespace(namespace: &Namespace) -> Self {
77 Self(namespace.to_string())
78 }
79
80 pub(crate) fn from_stored(key: String) -> Self {
83 Self(key)
84 }
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
89pub struct RepoId {
90 pub namespace: NamespaceKey,
92 pub name: RepoName,
94}
95
96#[derive(Debug, Clone, Default, PartialEq, Eq)]
98#[non_exhaustive]
99pub struct MultiAddressing {
100 pub namespace_policy: NamespacePolicy,
102}
103
104impl MultiAddressing {
105 #[must_use]
107 pub fn new() -> Self {
108 Self::default()
109 }
110
111 #[must_use]
113 pub fn with_namespace_policy(mut self, policy: NamespacePolicy) -> Self {
114 self.namespace_policy = policy;
115 self
116 }
117}
118
119#[derive(Debug, Clone, PartialEq, Eq)]
121#[non_exhaustive]
122pub struct ResolvedRepo {
123 pub repo: RepoId,
125 pub identity: String,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq)]
131#[non_exhaustive]
132pub enum Addressing {
133 Single {
135 repo: RepoId,
137 },
138 Multi(MultiAddressing),
142}
143
144impl Addressing {
145 pub fn resolve(
153 &self,
154 x_repository: Option<&str>,
155 signed: bool,
156 ) -> Result<ResolvedRepo, ServerError> {
157 let header = x_repository.filter(|s| !s.is_empty());
158 let invalid = || ServerError::invalid_argument("invalid X-Repository");
159 match self {
160 Self::Single { repo } => {
161 let identity = if repo.namespace == NamespaceKey::deployment_default() {
165 repo.name.as_str().to_owned()
166 } else {
167 format!("{}/{}", repo.namespace.as_str(), repo.name.as_str())
168 };
169 if let Some(header) = header {
170 RepositoryIdentity::parse_bare_allowed(header).map_err(|_| invalid())?;
171 if header != identity {
172 return Err(ServerError::repository_not_found());
173 }
174 } else if signed {
175 return Err(ServerError::unauthenticated(
176 "missing X-Repository on a signed request",
177 ));
178 }
179 Ok(ResolvedRepo {
180 repo: repo.clone(),
181 identity,
182 })
183 }
184 Self::Multi(_) => {
185 let header = header.ok_or_else(invalid)?;
186 let identity = RepositoryIdentity::parse(header).map_err(|_| invalid())?;
187 let namespace = identity.namespace().ok_or_else(invalid)?;
188 Ok(ResolvedRepo {
189 repo: RepoId {
190 namespace: NamespaceKey::from_namespace(namespace),
191 name: RepoName::new(identity.name())?,
192 },
193 identity: header.to_owned(),
194 })
195 }
196 }
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203 use crate::error::Code;
204
205 #[test]
206 fn repo_name_rejects_whitespace_newline_empty_and_256_bytes() {
207 for bad in [
208 String::new(),
209 "has space".into(),
210 "tab\there".into(),
211 "line\nbreak".into(),
212 "trailing\r".into(),
213 "del\u{7f}".into(),
214 "caf\u{e9}".into(),
215 "a".repeat(256),
216 ] {
217 let err = RepoName::new(bad.clone()).expect_err(&bad);
218 assert_eq!(err.code(), Code::InvalidArgument);
219 }
220 for good in [
221 "a".to_owned(),
222 "room-a".into(),
223 "~!@#/x_y.z".into(),
224 "a".repeat(255),
225 ] {
226 assert_eq!(RepoName::new(good.clone()).unwrap().as_str(), good);
227 }
228 }
229
230 #[test]
231 fn single_addressing_rejects_another_identity() {
232 let addressing = Addressing::Single {
233 repo: RepoId {
234 namespace: NamespaceKey::deployment_default(),
235 name: RepoName::new("room-a").unwrap(),
236 },
237 };
238 assert_eq!(
239 addressing
240 .resolve(Some("room-b"), false)
241 .unwrap_err()
242 .code(),
243 Code::NotFound
244 );
245 }
246
247 fn single(identity: &str) -> Addressing {
248 Addressing::Single {
249 repo: RepoId {
250 namespace: NamespaceKey::deployment_default(),
251 name: RepoName::new(identity).unwrap(),
252 },
253 }
254 }
255
256 #[test]
257 fn resolution_table_and_safe_errors() {
258 let single = single("default");
259 let multi = Addressing::Multi(MultiAddressing::new());
260 for signed in [false, true] {
261 for absent in [None, Some("")] {
262 let result = single.resolve(absent, signed);
263 if signed {
264 let e = result.unwrap_err();
265 assert_eq!(e.code(), Code::Unauthenticated);
266 assert_eq!(
267 e.public_message(),
268 "missing X-Repository on a signed request"
269 );
270 } else {
271 assert_eq!(
272 result.unwrap(),
273 single.resolve(Some("default"), false).unwrap()
274 );
275 }
276 let e = multi.resolve(absent, signed).unwrap_err();
277 assert_eq!(e.code(), Code::InvalidArgument);
278 assert_eq!(e.public_message(), "invalid X-Repository");
279 }
280 assert_eq!(
281 single
282 .resolve(Some("default"), signed)
283 .unwrap()
284 .repo
285 .namespace
286 .as_str(),
287 "root"
288 );
289 for bad in ["Default", ".x", "has space", "a/b"] {
290 let e = single.resolve(Some(bad), signed).unwrap_err();
291 assert_eq!(e.code(), Code::InvalidArgument);
292 assert_eq!(e.public_message(), "invalid X-Repository");
293 }
294 let identity = format!("ed25519-{}/one", "a".repeat(64));
295 for other in ["other", &identity] {
296 let e = single.resolve(Some(other), signed).unwrap_err();
297 assert_eq!(e.code(), Code::NotFound);
298 assert_eq!(e.public_message(), "repository not found");
299 }
300 let resolved = multi.resolve(Some(&identity), signed).unwrap();
301 assert_eq!(resolved.identity, identity);
302 assert_eq!(resolved.repo.namespace.as_str(), &identity[..72]);
303 assert_eq!(resolved.repo.name.as_str(), "one");
304 assert_eq!(
305 multi.resolve(Some("default"), signed).unwrap_err().code(),
306 Code::InvalidArgument
307 );
308 let configured = self::single(&identity)
309 .resolve(Some(&identity), true)
310 .unwrap();
311 assert_eq!(configured.repo.namespace.as_str(), "root");
312 assert_eq!(configured.repo.name.as_str(), identity);
313 }
314 }
315
316 #[test]
317 fn golden_repository_grammar_drives_both_modes() {
318 #[derive(serde::Deserialize)]
319 struct Case {
320 identity: String,
321 single_ok: bool,
322 multi_ok: bool,
323 }
324 let bytes = include_bytes!("../../../tests/golden/transport/repository-grammar.json");
325 let cases: Vec<Case> = serde_json::from_slice(bytes).unwrap();
326 let manifest = include_str!("../../../tests/golden/transport/MANIFEST.txt");
327 let digest = mkit_core::hash::to_hex(&mkit_core::hash::hash(bytes));
328 assert!(
329 manifest
330 .lines()
331 .any(|line| line == format!("repository-grammar.json {digest}"))
332 );
333 let multi = Addressing::Multi(MultiAddressing::new());
334 for case in cases {
335 for (addressing, valid) in [
338 (
339 single(if case.single_ok {
340 &case.identity
341 } else {
342 "default"
343 }),
344 case.single_ok,
345 ),
346 (multi.clone(), case.multi_ok),
347 ] {
348 let result = addressing.resolve(Some(&case.identity), false);
349 if valid {
350 assert_eq!(result.unwrap().identity, case.identity);
351 } else {
352 assert_eq!(
353 result.unwrap_err().code(),
354 Code::InvalidArgument,
355 "{}",
356 case.identity
357 );
358 }
359 }
360 }
361 }
362
363 #[test]
364 fn deployment_default_namespace_is_stable() {
365 assert_eq!(NamespaceKey::deployment_default().as_str(), "root");
366 }
367}