Skip to main content

majordomus_cli/release/
surface.rs

1//! The public surface: everything a caller can hold, read from the one declaration that
2//! states it.
3//!
4//! # Why this exists as a type
5//!
6//! A version is a claim about compatibility, and a claim nobody can check is worth nothing.
7//! Elm's package manager answers this by refusing to let the author choose: it reads the
8//! public API of both versions and computes the smallest honest bump. This module is the
9//! first half of that — the reading — and [`super::compat`] is the second.
10//!
11//! The surface is not discovered by crawling a running server, parsing rendered Markdown or
12//! calling MCP. Every interface of this repository is a projection of the capability
13//! registry (ADR 0002, ADR 0027), so the registry *is* the public surface and the
14//! projections are what it looks like from outside. Comparing projections would compare
15//! renderings; comparing the registry compares the contract.
16//!
17//! # Two origins, one shape
18//!
19//! ```text
20//!   the current tree    generate::registry_manifest(&registry)   in process, never stale
21//!   a released ref      git show <ref>:docs/generated/registry.json
22//! ```
23//!
24//! The current tree is read from the live registry rather than from the committed file,
25//! because a committed projection can be stale and a surface comparison against a stale
26//! *current* side is a false verdict in the dangerous direction: it would report a removal
27//! that did not happen, or miss an addition that did. A historical ref has no such option —
28//! the executable of that commit is not available — so the committed projection is used,
29//! and [`Surface::read_ref`] refuses loudly rather than guessing when a ref does not carry
30//! one. That asymmetry is deliberate and is the reason the generated registry is committed
31//! at every commit at all.
32//!
33//! ```
34//! use majordomus_cli::release::surface::{public_capabilities, Origin, Surface, SURFACE_SCHEMA};
35//! use serde_json::json;
36//!
37//! let surface = Surface {
38//!     schema: SURFACE_SCHEMA.to_string(),
39//!     origin: Origin::WorkingTree,
40//!     capabilities: public_capabilities(&json!({"capabilities": [
41//!         {"id": "a.one", "kind": "query", "visibility": "public",
42//!          "exposure": {"http": {"method": "GET", "path": "/api/v1/a"}}},
43//!         {"id": "a.hidden", "kind": "query", "visibility": "internal"},
44//!     ]})),
45//! };
46//! // An internal capability is not a promise to anyone, so it is not surface.
47//! assert_eq!(surface.capabilities.len(), 1);
48//! assert_eq!(surface.atoms(), 2, "the identity and the route");
49//! assert!(surface.fingerprint().starts_with("sha256:"));
50//! ```
51
52use std::collections::{BTreeMap, BTreeSet};
53use std::path::Path;
54use std::process::Command;
55
56use schemars::JsonSchema;
57use serde::{Deserialize, Serialize};
58use serde_json::Value;
59
60/// The committed projection of the registry, which is how a released ref's surface is read.
61pub const REGISTRY: &str = "docs/generated/registry.json";
62
63/// The schema this module's normalisation is defined against. It is carried into the plan so
64/// that a verdict recorded today can be reproduced when the rules below change.
65pub const SURFACE_SCHEMA: &str = "majordomus/public-surface/v1";
66
67/// Where a surface was read from.
68///
69/// ```
70/// use majordomus_cli::release::surface::Origin;
71/// let origin = Origin::Ref { reference: "v0.5.0".into(), commit: "3a032c1e".into() };
72/// // A ref origin names the commit, so a verdict is never pinned to a moving name.
73/// assert_ne!(origin, Origin::WorkingTree);
74/// ```
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
76#[serde(tag = "origin", rename_all = "snake_case")]
77#[schemars(rename = "ReleaseSurfaceOrigin")]
78pub enum Origin {
79    /// The live registry of this executable, which is what the current tree declares.
80    WorkingTree,
81    /// The committed projection at a ref.
82    Ref {
83        /// The ref as it was named: `v0.5.0`.
84        reference: String,
85        /// The commit it resolved to, so the verdict names a commit and not a moving name.
86        commit: String,
87    },
88}
89
90/// One HTTP binding a caller can hold.
91///
92/// ```
93/// use majordomus_cli::release::surface::HttpBinding;
94/// let binding = HttpBinding { method: "GET".into(), path: "/api/v1/release/analysis".into() };
95/// assert_eq!(binding.to_string(), "GET /api/v1/release/analysis");
96/// ```
97#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
98#[schemars(rename = "ReleaseHttpBinding")]
99pub struct HttpBinding {
100    /// `GET`.
101    pub method: String,
102    /// `/api/v1/release/analysis`.
103    pub path: String,
104}
105
106impl std::fmt::Display for HttpBinding {
107    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108        write!(f, "{} {}", self.method, self.path)
109    }
110}
111
112/// One public capability, reduced to what a caller outside this repository can depend on.
113///
114/// Everything here is a promise; nothing here is an implementation detail. The title and the
115/// description are deliberately absent — they are how the contract is explained, not what it
116/// is, and a reworded description that forced a release would make the gate a thing to route
117/// around within a week.
118///
119/// ```
120/// use majordomus_cli::release::surface::{public_capabilities, PublicCapability};
121/// use serde_json::json;
122/// let caps = public_capabilities(&json!({"capabilities": [{
123///     "id": "a.one", "kind": "query", "visibility": "public",
124///     "exposure": {"cli": {"path": ["a", "one"]}},
125///     "output": {"schema": {"type": "object", "description": "prose"}}
126/// }]}));
127/// let capability: &PublicCapability = &caps["a.one"];
128/// assert_eq!(capability.cli.as_deref(), Some("a one"));
129/// // Prose is normalised away: a reworded description is not a contract change.
130/// assert!(capability.output.as_ref().unwrap().get("description").is_none());
131/// ```
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
133#[schemars(rename = "ReleasePublicCapability")]
134pub struct PublicCapability {
135    /// `release.analysis`.
136    pub id: String,
137    /// `query` or `command`: whether calling it changes anything.
138    pub kind: String,
139    /// The MCP tool name, when it answers to one.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub mcp_tool: Option<String>,
142    /// The MCP resource URI, when it is readable as one.
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub mcp_resource: Option<String>,
145    /// The HTTP method and path, when it is bound to one.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub http: Option<HttpBinding>,
148    /// The command-line path that dispatches it, when one does.
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub cli: Option<String>,
151    /// The normalised input contract: what a caller may send.
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub input: Option<Value>,
154    /// The normalised output contract: what a caller is promised back.
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub output: Option<Value>,
157}
158
159/// The public surface of one tree or one ref.
160///
161/// ```
162/// use majordomus_cli::release::surface::{public_capabilities, Origin, Surface, SURFACE_SCHEMA};
163/// use serde_json::json;
164/// let of = |caps| Surface {
165///     schema: SURFACE_SCHEMA.to_string(),
166///     origin: Origin::WorkingTree,
167///     capabilities: public_capabilities(&json!({"capabilities": caps})),
168/// };
169/// // The same surface enumerated in a different order is the same surface.
170/// let one = of(json!([{"id": "b", "kind": "query"}, {"id": "a", "kind": "query"}]));
171/// let other = of(json!([{"id": "a", "kind": "query"}, {"id": "b", "kind": "query"}]));
172/// assert_eq!(one.fingerprint(), other.fingerprint());
173/// ```
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
175#[schemars(rename = "ReleasePublicSurface")]
176pub struct Surface {
177    /// The normalisation these capabilities were reduced by.
178    pub schema: String,
179    /// Where it was read from.
180    pub origin: Origin,
181    /// Every public capability, by id, in a deterministic order.
182    pub capabilities: BTreeMap<String, PublicCapability>,
183}
184
185/// Why a surface could not be read.
186///
187/// Each is a different fact, and collapsing them would make a check that cannot reach its
188/// subject indistinguishable from one that found nothing wrong:
189///
190/// ```
191/// use majordomus_cli::release::surface::SurfaceError;
192/// let e = SurfaceError::NothingPublished;
193/// assert!(e.to_string().contains("published nothing"));
194/// let e = SurfaceError::NoRegistry { reference: "v0.1.0".into() };
195/// assert!(e.to_string().contains("carries no"));
196/// ```
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub enum SurfaceError {
199    /// The ref carries no committed registry projection, so its surface is unknowable.
200    NoRegistry {
201        /// The ref that was asked.
202        reference: String,
203    },
204    /// The ref is not a commit in this repository, or git could not be run.
205    NoSuchRef {
206        /// The ref that was asked.
207        reference: String,
208        /// What git said.
209        reason: String,
210    },
211    /// The repository has published nothing at all, so there is no baseline to measure
212    /// against. Distinct from a ref that cannot be resolved: nothing is missing from this
213    /// clone, there is simply no release yet, and telling a first-time user to fetch tags
214    /// that do not exist sends them looking for a fault that is not there.
215    NothingPublished,
216    /// The registry was found but is not the document this reads.
217    Malformed {
218        /// Where it came from.
219        source: String,
220        /// What was wrong with it.
221        reason: String,
222    },
223}
224
225impl std::fmt::Display for SurfaceError {
226    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
227        match self {
228            SurfaceError::NoRegistry { reference } => write!(
229                f,
230                "{reference} carries no {REGISTRY}, so the surface it published cannot be read; \
231                 a release before the registry was committed cannot be compared with"
232            ),
233            SurfaceError::NoSuchRef { reference, reason } => write!(
234                f,
235                "{reference} is not a commit in this repository ({reason}); \
236                 a shallow clone is the usual cause — fetch the tags and their commits"
237            ),
238            SurfaceError::NothingPublished => write!(
239                f,
240                "this repository has published nothing: there is no release record and no \
241                 version tag, so there is no baseline to measure this tree against"
242            ),
243            SurfaceError::Malformed { source, reason } => {
244                write!(f, "the registry at {source} could not be read: {reason}")
245            }
246        }
247    }
248}
249
250impl std::error::Error for SurfaceError {}
251
252impl Surface {
253    /// The surface of the current tree, from the live registry.
254    ///
255    /// Never reads the committed projection: a stale `docs/generated/registry.json` would
256    /// make this side of the comparison a description of an older tree, and every verdict
257    /// drawn from it wrong in a way nothing else would catch.
258    ///
259    /// ```
260    /// use majordomus_cli::capability::registry::CapabilityRegistry;
261    /// use majordomus_cli::release::surface::{Origin, Surface};
262    /// let registry = CapabilityRegistry::builder().build().expect("an empty registry composes");
263    /// let surface = Surface::of_registry(&registry);
264    /// assert_eq!(surface.origin, Origin::WorkingTree);
265    /// ```
266    pub fn of_registry(registry: &crate::capability::registry::CapabilityRegistry) -> Surface {
267        let manifest = crate::generate::registry_manifest(registry);
268        // The manifest is this executable's own projection, so a shape it cannot read is a
269        // bug here rather than bad input; an empty surface is the honest answer and the
270        // diff against it will be loud.
271        Surface {
272            schema: SURFACE_SCHEMA.to_string(),
273            origin: Origin::WorkingTree,
274            capabilities: public_capabilities(&manifest),
275        }
276    }
277
278    /// The surface a ref published, from the registry projection committed at it.
279    ///
280    /// ```
281    /// use majordomus_cli::release::surface::{Surface, SurfaceError};
282    /// let dir = tempfile::tempdir().unwrap();
283    /// // Not a work tree, so there is no such ref — and it says so rather than
284    /// // comparing against an empty surface.
285    /// assert!(matches!(
286    ///     Surface::read_ref(dir.path(), "v0.5.0"),
287    ///     Err(SurfaceError::NoSuchRef { .. })
288    /// ));
289    /// ```
290    pub fn read_ref(root: &Path, reference: &str) -> Result<Surface, SurfaceError> {
291        let commit = rev_parse(root, reference).ok_or_else(|| SurfaceError::NoSuchRef {
292            reference: reference.to_string(),
293            reason: "git could not resolve it to a commit".to_string(),
294        })?;
295        let out = Command::new("git")
296            .current_dir(root)
297            .args(["show", &format!("{reference}:{REGISTRY}")])
298            .output()
299            .map_err(|e| SurfaceError::NoSuchRef {
300                reference: reference.to_string(),
301                reason: e.to_string(),
302            })?;
303        if !out.status.success() {
304            return Err(SurfaceError::NoRegistry {
305                reference: reference.to_string(),
306            });
307        }
308        let manifest: Value =
309            serde_json::from_slice(&out.stdout).map_err(|e| SurfaceError::Malformed {
310                source: format!("{reference}:{REGISTRY}"),
311                reason: e.to_string(),
312            })?;
313        if manifest
314            .get("capabilities")
315            .and_then(|c| c.as_array())
316            .is_none()
317        {
318            return Err(SurfaceError::Malformed {
319                source: format!("{reference}:{REGISTRY}"),
320                reason: "it carries no `capabilities` array".to_string(),
321            });
322        }
323        Ok(Surface {
324            schema: SURFACE_SCHEMA.to_string(),
325            origin: Origin::Ref {
326                reference: reference.to_string(),
327                commit,
328            },
329            capabilities: public_capabilities(&manifest),
330        })
331    }
332
333    /// How many public atoms this surface carries: the things a caller can hold, counted.
334    ///
335    /// One per capability identity, plus one per binding it answers to. This is what the
336    /// reports mean by "87 public atoms"; it is a size, never an input to a verdict.
337    ///
338    /// ```
339    /// use majordomus_cli::release::surface::{public_capabilities, Origin, Surface, SURFACE_SCHEMA};
340    /// use serde_json::json;
341    /// let surface = Surface {
342    ///     schema: SURFACE_SCHEMA.to_string(),
343    ///     origin: Origin::WorkingTree,
344    ///     capabilities: public_capabilities(&json!({"capabilities": [{
345    ///         "id": "a.one", "kind": "query", "visibility": "public",
346    ///         "exposure": {"mcp": {"tool": "t"}, "http": {"method": "GET", "path": "/x"}}
347    ///     }]})),
348    /// };
349    /// assert_eq!(surface.atoms(), 3, "the identity, the tool, the route");
350    /// ```
351    pub fn atoms(&self) -> usize {
352        self.capabilities
353            .values()
354            .map(|c| {
355                1 + usize::from(c.mcp_tool.is_some())
356                    + usize::from(c.mcp_resource.is_some())
357                    + usize::from(c.http.is_some())
358                    + usize::from(c.cli.is_some())
359            })
360            .sum()
361    }
362
363    /// A stable identity for this surface.
364    ///
365    /// Deterministic, ordering-independent and schema-versioned: the same logical surface
366    /// hashes the same however the registry enumerated it, because the capabilities are a
367    /// sorted map and every schema was normalised before it got here. Two trees with the
368    /// same fingerprint have the same public contract, which is what makes it usable as
369    /// release evidence rather than decoration.
370    ///
371    /// ```
372    /// use majordomus_cli::capability::registry::CapabilityRegistry;
373    /// use majordomus_cli::release::surface::Surface;
374    /// let registry = CapabilityRegistry::builder().build().expect("an empty registry composes");
375    /// let surface = Surface::of_registry(&registry);
376    /// assert_eq!(surface.fingerprint(), surface.fingerprint(), "deterministic");
377    /// assert!(surface.fingerprint().starts_with("sha256:"));
378    /// ```
379    pub fn fingerprint(&self) -> String {
380        let mut hasher = Sha256::new();
381        hasher.update(SURFACE_SCHEMA.as_bytes());
382        hasher.update(b"\n");
383        for (id, c) in &self.capabilities {
384            hasher.update(id.as_bytes());
385            hasher.update(b"\0");
386            // Serialised through serde_json, whose object keys here are already sorted:
387            // `PublicCapability` is a struct with a fixed field order and every schema
388            // inside it was rebuilt into a BTreeMap by `normalise`.
389            let bytes = serde_json::to_vec(c).unwrap_or_default();
390            hasher.update(&bytes);
391            hasher.update(b"\n");
392        }
393        format!("sha256:{}", hasher.hex())
394    }
395}
396
397/// Resolve a ref to a full commit id.
398fn rev_parse(root: &Path, reference: &str) -> Option<String> {
399    let out = Command::new("git")
400        .current_dir(root)
401        .args([
402            "rev-parse",
403            "--verify",
404            "--quiet",
405            &format!("{reference}^{{commit}}"),
406        ])
407        .output()
408        .ok()?;
409    if !out.status.success() {
410        return None;
411    }
412    let id = String::from_utf8_lossy(&out.stdout).trim().to_string();
413    if id.is_empty() {
414        None
415    } else {
416        Some(id)
417    }
418}
419
420/// Reduce a registry manifest to the public capabilities it declares.
421///
422/// A capability that is not public is not part of the contract and is dropped here rather
423/// than carried and filtered later, so that nothing downstream can accidentally hold one.
424///
425/// ```
426/// use majordomus_cli::release::surface::public_capabilities;
427/// use serde_json::json;
428/// let caps = public_capabilities(&json!({"capabilities": [
429///     {"id": "a.one", "kind": "query", "visibility": "public"},
430///     {"id": "a.two", "kind": "query", "visibility": "internal"},
431///     {"id": "a.three", "kind": "query"},
432/// ]}));
433/// assert!(caps.contains_key("a.one"));
434/// assert!(!caps.contains_key("a.two"), "internal is not a promise to anyone");
435/// assert!(caps.contains_key("a.three"), "the registry's own default is public");
436/// ```
437pub fn public_capabilities(manifest: &Value) -> BTreeMap<String, PublicCapability> {
438    let mut out = BTreeMap::new();
439    let Some(list) = manifest.get("capabilities").and_then(|c| c.as_array()) else {
440        return out;
441    };
442    for c in list {
443        // A capability with no stated visibility is public: that is the registry's own
444        // default, and reading it differently here would make this a second policy.
445        let visibility = c
446            .get("visibility")
447            .and_then(|v| v.as_str())
448            .unwrap_or("public");
449        if visibility != "public" {
450            continue;
451        }
452        let Some(id) = c.get("id").and_then(|v| v.as_str()) else {
453            continue;
454        };
455        let exposure = c.get("exposure");
456        let mcp = exposure.and_then(|e| e.get("mcp"));
457        let http = exposure.and_then(|e| e.get("http")).and_then(|h| {
458            Some(HttpBinding {
459                method: h.get("method")?.as_str()?.to_string(),
460                path: h.get("path")?.as_str()?.to_string(),
461            })
462        });
463        let cli = exposure
464            .and_then(|e| e.get("cli"))
465            .and_then(|c| c.get("path"))
466            .and_then(|p| p.as_array())
467            .map(|parts| {
468                parts
469                    .iter()
470                    .filter_map(|p| p.as_str())
471                    .collect::<Vec<_>>()
472                    .join(" ")
473            });
474        out.insert(
475            id.to_string(),
476            PublicCapability {
477                id: id.to_string(),
478                kind: c
479                    .get("kind")
480                    .and_then(|v| v.as_str())
481                    .unwrap_or("query")
482                    .to_string(),
483                mcp_tool: mcp
484                    .and_then(|m| m.get("tool"))
485                    .and_then(|t| t.as_str())
486                    .map(str::to_string),
487                mcp_resource: mcp
488                    .and_then(|m| m.get("resource"))
489                    .and_then(|r| r.get("uri"))
490                    .and_then(|u| u.as_str())
491                    .map(str::to_string),
492                http,
493                cli,
494                input: c.get("input").and_then(|i| i.get("schema")).map(normalise),
495                output: c.get("output").and_then(|o| o.get("schema")).map(normalise),
496            },
497        );
498    }
499    out
500}
501
502/// The keys of a schema that explain it rather than constrain it.
503///
504/// Removing these is what stops a reworded doc comment from demanding a release. Everything
505/// else a schema carries is a constraint a caller can violate, and is kept.
506const PROSE_KEYS: &[&str] = &["description", "title", "$comment", "examples", "example"];
507
508/// Reduce a JSON Schema to the part of it a caller can break against.
509///
510/// Two normalisations, and no more, because every one of them is a place a real contract
511/// change could hide:
512///
513/// - the prose keys above are dropped, at every depth;
514/// - object keys are rebuilt in sorted order, so that a generator that emitted them
515///   differently produces the same value.
516///
517/// Array order is *kept*. A schema's `required` list, its `enum` and its `oneOf` are sets in
518/// meaning but the comparison in [`super::compat`] treats them as sets explicitly, where it
519/// knows what they mean. Sorting them here would also silently reorder `prefixItems`, where
520/// order is the contract.
521///
522/// ```
523/// use majordomus_cli::release::surface::normalise;
524/// use serde_json::json;
525/// let one = json!({"type": "object", "description": "one wording"});
526/// let other = json!({"type": "object", "description": "another, at length"});
527/// assert_eq!(normalise(&one), normalise(&other), "prose is not contract");
528/// // and it is a fixed point, so normalising twice changes nothing further
529/// let once = normalise(&one);
530/// assert_eq!(normalise(&once), once);
531/// ```
532pub fn normalise(schema: &Value) -> Value {
533    match schema {
534        Value::Object(map) => {
535            let mut out = serde_json::Map::new();
536            // BTreeSet gives the sorted key order without sorting a Vec of clones.
537            let keys: BTreeSet<&String> = map.keys().collect();
538            for k in keys {
539                if PROSE_KEYS.contains(&k.as_str()) {
540                    continue;
541                }
542                out.insert(k.clone(), normalise(&map[k]));
543            }
544            Value::Object(out)
545        }
546        Value::Array(items) => Value::Array(items.iter().map(normalise).collect()),
547        other => other.clone(),
548    }
549}
550
551/// A SHA-256, so that the fingerprint needs no dependency this crate does not already have.
552///
553/// The implementation is the FIPS 180-4 one, written out rather than pulled in: this crate
554/// has no hash dependency, and adding one for a single fingerprint would be a larger change
555/// to the dependency policy than the feature warrants.
556struct Sha256 {
557    state: [u32; 8],
558    buffer: Vec<u8>,
559    length: u64,
560}
561
562const K: [u32; 64] = [
563    0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
564    0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
565    0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
566    0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
567    0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
568    0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
569    0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
570    0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
571];
572
573impl Sha256 {
574    fn new() -> Sha256 {
575        Sha256 {
576            state: [
577                0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
578                0x5be0cd19,
579            ],
580            buffer: Vec::with_capacity(64),
581            length: 0,
582        }
583    }
584
585    fn update(&mut self, bytes: &[u8]) {
586        self.length = self.length.wrapping_add(bytes.len() as u64);
587        self.buffer.extend_from_slice(bytes);
588        while self.buffer.len() >= 64 {
589            let block: [u8; 64] = self.buffer[..64].try_into().expect("64 bytes");
590            self.compress(&block);
591            self.buffer.drain(..64);
592        }
593    }
594
595    fn compress(&mut self, block: &[u8; 64]) {
596        let mut w = [0u32; 64];
597        for i in 0..16 {
598            w[i] = u32::from_be_bytes(block[i * 4..i * 4 + 4].try_into().expect("4 bytes"));
599        }
600        for i in 16..64 {
601            let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
602            let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
603            w[i] = w[i - 16]
604                .wrapping_add(s0)
605                .wrapping_add(w[i - 7])
606                .wrapping_add(s1);
607        }
608        let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut h] = self.state;
609        for i in 0..64 {
610            let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
611            let ch = (e & f) ^ ((!e) & g);
612            let t1 = h
613                .wrapping_add(s1)
614                .wrapping_add(ch)
615                .wrapping_add(K[i])
616                .wrapping_add(w[i]);
617            let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
618            let maj = (a & b) ^ (a & c) ^ (b & c);
619            let t2 = s0.wrapping_add(maj);
620            h = g;
621            g = f;
622            f = e;
623            e = d.wrapping_add(t1);
624            d = c;
625            c = b;
626            b = a;
627            a = t1.wrapping_add(t2);
628        }
629        for (s, v) in self.state.iter_mut().zip([a, b, c, d, e, f, g, h]) {
630            *s = s.wrapping_add(v);
631        }
632    }
633
634    fn hex(mut self) -> String {
635        let bits = self.length.wrapping_mul(8);
636        self.buffer.push(0x80);
637        while self.buffer.len() % 64 != 56 {
638            self.buffer.push(0);
639        }
640        let tail = bits.to_be_bytes();
641        self.buffer.extend_from_slice(&tail);
642        let blocks: Vec<[u8; 64]> = self
643            .buffer
644            .chunks_exact(64)
645            .map(|c| c.try_into().expect("64 bytes"))
646            .collect();
647        self.buffer.clear();
648        for b in blocks {
649            self.compress(&b);
650        }
651        self.state.iter().map(|w| format!("{w:08x}")).collect()
652    }
653}
654
655#[cfg(test)]
656mod tests {
657    use super::*;
658    use serde_json::json;
659
660    /// The published vectors. A fingerprint nothing else can reproduce is not evidence, so
661    /// the hash behind it is checked against the standard rather than against itself.
662    #[test]
663    fn the_hash_is_sha256() {
664        let mut h = Sha256::new();
665        h.update(b"abc");
666        assert_eq!(
667            h.hex(),
668            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
669        );
670        let mut h = Sha256::new();
671        h.update(b"");
672        assert_eq!(
673            h.hex(),
674            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
675        );
676        // Longer than one block, and split across updates, because the surface is fed in
677        // capability by capability rather than all at once.
678        let mut h = Sha256::new();
679        h.update(b"abcdbcdecdefdefgefghfghighijhi");
680        h.update(b"jkijkljklmklmnlmnomnopnopq");
681        assert_eq!(
682            h.hex(),
683            "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"
684        );
685    }
686
687    #[test]
688    fn prose_is_not_contract() {
689        let a = json!({"type": "object", "description": "one wording", "properties": {"x": {"type": "string", "title": "X"}}});
690        let b = json!({"type": "object", "description": "another wording entirely", "properties": {"x": {"type": "string"}}});
691        assert_eq!(
692            normalise(&a),
693            normalise(&b),
694            "a reworded description is not a contract change"
695        );
696    }
697
698    #[test]
699    fn normalising_is_idempotent_and_orders_keys() {
700        let messy = json!({"properties": {"b": {"type": "string"}, "a": {"type": "number"}}, "type": "object"});
701        let once = normalise(&messy);
702        assert_eq!(once, normalise(&once), "normalise is a fixed point");
703        let keys: Vec<&String> = once.as_object().expect("object").keys().collect();
704        assert_eq!(keys, vec!["properties", "type"], "keys come out sorted");
705    }
706
707    #[test]
708    fn array_order_survives_normalisation() {
709        // `prefixItems` is positional: sorting it would change the contract while claiming
710        // to normalise it.
711        let v = json!({"prefixItems": [{"type": "string"}, {"type": "number"}]});
712        let n = normalise(&v);
713        assert_eq!(n["prefixItems"][0]["type"], "string");
714        assert_eq!(n["prefixItems"][1]["type"], "number");
715    }
716
717    fn manifest(caps: Value) -> Value {
718        json!({"schema": "majordomus/capability-registry/v1", "capabilities": caps})
719    }
720
721    #[test]
722    fn only_public_capabilities_are_surface() {
723        let m = manifest(json!([
724            {"id": "a.one", "kind": "query", "visibility": "public"},
725            {"id": "a.two", "kind": "query", "visibility": "internal"},
726            {"id": "a.three", "kind": "query"},
727        ]));
728        let caps = public_capabilities(&m);
729        assert!(caps.contains_key("a.one"));
730        assert!(
731            !caps.contains_key("a.two"),
732            "an internal capability is not a promise to anyone"
733        );
734        assert!(
735            caps.contains_key("a.three"),
736            "the registry's own default is public, and this must not be a second policy"
737        );
738    }
739
740    #[test]
741    fn every_binding_a_caller_can_hold_is_read() {
742        let m = manifest(json!([{
743            "id": "a.one", "kind": "query", "visibility": "public",
744            "exposure": {
745                "mcp": {"tool": "t", "resource": {"uri": "majordomus://r"}},
746                "http": {"method": "GET", "path": "/api/v1/x"},
747                "cli": {"path": ["a", "one"]}
748            },
749            "input": {"schema": {"type": "object"}},
750            "output": {"schema": {"type": "object"}}
751        }]));
752        let c = &public_capabilities(&m)["a.one"];
753        assert_eq!(c.mcp_tool.as_deref(), Some("t"));
754        assert_eq!(c.mcp_resource.as_deref(), Some("majordomus://r"));
755        assert_eq!(
756            c.http.as_ref().map(|h| h.to_string()).as_deref(),
757            Some("GET /api/v1/x")
758        );
759        assert_eq!(c.cli.as_deref(), Some("a one"));
760        assert!(c.input.is_some() && c.output.is_some());
761    }
762
763    fn surface_of(caps: Value) -> Surface {
764        Surface {
765            schema: SURFACE_SCHEMA.to_string(),
766            origin: Origin::WorkingTree,
767            capabilities: public_capabilities(&manifest(caps)),
768        }
769    }
770
771    #[test]
772    fn the_fingerprint_is_deterministic_and_ordering_independent() {
773        let one = surface_of(json!([
774            {"id": "b.two", "kind": "query", "visibility": "public"},
775            {"id": "a.one", "kind": "query", "visibility": "public"},
776        ]));
777        let other = surface_of(json!([
778            {"id": "a.one", "kind": "query", "visibility": "public"},
779            {"id": "b.two", "kind": "query", "visibility": "public"},
780        ]));
781        assert_eq!(
782            one.fingerprint(),
783            other.fingerprint(),
784            "the same surface enumerated in a different order is the same surface"
785        );
786        assert_eq!(one.fingerprint(), one.fingerprint(), "and it is stable");
787        assert!(one.fingerprint().starts_with("sha256:"));
788    }
789
790    #[test]
791    fn a_changed_surface_changes_the_fingerprint() {
792        let before = surface_of(json!([{"id": "a.one", "kind": "query", "visibility": "public"}]));
793        let after = surface_of(json!([
794            {"id": "a.one", "kind": "query", "visibility": "public"},
795            {"id": "a.two", "kind": "query", "visibility": "public"},
796        ]));
797        assert_ne!(before.fingerprint(), after.fingerprint());
798    }
799
800    #[test]
801    fn prose_does_not_move_the_fingerprint() {
802        let before = surface_of(json!([{
803            "id": "a.one", "kind": "query", "visibility": "public",
804            "output": {"schema": {"type": "object", "description": "before"}}
805        }]));
806        let after = surface_of(json!([{
807            "id": "a.one", "kind": "query", "visibility": "public",
808            "output": {"schema": {"type": "object", "description": "after, at length"}}
809        }]));
810        assert_eq!(before.fingerprint(), after.fingerprint());
811    }
812
813    #[test]
814    fn atoms_count_the_things_a_caller_can_hold() {
815        let s = surface_of(json!([{
816            "id": "a.one", "kind": "query", "visibility": "public",
817            "exposure": {"mcp": {"tool": "t"}, "http": {"method": "GET", "path": "/x"}}
818        }]));
819        assert_eq!(s.atoms(), 3, "the identity, the tool, the route");
820    }
821
822    #[test]
823    fn a_ref_without_a_registry_says_so_rather_than_comparing_with_nothing() {
824        let dir = tempfile::tempdir().expect("a temporary directory");
825        let run = |args: &[&str]| {
826            Command::new("git")
827                .current_dir(dir.path())
828                .args(args)
829                .output()
830                .expect("git runs");
831        };
832        run(&["init", "-q"]);
833        run(&["config", "user.email", "t@example.com"]);
834        run(&["config", "user.name", "T"]);
835        std::fs::write(dir.path().join("a.txt"), "x").expect("write");
836        run(&["add", "-A"]);
837        run(&["commit", "-qm", "one"]);
838
839        match Surface::read_ref(dir.path(), "HEAD") {
840            Err(SurfaceError::NoRegistry { reference }) => assert_eq!(reference, "HEAD"),
841            other => panic!("a ref with no committed registry must refuse: {other:?}"),
842        }
843        match Surface::read_ref(dir.path(), "v9.9.9") {
844            Err(SurfaceError::NoSuchRef { .. }) => {}
845            other => panic!("a ref that does not exist must say so: {other:?}"),
846        }
847    }
848
849    #[test]
850    fn a_ref_with_a_registry_is_read_and_names_its_commit() {
851        let dir = tempfile::tempdir().expect("a temporary directory");
852        let run = |args: &[&str]| {
853            Command::new("git")
854                .current_dir(dir.path())
855                .args(args)
856                .output()
857                .expect("git runs");
858        };
859        run(&["init", "-q"]);
860        run(&["config", "user.email", "t@example.com"]);
861        run(&["config", "user.name", "T"]);
862        std::fs::create_dir_all(dir.path().join("docs/generated")).expect("mkdir");
863        std::fs::write(
864            dir.path().join(REGISTRY),
865            serde_json::to_string(&manifest(json!([
866                {"id": "a.one", "kind": "query", "visibility": "public"}
867            ])))
868            .expect("json"),
869        )
870        .expect("write");
871        run(&["add", "-A"]);
872        run(&["commit", "-qm", "one"]);
873
874        let s = Surface::read_ref(dir.path(), "HEAD").expect("the registry is committed");
875        assert!(s.capabilities.contains_key("a.one"));
876        match s.origin {
877            Origin::Ref { reference, commit } => {
878                assert_eq!(reference, "HEAD");
879                assert_eq!(
880                    commit.len(),
881                    40,
882                    "the verdict names a commit, not a moving name"
883                );
884            }
885            other => panic!("a ref surface carries a ref origin: {other:?}"),
886        }
887    }
888
889    #[test]
890    fn a_registry_that_is_not_one_is_refused_rather_than_read_as_empty() {
891        let dir = tempfile::tempdir().expect("a temporary directory");
892        let run = |args: &[&str]| {
893            Command::new("git")
894                .current_dir(dir.path())
895                .args(args)
896                .output()
897                .expect("git runs");
898        };
899        run(&["init", "-q"]);
900        run(&["config", "user.email", "t@example.com"]);
901        run(&["config", "user.name", "T"]);
902        std::fs::create_dir_all(dir.path().join("docs/generated")).expect("mkdir");
903        // An empty surface read as valid would report every capability as removed.
904        std::fs::write(dir.path().join(REGISTRY), "{\"schema\":\"x\"}").expect("write");
905        run(&["add", "-A"]);
906        run(&["commit", "-qm", "one"]);
907        match Surface::read_ref(dir.path(), "HEAD") {
908            Err(SurfaceError::Malformed { reason, .. }) => {
909                assert!(
910                    reason.contains("capabilities"),
911                    "it names what was missing: {reason}"
912                );
913            }
914            other => panic!("a registry with no capabilities array must refuse: {other:?}"),
915        }
916        std::fs::write(dir.path().join(REGISTRY), "not json at all").expect("write");
917        run(&["add", "-A"]);
918        run(&["commit", "-qm", "two"]);
919        assert!(matches!(
920            Surface::read_ref(dir.path(), "HEAD"),
921            Err(SurfaceError::Malformed { .. })
922        ));
923    }
924}