Skip to main content

lodestone_types/
build_identity.rs

1//! Closed nonauthoritative executable identity for exact development supplies.
2
3use std::error::Error;
4use std::fmt::{self, Display, Formatter};
5
6use serde::{Deserialize, Serialize};
7
8pub const BUILD_IDENTITY_SCHEMA_ID: &str = "lodestone-build-identity/v1";
9pub const BUILD_IDENTITY_FORMAT_VERSION: u16 = 1;
10pub const MAX_BUILD_IDENTITY_BYTES: usize = 16 * 1024;
11
12// INVARIANT: These portable crate-local projections are derived from the
13// repository authorities by `check-repository-authorities.py`. Embedding the
14// authority files with repository-relative paths would make the published
15// `lodestone-types` crate impossible to compile outside this checkout.
16const NATIVE_API_SHA256: &str = "sha256:3a14f2017b295839922ab36dd98b32e02dad1d314ecb9ccbc396a9c89cf84b5b";
17const CONFIGURATION_SCHEMA_SHA256: &str = "sha256:5e80c4bddc11dc363a412143f8fb20fc0ace5a390fbc77ece36609099b23a39b";
18const SUPPLY_CONTRACT_SHA256: &str = "sha256:2141026b1456de36051ebfb558eaea6e1c1c7f1566afae409deddc136c225d80";
19
20/// Source binding carried by a product executable.
21#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
22#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
23pub enum BuildSourceBindingV1 {
24    /// Ordinary source-tree builds make no package-source claim.
25    Unbound,
26    /// Supply builds bind one exact clean revision and tree.
27    ExactDevelopmentSupply {
28        revision: String,
29        tree: String,
30        clean_tree: bool,
31        source_state_sha256: String,
32    },
33}
34
35impl BuildSourceBindingV1 {
36    /// Projects the four all-or-none executable build inputs. A partial or
37    /// malformed combination remains representable only as an invalid exact
38    /// binding so the ordinary identity validation path refuses it.
39    #[must_use]
40    pub fn from_executable_environment(
41        revision: Option<&str>,
42        tree: Option<&str>,
43        clean_tree: Option<&str>,
44        source_state_sha256: Option<&str>,
45    ) -> Self {
46        match (revision, tree, clean_tree, source_state_sha256) {
47            (None, None, None, None) => Self::Unbound,
48            (Some(revision), Some(tree), Some("true"), Some(source_state_sha256)) => Self::ExactDevelopmentSupply {
49                revision: revision.to_owned(),
50                tree: tree.to_owned(),
51                clean_tree: true,
52                source_state_sha256: source_state_sha256.to_owned(),
53            },
54            (revision, tree, clean_tree, source_state_sha256) => Self::ExactDevelopmentSupply {
55                revision: revision.unwrap_or_default().to_owned(),
56                tree: tree.unwrap_or_default().to_owned(),
57                clean_tree: clean_tree == Some("true"),
58                source_state_sha256: source_state_sha256.unwrap_or_default().to_owned(),
59            },
60        }
61    }
62}
63
64/// Closed executable projection compared with the outer supply index.
65#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
66#[serde(deny_unknown_fields)]
67pub struct BuildIdentityV1 {
68    pub schema_id: String,
69    pub format_version: u16,
70    pub artifact_version: String,
71    pub target: String,
72    pub native_api_sha256: String,
73    pub configuration_schema_sha256: String,
74    pub supply_contract_sha256: String,
75    pub source_binding: BuildSourceBindingV1,
76}
77
78impl BuildIdentityV1 {
79    /// Constructs the ordinary unbound identity for this artifact target.
80    #[must_use]
81    pub fn current() -> Self {
82        Self::current_with_source_binding(BuildSourceBindingV1::Unbound)
83    }
84
85    /// Constructs this artifact target's identity with a leaf-executable
86    /// source binding. Keeping revision-specific values at executable leaves
87    /// prevents them from invalidating every foundational workspace crate.
88    #[must_use]
89    pub fn current_with_source_binding(source_binding: BuildSourceBindingV1) -> Self {
90        Self {
91            schema_id: BUILD_IDENTITY_SCHEMA_ID.to_owned(),
92            format_version: BUILD_IDENTITY_FORMAT_VERSION,
93            artifact_version: env!("CARGO_PKG_VERSION").to_owned(),
94            target: env!("LODESTONE_COMPILE_TARGET").to_owned(),
95            native_api_sha256: NATIVE_API_SHA256.to_owned(),
96            configuration_schema_sha256: CONFIGURATION_SCHEMA_SHA256.to_owned(),
97            supply_contract_sha256: SUPPLY_CONTRACT_SHA256.to_owned(),
98            source_binding,
99        }
100    }
101
102    /// Validates a decoded projection without trusting its producer.
103    pub fn validate(&self) -> Result<(), BuildIdentityError> {
104        if self.schema_id != BUILD_IDENTITY_SCHEMA_ID || self.format_version != BUILD_IDENTITY_FORMAT_VERSION {
105            return Err(BuildIdentityError::UnsupportedFormat);
106        }
107        if !valid_artifact_version(&self.artifact_version) || !valid_target(&self.target) {
108            return Err(BuildIdentityError::InvalidIdentity);
109        }
110        if !valid_sha256(&self.native_api_sha256)
111            || !valid_sha256(&self.configuration_schema_sha256)
112            || !valid_sha256(&self.supply_contract_sha256)
113        {
114            return Err(BuildIdentityError::InvalidDigest);
115        }
116        if let BuildSourceBindingV1::ExactDevelopmentSupply {
117            revision,
118            tree,
119            clean_tree,
120            source_state_sha256,
121        } = &self.source_binding
122            && (!valid_git_object(revision)
123                || !valid_git_object(tree)
124                || !clean_tree
125                || !valid_sha256(source_state_sha256))
126        {
127            return Err(BuildIdentityError::InvalidSourceBinding);
128        }
129        Ok(())
130    }
131
132    /// Encodes the exact compact JSON command projection.
133    pub fn encode_canonical(&self) -> Result<Vec<u8>, BuildIdentityError> {
134        self.validate()?;
135        // INVARIANT: Materialize through `Value` so every object, including the
136        // internally tagged source binding, uses lexicographically ordered keys.
137        // The independent supply verifier applies the same canonical JSON rule.
138        let value = serde_json::to_value(self).map_err(|_| BuildIdentityError::Encoding)?;
139        serde_json::to_vec(&value).map_err(|_| BuildIdentityError::Encoding)
140    }
141
142    /// Decodes one bounded canonical build-identity document.
143    pub fn decode_canonical(bytes: &[u8]) -> Result<Self, BuildIdentityError> {
144        if bytes.is_empty() || bytes.len() > MAX_BUILD_IDENTITY_BYTES {
145            return Err(BuildIdentityError::InvalidSize);
146        }
147        let value: Self = serde_json::from_slice(bytes).map_err(|_| BuildIdentityError::Malformed)?;
148        value.validate()?;
149        if value.encode_canonical()?.as_slice() != bytes {
150            return Err(BuildIdentityError::NonCanonical);
151        }
152        Ok(value)
153    }
154}
155
156#[derive(Clone, Copy, Debug, Eq, PartialEq)]
157pub enum BuildIdentityError {
158    InvalidSize,
159    Malformed,
160    NonCanonical,
161    UnsupportedFormat,
162    InvalidIdentity,
163    InvalidDigest,
164    InvalidSourceBinding,
165    Encoding,
166}
167
168impl Display for BuildIdentityError {
169    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
170        formatter.write_str(match self {
171            Self::InvalidSize => "build identity is empty or oversized",
172            Self::Malformed => "build identity is malformed",
173            Self::NonCanonical => "build identity is not canonical",
174            Self::UnsupportedFormat => "build identity format is unsupported",
175            Self::InvalidIdentity => "build identity contains an invalid artifact version or target",
176            Self::InvalidDigest => "build identity contains an invalid digest",
177            Self::InvalidSourceBinding => "build identity contains an invalid source binding",
178            Self::Encoding => "build identity could not be encoded",
179        })
180    }
181}
182
183impl Error for BuildIdentityError {}
184
185fn valid_sha256(value: &str) -> bool {
186    value.len() == 71 && value.starts_with("sha256:") && lower_hex(value.trim_start_matches("sha256:"), 64, 64)
187}
188
189fn valid_git_object(value: &str) -> bool {
190    lower_hex(value, 40, 64)
191}
192
193fn lower_hex(value: &str, minimum: usize, maximum: usize) -> bool {
194    (minimum..=maximum).contains(&value.len())
195        && value
196            .as_bytes()
197            .iter()
198            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte))
199}
200
201fn valid_artifact_version(value: &str) -> bool {
202    !value.is_empty()
203        && value.len() <= 128
204        && value
205            .as_bytes()
206            .iter()
207            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'+' | b'-'))
208}
209
210fn valid_target(value: &str) -> bool {
211    !value.is_empty()
212        && value.len() <= 128
213        && value
214            .as_bytes()
215            .first()
216            .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'_')
217        && value
218            .as_bytes()
219            .iter()
220            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'.' | b'-'))
221}
222
223#[cfg(test)]
224mod tests {
225    use super::{BuildIdentityError, BuildIdentityV1, BuildSourceBindingV1};
226
227    #[test]
228    fn current_identity_is_valid_and_canonical() -> Result<(), Box<dyn std::error::Error>> {
229        let identity = BuildIdentityV1::current();
230        identity.validate()?;
231        let encoded = identity.encode_canonical()?;
232        assert_eq!(BuildIdentityV1::decode_canonical(&encoded)?, identity);
233        assert!(matches!(identity.source_binding, BuildSourceBindingV1::Unbound));
234        Ok(())
235    }
236
237    #[test]
238    fn malformed_partial_and_noncanonical_documents_refuse() -> Result<(), Box<dyn std::error::Error>> {
239        let identity = BuildIdentityV1::current();
240        let mut value = serde_json::to_value(&identity)?;
241        value["source_binding"] = serde_json::json!({
242            "kind": "exact_development_supply",
243            "revision": "1".repeat(40)
244        });
245        let partial = serde_json::to_vec(&value)?;
246        assert_eq!(
247            BuildIdentityV1::decode_canonical(&partial),
248            Err(BuildIdentityError::Malformed)
249        );
250
251        let mut pretty = serde_json::to_vec_pretty(&identity)?;
252        pretty.push(b'\n');
253        assert_eq!(
254            BuildIdentityV1::decode_canonical(&pretty),
255            Err(BuildIdentityError::NonCanonical)
256        );
257        Ok(())
258    }
259
260    #[test]
261    fn executable_source_binding_is_all_or_none_and_fail_closed() {
262        assert_eq!(
263            BuildSourceBindingV1::from_executable_environment(None, None, None, None),
264            BuildSourceBindingV1::Unbound
265        );
266        let partial = BuildIdentityV1::current_with_source_binding(BuildSourceBindingV1::from_executable_environment(
267            Some(&"1".repeat(40)),
268            None,
269            Some("true"),
270            None,
271        ));
272        assert_eq!(partial.validate(), Err(BuildIdentityError::InvalidSourceBinding));
273    }
274
275    #[test]
276    fn package_and_self_digests_are_absent() -> Result<(), Box<dyn std::error::Error>> {
277        let encoded = BuildIdentityV1::current().encode_canonical()?;
278        let text = std::str::from_utf8(&encoded)?;
279        assert!(!text.contains("package_digest"));
280        assert!(!text.contains("executable_sha256"));
281        assert!(!text.contains("oci_manifest"));
282        Ok(())
283    }
284
285    #[test]
286    fn canonical_encoding_orders_every_object_lexicographically() -> Result<(), Box<dyn std::error::Error>> {
287        let identity = BuildIdentityV1 {
288            schema_id: "lodestone-build-identity/v1".to_owned(),
289            format_version: 1,
290            artifact_version: env!("CARGO_PKG_VERSION").to_owned(),
291            target: "x86_64-unknown-linux-musl".to_owned(),
292            native_api_sha256: format!("sha256:{}", "1".repeat(64)),
293            configuration_schema_sha256: format!("sha256:{}", "2".repeat(64)),
294            supply_contract_sha256: format!("sha256:{}", "3".repeat(64)),
295            source_binding: BuildSourceBindingV1::ExactDevelopmentSupply {
296                revision: "4".repeat(40),
297                tree: "5".repeat(40),
298                clean_tree: true,
299                source_state_sha256: format!("sha256:{}", "6".repeat(64)),
300            },
301        };
302
303        let encoded = identity.encode_canonical()?;
304        let expected = format!(
305            concat!(
306                r#"{{"artifact_version":"{artifact_version}","configuration_schema_sha256":"sha256:{configuration}","format_version":1,"native_api_sha256":"sha256:{api}","schema_id":"lodestone-build-identity/v1","source_binding":{{"clean_tree":true,"kind":"exact_development_supply","revision":"{revision}","source_state_sha256":"sha256:{source_state}","tree":"{tree}"}},"supply_contract_sha256":"sha256:{contract}","target":"x86_64-unknown-linux-musl"}}"#
307            ),
308            artifact_version = env!("CARGO_PKG_VERSION"),
309            configuration = "2".repeat(64),
310            api = "1".repeat(64),
311            revision = "4".repeat(40),
312            source_state = "6".repeat(64),
313            tree = "5".repeat(40),
314            contract = "3".repeat(64),
315        );
316        assert_eq!(encoded, expected.as_bytes());
317        assert_eq!(BuildIdentityV1::decode_canonical(&encoded)?, identity);
318        Ok(())
319    }
320}