Skip to main content

kernel/
locator.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! Locator: the one grammar for referring to content across events and
7//! sessions. The part `crates/kernel/spec/Locator.lean` specifies this
8//! module. Two schemes only:
9//!
10//! `cas:b3-<hex64>[#(L|B)<a>-<b>]` | `file:<address>@<hex40>[#(L|B)<a>-<b>]`
11//!
12//! Invariants owned here:
13//! - fail-closed: whatever is not exactly the grammar is `E_LOCATOR_INVALID`;
14//!   unknown schemes and unknown algorithm tags are errors, never fallbacks.
15//! - canonical echo: `parse` additionally requires `Display(parsed) == raw`,
16//!   which closes every non-canonical spelling (uppercase hex, leading
17//!   zeros, `+` signs) with one rule.
18//! - `secret:` never parses here: SecretRef has a separate parser by
19//!   design, so a read path can never redeem a credential.
20//! - range semantics: `L` is 1-based closed (editor precedent), `B` is
21//!   0-based closed (HTTP Range precedent); `from <= to` always.
22//!
23//! [`B3Hash`] lives here because `b3-` is part of this grammar; the event
24//! chain and CAS reuse the same type (one hash function library-wide).
25
26use serde::{Deserialize, Serialize};
27
28use crate::address::Address;
29use crate::error::{AxCode, AxError};
30
31/// BLAKE3 digest, 32 bytes; displayed as 64 lowercase hex digits.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
33pub struct B3Hash([u8; 32]);
34
35impl B3Hash {
36    pub const fn from_bytes(bytes: [u8; 32]) -> Self {
37        B3Hash(bytes)
38    }
39
40    pub fn as_bytes(&self) -> &[u8; 32] {
41        &self.0
42    }
43
44    /// The library-wide content-hash producer. Every non-chain hash (prefix
45    /// segment hashes, stall fingerprints) comes through here so blake3 has
46    /// exactly one home outside `chain_hash`.
47    pub fn digest(bytes: &[u8]) -> Self {
48        B3Hash(*blake3::hash(bytes).as_bytes())
49    }
50}
51
52impl std::fmt::Display for B3Hash {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        write_hex(f, &self.0)
55    }
56}
57
58impl Serialize for B3Hash {
59    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
60        write_digest(self, &self.0, serializer)
61    }
62}
63
64impl<'de> Deserialize<'de> for B3Hash {
65    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
66        read_digest(deserializer).map(B3Hash)
67    }
68}
69
70/// Git object id, 40 lowercase hex digits (SHA-1 repositories; other
71/// lengths are rejected; SHA-256 repositories are not accepted).
72#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
73pub struct GitOid([u8; 20]);
74
75impl GitOid {
76    pub fn from_bytes(bytes: [u8; 20]) -> Self {
77        GitOid(bytes)
78    }
79
80    /// The hex spelling, read back.
81    ///
82    /// Public because a checkpoint identity travels as a string in more
83    /// than one direction: the wire deserialises one, and a client that
84    /// was shown a checkpoint in a sentence has to turn it back into an
85    /// oid to act on it. Without this the caller decodes its own hex,
86    /// which is a second definition of what an oid looks like - the exact
87    /// thing the note on `Deserialize` below says stays here.
88    ///
89    /// `None` on anything that is not exactly forty lowercase hex digits:
90    /// a wrong length is a refusal, never a padded guess.
91    #[must_use]
92    pub fn parse(raw: &str) -> Option<Self> {
93        decode_hex_fixed::<20>(raw).map(GitOid)
94    }
95}
96
97impl std::fmt::Display for GitOid {
98    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99        write_hex(f, &self.0)
100    }
101}
102
103impl Serialize for GitOid {
104    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
105        write_digest(self, &self.0, serializer)
106    }
107}
108
109impl<'de> Deserialize<'de> for GitOid {
110    /// The shape authority stays here, so the wire (`wire::frames`) has no
111    /// second definition of what an oid looks like.
112    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
113        read_digest(deserializer).map(GitOid)
114    }
115}
116
117/// A digest as its format wants it (`crates/kernel/spec/Locator.lean` ยง8-84): the hex spelling
118/// wherever a person or a frame reads it, the bytes themselves in a
119/// binary format, which only this binary reads back (a snapshot).
120fn write_digest<const N: usize, S: serde::Serializer>(
121    spelled: &impl std::fmt::Display,
122    bytes: &[u8; N],
123    serializer: S,
124) -> Result<S::Ok, S::Error>
125where
126    [u8; N]: Serialize,
127{
128    if serializer.is_human_readable() {
129        serializer.collect_str(spelled)
130    } else {
131        bytes.serialize(serializer)
132    }
133}
134
135/// What [`write_digest`] wrote. A human-readable format is read through
136/// the one hex spelling, and a wrong length is a refusal rather than a
137/// padded guess.
138fn read_digest<'de, const N: usize, D: serde::Deserializer<'de>>(
139    deserializer: D,
140) -> Result<[u8; N], D::Error>
141where
142    [u8; N]: Deserialize<'de>,
143{
144    if !deserializer.is_human_readable() {
145        return <[u8; N]>::deserialize(deserializer);
146    }
147    let raw = String::deserialize(deserializer)?;
148    decode_hex_fixed::<N>(&raw).ok_or_else(|| {
149        serde::de::Error::custom(format_args!(
150            "expected exactly {} lowercase hex digits",
151            N.saturating_mul(2)
152        ))
153    })
154}
155
156/// Sub-content range. `Lines` is 1-based closed; `Bytes` is 0-based closed.
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158pub enum Range {
159    Lines { from: u64, to: u64 },
160    Bytes { from: u64, to: u64 },
161}
162
163impl Range {
164    /// 1-based closed line range; rejects `from == 0` and `from > to`.
165    pub fn lines(from: u64, to: u64) -> Result<Self, AxError> {
166        if from == 0 || from > to {
167            return Err(range_error("lines", from, to));
168        }
169        Ok(Range::Lines { from, to })
170    }
171
172    /// 0-based closed byte range; rejects `from > to`.
173    pub fn bytes(from: u64, to: u64) -> Result<Self, AxError> {
174        if from > to {
175            return Err(range_error("bytes", from, to));
176        }
177        Ok(Range::Bytes { from, to })
178    }
179}
180
181fn range_error(unit: &str, from: u64, to: u64) -> AxError {
182    AxError::failure(
183        AxCode::InvalidArgs,
184        "construct range",
185        format!("{unit} {from}-{to}"),
186    )
187    .with_recovery("require from <= to; line ranges start at 1, byte ranges at 0")
188}
189
190impl std::fmt::Display for Range {
191    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
192        match self {
193            Range::Lines { from, to } => write!(f, "L{from}-{to}"),
194            Range::Bytes { from, to } => write!(f, "B{from}-{to}"),
195        }
196    }
197}
198
199/// The two-scheme reference grammar.
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub enum Locator {
202    Cas {
203        hash: B3Hash,
204        range: Option<Range>,
205    },
206    File {
207        address: Address,
208        oid: GitOid,
209        range: Option<Range>,
210    },
211}
212
213impl Locator {
214    /// The whole stored object behind `hash`. Infallible because a
215    /// `B3Hash` is already a digest the grammar accepts, so a caller that
216    /// holds one never spells the locator as text and parses it back.
217    #[must_use]
218    pub const fn cas(hash: B3Hash) -> Self {
219        Locator::Cas { hash, range: None }
220    }
221
222    /// Fail-closed parser: exact grammar or `E_LOCATOR_INVALID`.
223    pub fn parse(raw: &str) -> Result<Self, AxError> {
224        let parsed = Locator::parse_inner(raw)?;
225        // Canonical echo: one rule closes every non-canonical spelling.
226        let echo = parsed.to_string();
227        if echo != raw {
228            return Err(invalid(
229                raw,
230                format!("non-canonical spelling; use `{echo}`"),
231            ));
232        }
233        Ok(parsed)
234    }
235
236    fn parse_inner(raw: &str) -> Result<Self, AxError> {
237        if let Some(rest) = raw.strip_prefix("cas:") {
238            let (body, range) = split_range(raw, rest)?;
239            let hex = body.strip_prefix("b3-").ok_or_else(|| {
240                invalid(
241                    raw,
242                    "unknown or missing algorithm tag; the only tag is `b3-`",
243                )
244            })?;
245            let hash = decode_hex_fixed::<32>(hex)
246                .map(B3Hash)
247                .ok_or_else(|| invalid(raw, "hash must be exactly 64 lowercase hex digits"))?;
248            return Ok(Locator::Cas { hash, range });
249        }
250        if let Some(rest) = raw.strip_prefix("file:") {
251            let (body, range) = split_range(raw, rest)?;
252            let (addr_raw, oid_raw) = body
253                .rsplit_once('@')
254                .ok_or_else(|| invalid(raw, "file locator needs `<address>@<git-oid>`"))?;
255            let address = Address::parse(addr_raw)
256                .map_err(|_| invalid(raw, "address part violates the address grammar"))?;
257            let oid = GitOid::parse(oid_raw)
258                .ok_or_else(|| invalid(raw, "git oid must be exactly 40 lowercase hex digits"))?;
259            return Ok(Locator::File {
260                address,
261                oid,
262                range,
263            });
264        }
265        if raw.starts_with("secret:") {
266            return Err(invalid(
267                raw,
268                "SecretRef never enters the locator grammar (separate parser by design)",
269            ));
270        }
271        Err(invalid(
272            raw,
273            "unknown scheme; only `cas:` and `file:` exist",
274        ))
275    }
276}
277
278impl std::fmt::Display for Locator {
279    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
280        match self {
281            Locator::Cas { hash, range } => {
282                write!(f, "cas:b3-{hash}")?;
283                write_range(f, range)
284            }
285            Locator::File {
286                address,
287                oid,
288                range,
289            } => {
290                write!(f, "file:{address}@{oid}")?;
291                write_range(f, range)
292            }
293        }
294    }
295}
296
297impl Serialize for Locator {
298    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
299        serializer.collect_str(self)
300    }
301}
302
303impl<'de> Deserialize<'de> for Locator {
304    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
305        let raw = String::deserialize(deserializer)?;
306        Locator::parse(&raw).map_err(serde::de::Error::custom)
307    }
308}
309
310fn invalid(raw: &str, violation: impl Into<String>) -> AxError {
311    AxError::failure(AxCode::LocatorInvalid, "parse locator", raw).with_recovery(violation.into())
312}
313
314/// Splits an optional `#(L|B)<a>-<b>` suffix off `rest`; `raw` only feeds
315/// error context.
316fn split_range(raw: &str, rest: &str) -> Result<(String, Option<Range>), AxError> {
317    match rest.split_once('#') {
318        None => Ok((rest.to_owned(), None)),
319        Some((body, fragment)) => {
320            let range = parse_range(raw, fragment)?;
321            Ok((body.to_owned(), Some(range)))
322        }
323    }
324}
325
326fn parse_range(raw: &str, fragment: &str) -> Result<Range, AxError> {
327    let mut chars = fragment.chars();
328    let unit = chars
329        .next()
330        .ok_or_else(|| invalid(raw, "empty range fragment"))?;
331    let body: &str = chars.as_str();
332    let (from_raw, to_raw) = body
333        .split_once('-')
334        .ok_or_else(|| invalid(raw, "range needs `<from>-<to>`"))?;
335    let from = parse_canonical_u64(raw, from_raw)?;
336    let to = parse_canonical_u64(raw, to_raw)?;
337    let range = match unit {
338        'L' => Range::lines(from, to),
339        'B' => Range::bytes(from, to),
340        _ => {
341            return Err(invalid(
342                raw,
343                "range unit must be `L` (lines) or `B` (bytes)",
344            ));
345        }
346    };
347    range.map_err(|_| invalid(raw, "range bounds are out of order or out of base"))
348}
349
350/// Digits only, no leading zeros (except "0" itself), no signs.
351fn parse_canonical_u64(raw: &str, digits: &str) -> Result<u64, AxError> {
352    if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) {
353        return Err(invalid(raw, "range bound must be decimal digits"));
354    }
355    if digits.len() > 1 && digits.starts_with('0') {
356        return Err(invalid(raw, "range bound must not carry leading zeros"));
357    }
358    digits
359        .parse::<u64>()
360        .map_err(|_| invalid(raw, "range bound exceeds u64"))
361}
362
363pub(crate) fn write_hex(f: &mut std::fmt::Formatter<'_>, bytes: &[u8]) -> std::fmt::Result {
364    for b in bytes {
365        write!(f, "{b:02x}")?;
366    }
367    Ok(())
368}
369
370fn write_range(f: &mut std::fmt::Formatter<'_>, range: &Option<Range>) -> std::fmt::Result {
371    match range {
372        Some(r) => write!(f, "#{r}"),
373        None => Ok(()),
374    }
375}
376
377/// Strict lowercase hex into a fixed-width array; `None` on any deviation.
378pub(crate) fn decode_hex_fixed<const N: usize>(raw: &str) -> Option<[u8; N]> {
379    if raw.len() != N.checked_mul(2)? {
380        return None;
381    }
382    let mut out = Vec::with_capacity(N);
383    let mut bytes = raw.bytes();
384    while let Some(hi) = bytes.next() {
385        let lo = bytes.next()?;
386        let value = nibble(hi)?.checked_mul(16)?.checked_add(nibble(lo)?)?;
387        out.push(value);
388    }
389    <[u8; N]>::try_from(out.as_slice()).ok()
390}
391
392/// Lowercase-only nibble; uppercase is non-canonical and refused.
393fn nibble(c: u8) -> Option<u8> {
394    match c {
395        b'0'..=b'9' => c.checked_sub(b'0'),
396        b'a'..=b'f' => c.checked_sub(b'a')?.checked_add(10),
397        _ => None,
398    }
399}
400
401#[cfg(test)]
402#[allow(
403    clippy::unwrap_used,
404    clippy::expect_used,
405    clippy::panic,
406    clippy::indexing_slicing,
407    clippy::wildcard_enum_match_arm,
408    clippy::let_underscore_must_use,
409    clippy::let_underscore_untyped,
410    reason = "test code"
411)]
412mod tests;