Skip to main content

spec_driven_docs/domain/
ownership.rs

1//! Ownership classes: how an instance holds each file the canon delivered.
2//!
3//! Three classes exist. Managed files are byte projections of the payload;
4//! adopted files are seeded once and owned locally against a recorded
5//! baseline; integration blocks are marked regions inside files the project
6//! owns. These are the record shapes only — hashing bytes and comparing
7//! them to disk is the verifier's work.
8
9use std::fmt;
10use std::str::FromStr;
11
12use camino::Utf8PathBuf;
13use serde::{Deserialize, Deserializer, Serialize, Serializer};
14
15/// A lowercase-hex SHA-256 digest.
16#[derive(Debug, Clone, PartialEq, Eq, Hash)]
17pub struct Sha256(String);
18
19impl Sha256 {
20    /// Digest a byte string.
21    #[must_use]
22    pub fn of(bytes: &[u8]) -> Self {
23        use sha2::Digest;
24        Self(hex::encode(sha2::Sha256::digest(bytes)))
25    }
26
27    /// The 64-character hex form.
28    #[must_use]
29    pub fn as_str(&self) -> &str {
30        &self.0
31    }
32}
33
34impl fmt::Display for Sha256 {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        f.write_str(&self.0)
37    }
38}
39
40/// Rejection of a string that is not a 64-character lowercase hex digest.
41#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
42#[error("'{0}' is not a sha256 digest")]
43pub struct Sha256Error(String);
44
45impl FromStr for Sha256 {
46    type Err = Sha256Error;
47
48    fn from_str(s: &str) -> Result<Self, Self::Err> {
49        if s.len() == 64
50            && s.bytes()
51                .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
52        {
53            Ok(Self(s.to_string()))
54        } else {
55            Err(Sha256Error(s.to_string()))
56        }
57    }
58}
59
60impl Serialize for Sha256 {
61    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
62        serializer.serialize_str(&self.0)
63    }
64}
65
66impl<'de> Deserialize<'de> for Sha256 {
67    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
68        let s = String::deserialize(deserializer)?;
69        s.parse().map_err(serde::de::Error::custom)
70    }
71}
72
73/// A byte-for-byte projection of a payload file into the instance.
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(deny_unknown_fields)]
76pub struct ManagedEntry {
77    /// The payload path the bytes came from.
78    pub source: Utf8PathBuf,
79    /// Where the instance holds them, relative to its root.
80    pub destination: Utf8PathBuf,
81    /// The installed bytes; any difference on disk is drift.
82    pub sha256: Sha256,
83}
84
85/// A file seeded from the payload and owned by the instance from then on.
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(deny_unknown_fields)]
88pub struct AdoptedEntry {
89    /// The payload path the seed came from.
90    pub source: Utf8PathBuf,
91    /// Where the instance holds its copy, relative to its root.
92    pub destination: Utf8PathBuf,
93    /// The instance's installed bytes.
94    pub sha256: Sha256,
95    /// What upstream shipped; an edit reports drift until reconciled.
96    pub baseline_sha256: Sha256,
97}
98
99/// A marker-delimited region the canon owns inside a project-owned file.
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(deny_unknown_fields)]
102pub struct IntegrationBlock {
103    /// The host file, relative to the instance root.
104    pub path: Utf8PathBuf,
105    /// The bytes between and including the markers.
106    pub marker_hash: Sha256,
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    #[test]
114    fn digests_deterministically() {
115        let digest = Sha256::of(b"payload\n");
116        assert_eq!(digest.as_str().len(), 64);
117        assert_eq!(digest, Sha256::of(b"payload\n"));
118        assert_ne!(digest, Sha256::of(b"payload"));
119    }
120
121    #[test]
122    fn rejects_malformed_digests() {
123        for bad in ["", "abc", &"A".repeat(64), &"g".repeat(64)] {
124            assert!(bad.parse::<Sha256>().is_err(), "accepted {bad:?}");
125        }
126        assert!("0123456789abcdef".repeat(4).parse::<Sha256>().is_ok());
127    }
128}