1use 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
12const NATIVE_API_SHA256: &str = "sha256:3a14f2017b295839922ab36dd98b32e02dad1d314ecb9ccbc396a9c89cf84b5b";
17const CONFIGURATION_SCHEMA_SHA256: &str = "sha256:5e80c4bddc11dc363a412143f8fb20fc0ace5a390fbc77ece36609099b23a39b";
18const SUPPLY_CONTRACT_SHA256: &str = "sha256:2141026b1456de36051ebfb558eaea6e1c1c7f1566afae409deddc136c225d80";
19
20#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
22#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
23pub enum BuildSourceBindingV1 {
24 Unbound,
26 ExactDevelopmentSupply {
28 revision: String,
29 tree: String,
30 clean_tree: bool,
31 source_state_sha256: String,
32 },
33}
34
35impl BuildSourceBindingV1 {
36 #[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#[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 #[must_use]
81 pub fn current() -> Self {
82 Self::current_with_source_binding(BuildSourceBindingV1::Unbound)
83 }
84
85 #[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 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 pub fn encode_canonical(&self) -> Result<Vec<u8>, BuildIdentityError> {
134 self.validate()?;
135 let value = serde_json::to_value(self).map_err(|_| BuildIdentityError::Encoding)?;
139 serde_json::to_vec(&value).map_err(|_| BuildIdentityError::Encoding)
140 }
141
142 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}