Skip to main content

mkit_server/
repo.rs

1//! Repository and namespace identifiers (PRD §6.1).
2//!
3//! Single deployments retain their configured name and `root` namespace.
4//! Multi deployments use SPEC-TRANSPORT-CONNECT §7.4 identities.
5
6use mkit_core::repo_identity::{Namespace, RepositoryIdentity};
7
8use crate::error::ServerError;
9use crate::policy::NamespacePolicy;
10
11/// Longest accepted repository name, matching the auth v2 `repository`
12/// component bound (`mkit_core::write_auth`).
13pub(crate) const MAX_REPO_NAME_BYTES: usize = 255;
14
15/// The namespace a single-repository deployment uses: a reserved sentinel.
16/// SPEC-TRANSPORT-CONNECT §7.4 namespaces are always `ed25519-<64 hex>` or
17/// `0x<40 hex>`, so no request can ever select `root`. It matches the
18/// `vcs-worker` `RefStore` instance name, so M0 needs no Durable Object
19/// migration (planner default Q13).
20const DEPLOYMENT_DEFAULT_NAMESPACE: &str = "root";
21
22/// A repository name: 1..=255 bytes of printable ASCII with no whitespace
23/// (bytes `0x21..=0x7e`).
24#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
25pub struct RepoName(String);
26
27impl RepoName {
28    /// Validate `name`.
29    ///
30    /// # Errors
31    /// [`crate::Code::InvalidArgument`] if `name` is empty, longer than 255
32    /// bytes, or holds a byte outside `0x21..=0x7e`.
33    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    /// The name as given.
47    #[must_use]
48    pub fn as_str(&self) -> &str {
49        &self.0
50    }
51}
52
53/// A namespace key. Under D34 it selects the namespace coordinator and
54/// prefixes every shard key. Single deployments use the reserved default;
55/// Multi deployments use a parsed self-certifying namespace (§7.4).
56#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
57pub struct NamespaceKey(String);
58
59impl NamespaceKey {
60    /// The namespace of a single-repository deployment: the reserved
61    /// sentinel `"root"`, which the SPEC-TRANSPORT-CONNECT §7.4 namespace
62    /// grammar (`ed25519-…` / `0x…`) can never select.
63    #[must_use]
64    pub fn deployment_default() -> Self {
65        Self(DEPLOYMENT_DEFAULT_NAMESPACE.to_owned())
66    }
67
68    /// The key as a string.
69    #[must_use]
70    pub fn as_str(&self) -> &str {
71        &self.0
72    }
73
74    /// A canonical namespace already validated by the shared grammar.
75    #[must_use]
76    pub fn from_namespace(namespace: &Namespace) -> Self {
77        Self(namespace.to_string())
78    }
79
80    /// A key read back from storage (`Partition::decode`): the store only
81    /// ever holds keys this server encoded.
82    pub(crate) fn from_stored(key: String) -> Self {
83        Self(key)
84    }
85}
86
87/// A repository's full identity.
88#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
89pub struct RepoId {
90    /// Owning namespace.
91    pub namespace: NamespaceKey,
92    /// Name within the namespace.
93    pub name: RepoName,
94}
95
96/// Multi-repository addressing with a deployment namespace policy.
97#[derive(Debug, Clone, Default, PartialEq, Eq)]
98#[non_exhaustive]
99pub struct MultiAddressing {
100    /// Namespaces permitted for writes. Defaults to an empty allowlist.
101    pub namespace_policy: NamespacePolicy,
102}
103
104impl MultiAddressing {
105    /// Route requests using their namespaced `X-Repository` identity.
106    #[must_use]
107    pub fn new() -> Self {
108        Self::default()
109    }
110
111    /// Route with the given namespace policy (SPEC-TRANSPORT-CONNECT §7.5).
112    #[must_use]
113    pub fn with_namespace_policy(mut self, policy: NamespacePolicy) -> Self {
114        self.namespace_policy = policy;
115        self
116    }
117}
118
119/// A resolved request target and its byte-exact wire identity.
120#[derive(Debug, Clone, PartialEq, Eq)]
121#[non_exhaustive]
122pub struct ResolvedRepo {
123    /// The storage identity (single deployments retain the M0 layout).
124    pub repo: RepoId,
125    /// The identity carried on the wire.
126    pub identity: String,
127}
128
129/// How a deployment maps a request to a repository.
130#[derive(Debug, Clone, PartialEq, Eq)]
131#[non_exhaustive]
132pub enum Addressing {
133    /// One configured repository, with optional addressing on unsigned RPCs.
134    Single {
135        /// The configured repository.
136        repo: RepoId,
137    },
138    /// Routes by `X-Repository`. The adapters serve it (WP-1.30);
139    /// R-111(1)'s no-deployment-before-WP-1.14 rule still applies to the
140    /// whole branch (ticket expiry).
141    Multi(MultiAddressing),
142}
143
144impl Addressing {
145    /// Resolve the request target using SPEC-TRANSPORT-CONNECT §7.4.
146    /// Empty headers count as absent; identities are never normalized.
147    ///
148    /// # Errors
149    /// `invalid_argument` for malformed identities or missing Multi headers;
150    /// `unauthenticated` for missing signed Single headers; `not_found` for
151    /// another well-formed identity on a Single deployment.
152    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                // A Single deployment under the reserved `root` namespace
162                // keeps its bare-name wire identity; a self-certifying
163                // namespace (`mkit serve --root`, WP-1.15) is part of it.
164                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            // Valid Single cases configure their byte-exact identity; invalid
336            // cases use a valid config and must fail grammar, not equality.
337            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}