Skip to main content

deputy_core/
ids.rs

1use crate::error::{Error, Result};
2use serde::{Serialize, Serializer};
3use std::fmt;
4
5/// Hash algorithm used for content addressing. SHA-256 today; the enum exists so the
6/// on-disk address format (`<algo>:<hex>`) can evolve without ambiguity.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
8#[non_exhaustive]
9pub enum HashAlgo {
10    Sha256,
11}
12
13impl HashAlgo {
14    pub const fn as_str(self) -> &'static str {
15        match self {
16            HashAlgo::Sha256 => "sha256",
17        }
18    }
19}
20
21impl fmt::Display for HashAlgo {
22    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
23        f.write_str(self.as_str())
24    }
25}
26
27/// The content address of an artifact: the hash of its canonical bytes. An artifact's
28/// identity *is* this value — see `docs/STORAGE.md` §1. Renders as `sha256:<lower-hex>`.
29#[derive(Debug, Clone, PartialEq, Eq, Hash)]
30pub struct ContentHash {
31    algo: HashAlgo,
32    bytes: Vec<u8>,
33}
34
35impl ContentHash {
36    pub fn new(algo: HashAlgo, bytes: Vec<u8>) -> Self {
37        Self { algo, bytes }
38    }
39
40    /// Construct a SHA-256 content hash from a 32-byte digest.
41    pub fn sha256(digest: [u8; 32]) -> Self {
42        Self {
43            algo: HashAlgo::Sha256,
44            bytes: digest.to_vec(),
45        }
46    }
47
48    /// Parse a 64-character lowercase-hex SHA-256 (e.g. a `Cargo.lock` checksum) into a
49    /// content hash. Errors on wrong length or non-hex input.
50    pub fn from_sha256_hex(hex: &str) -> Result<Self> {
51        if hex.len() != 64 {
52            return Err(Error::Malformed {
53                what: format!("sha256 hex must be 64 chars, got {}", hex.len()),
54            });
55        }
56        let mut digest = [0u8; 32];
57        for (i, pair) in hex.as_bytes().chunks_exact(2).enumerate() {
58            let s = std::str::from_utf8(pair).map_err(|_| Error::Malformed {
59                what: "sha256 hex is not valid UTF-8".to_owned(),
60            })?;
61            digest[i] = u8::from_str_radix(s, 16).map_err(|_| Error::Malformed {
62                what: format!("invalid hex byte `{s}`"),
63            })?;
64        }
65        Ok(Self::sha256(digest))
66    }
67
68    pub fn algo(&self) -> HashAlgo {
69        self.algo
70    }
71
72    pub fn bytes(&self) -> &[u8] {
73        &self.bytes
74    }
75
76    /// Lower-hex encoding of the digest (no algorithm prefix).
77    pub fn to_hex(&self) -> String {
78        use fmt::Write as _;
79        let mut s = String::with_capacity(self.bytes.len() * 2);
80        for b in &self.bytes {
81            let _ = write!(s, "{b:02x}");
82        }
83        s
84    }
85}
86
87impl fmt::Display for ContentHash {
88    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89        write!(f, "{}:{}", self.algo, self.to_hex())
90    }
91}
92
93impl Serialize for ContentHash {
94    /// Serializes as the canonical `<algo>:<hex>` string for clean API JSON.
95    fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
96        serializer.serialize_str(&self.to_string())
97    }
98}
99
100/// A supported dependency ecosystem. Cargo is the first beachhead
101/// (`docs/ARCHITECTURE.md` §7); npm/PyPI/Go follow via the `DepEcosystem` trait.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
103#[non_exhaustive]
104pub enum EcosystemId {
105    Cargo,
106}
107
108impl EcosystemId {
109    pub const fn as_str(self) -> &'static str {
110        match self {
111            EcosystemId::Cargo => "cargo",
112        }
113    }
114}
115
116impl fmt::Display for EcosystemId {
117    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118        f.write_str(self.as_str())
119    }
120}
121
122macro_rules! string_newtype {
123    ($(#[$meta:meta])* $name:ident) => {
124        $(#[$meta])*
125        #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
126        pub struct $name(String);
127
128        impl $name {
129            pub fn new(value: impl Into<String>) -> Self {
130                Self(value.into())
131            }
132            pub fn as_str(&self) -> &str {
133                &self.0
134            }
135        }
136
137        impl fmt::Display for $name {
138            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139                f.write_str(&self.0)
140            }
141        }
142
143        impl From<String> for $name {
144            fn from(value: String) -> Self {
145                Self(value)
146            }
147        }
148
149        impl From<&str> for $name {
150            fn from(value: &str) -> Self {
151                Self(value.to_owned())
152            }
153        }
154    };
155}
156
157string_newtype!(
158    /// A connected source-provider account (e.g. a linked GitHub account).
159    SourceId
160);
161string_newtype!(
162    /// A repository within a connected source.
163    RepoId
164);
165string_newtype!(
166    /// A dependency's package name within its ecosystem.
167    DepName
168);
169string_newtype!(
170    /// A resolved, exact dependency version (not a range).
171    Version
172);
173
174/// A reference to a specific dependency at a specific version within an ecosystem.
175#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
176pub struct DepRef {
177    pub ecosystem: EcosystemId,
178    pub name: DepName,
179    pub version: Version,
180}
181
182/// A `DepRef` bound to the exact content hash we expect to download — the pin that makes
183/// acquisition tamper-evident (`docs/PIPELINE.md` §2).
184#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
185pub struct Pin {
186    pub dep: DepRef,
187    pub expected: ContentHash,
188}
189
190/// A handle to a stored artifact: its ecosystem plus its content address.
191#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
192pub struct ArtifactRef {
193    pub ecosystem: EcosystemId,
194    pub hash: ContentHash,
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn content_hash_renders_with_algo_prefix() {
203        let h = ContentHash::sha256([0xab; 32]);
204        assert_eq!(h.algo(), HashAlgo::Sha256);
205        assert_eq!(h.to_string(), format!("sha256:{}", "ab".repeat(32)));
206    }
207
208    #[test]
209    fn string_newtypes_roundtrip() {
210        let name = DepName::from("serde");
211        assert_eq!(name.as_str(), "serde");
212        assert_eq!(name, DepName::new("serde"));
213    }
214
215    #[test]
216    fn from_sha256_hex_roundtrips_and_validates() {
217        let hex = "ab".repeat(32);
218        let h = ContentHash::from_sha256_hex(&hex).unwrap();
219        assert_eq!(h.to_hex(), hex);
220        assert_eq!(h, ContentHash::sha256([0xab; 32]));
221
222        assert!(ContentHash::from_sha256_hex("tooshort").is_err());
223        assert!(ContentHash::from_sha256_hex(&"zz".repeat(32)).is_err());
224    }
225}