service_source_lock.rs (56988B)
1 use std::{collections::BTreeSet, fmt, fmt::Write as _, fs, io::Read as _, path::Path}; 2 3 use serde::{ 4 Deserialize, Deserializer, 5 de::{self, MapAccess, SeqAccess, Visitor}, 6 }; 7 use sha2::{Digest, Sha256}; 8 9 const CONTRACT_RELATIVE: &str = 10 "contracts/architecture/decisions/services_hardening_source_lock.v2.json"; 11 const LOCK_SCHEMA: &str = "radroots.service.source-lock.v2"; 12 pub(crate) const LOCK_FILENAME: &str = "radroots.service.source-lock.v2.toml"; 13 pub(crate) const PREDECESSOR_LOCK_FILENAME: &str = "radroots.service.source-lock.v1.toml"; 14 pub(crate) const LIB_REPOSITORY: &str = "https://github.com/radrootslabs/lib"; 15 const ARCHITECTURE: &str = "radroots.crates.release.v2"; 16 const LIB_VERSION: &str = "0.1.0-alpha"; 17 const RUST_VERSION: &str = "1.97.1"; 18 const HOST_FEATURE_PROFILE: &str = "service-host"; 19 const MAX_LOCK_BYTES: usize = 4096; 20 const MAX_SERVICE_BYTES: usize = 128; 21 const MAX_CONTRACT_BYTES: usize = 32_768; 22 const MAX_FLAKE_NIX_BYTES: usize = 1_048_576; 23 const MAX_FLAKE_LOCK_BYTES: usize = 4_194_304; 24 25 const DEFERRED_FIELD_ORDER: [&str; 20] = [ 26 "schema", 27 "contract_version", 28 "service", 29 "repository", 30 "revision", 31 "architecture", 32 "workspace_catalog_sha256", 33 "version", 34 "source_archive_sha256", 35 "cargo_lock_sha256", 36 "rust_version", 37 "host_feature_profile", 38 "nix.material", 39 "nix.lib_revision", 40 "nix.flake_lock_sha256", 41 "contract_versions.config", 42 "contract_versions.state", 43 "contract_versions.admin", 44 "contract_versions.status", 45 "contract_versions.provider", 46 ]; 47 48 const ABSENT_FIELD_ORDER: [&str; 18] = [ 49 "schema", 50 "contract_version", 51 "service", 52 "repository", 53 "revision", 54 "architecture", 55 "workspace_catalog_sha256", 56 "version", 57 "source_archive_sha256", 58 "cargo_lock_sha256", 59 "rust_version", 60 "host_feature_profile", 61 "nix.material", 62 "contract_versions.config", 63 "contract_versions.state", 64 "contract_versions.admin", 65 "contract_versions.status", 66 "contract_versions.provider", 67 ]; 68 69 const ERROR_CODES: [&str; 11] = [ 70 "invalid_contract_version", 71 "invalid_digest", 72 "invalid_feature_profile", 73 "invalid_fixed_identity", 74 "invalid_nix_material", 75 "invalid_revision", 76 "invalid_service", 77 "invalid_toolchain", 78 "malformed", 79 "noncanonical", 80 "too_large", 81 ]; 82 83 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 84 pub(crate) enum ServiceSourceLockError { 85 TooLarge, 86 Malformed, 87 Noncanonical, 88 InvalidFixedIdentity, 89 InvalidNixMaterial, 90 InvalidService, 91 InvalidRevision, 92 InvalidDigest, 93 InvalidToolchain, 94 InvalidFeatureProfile, 95 InvalidContractVersion, 96 } 97 98 impl ServiceSourceLockError { 99 fn code(self) -> &'static str { 100 match self { 101 Self::TooLarge => "too_large", 102 Self::Malformed => "malformed", 103 Self::Noncanonical => "noncanonical", 104 Self::InvalidFixedIdentity => "invalid_fixed_identity", 105 Self::InvalidNixMaterial => "invalid_nix_material", 106 Self::InvalidService => "invalid_service", 107 Self::InvalidRevision => "invalid_revision", 108 Self::InvalidDigest => "invalid_digest", 109 Self::InvalidToolchain => "invalid_toolchain", 110 Self::InvalidFeatureProfile => "invalid_feature_profile", 111 Self::InvalidContractVersion => "invalid_contract_version", 112 } 113 } 114 } 115 116 impl fmt::Display for ServiceSourceLockError { 117 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 118 formatter.write_str(match self { 119 Self::TooLarge => "service source lock exceeds its byte limit", 120 Self::Malformed => "service source lock is malformed", 121 Self::Noncanonical => "service source lock is not canonical", 122 Self::InvalidFixedIdentity => "service source lock identity is invalid", 123 Self::InvalidNixMaterial => "service source lock Nix material is invalid", 124 Self::InvalidService => "service source lock service is invalid", 125 Self::InvalidRevision => "service source lock revision is invalid", 126 Self::InvalidDigest => "service source lock digest is invalid", 127 Self::InvalidToolchain => "service source lock toolchain is invalid", 128 Self::InvalidFeatureProfile => "service source lock feature profile is invalid", 129 Self::InvalidContractVersion => "service source lock contract version is invalid", 130 }) 131 } 132 } 133 134 impl std::error::Error for ServiceSourceLockError {} 135 136 #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)] 137 #[serde(deny_unknown_fields)] 138 pub(crate) struct ContractVersions { 139 config: u32, 140 state: u32, 141 admin: u32, 142 status: u32, 143 provider: u32, 144 } 145 146 impl ContractVersions { 147 pub(crate) const fn new( 148 config: u32, 149 state: u32, 150 admin: u32, 151 status: u32, 152 provider: u32, 153 ) -> Self { 154 Self { 155 config, 156 state, 157 admin, 158 status, 159 provider, 160 } 161 } 162 163 fn is_valid(self) -> bool { 164 self.config != 0 165 && self.state != 0 166 && self.admin != 0 167 && self.status != 0 168 && self.provider != 0 169 } 170 171 pub(crate) const fn config(self) -> u32 { 172 self.config 173 } 174 175 pub(crate) const fn state(self) -> u32 { 176 self.state 177 } 178 179 pub(crate) const fn admin(self) -> u32 { 180 self.admin 181 } 182 183 pub(crate) const fn status(self) -> u32 { 184 self.status 185 } 186 187 pub(crate) const fn provider(self) -> u32 { 188 self.provider 189 } 190 } 191 192 #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] 193 #[serde(deny_unknown_fields)] 194 struct RawServiceSourceLock { 195 schema: String, 196 contract_version: u32, 197 service: String, 198 repository: String, 199 revision: String, 200 architecture: String, 201 workspace_catalog_sha256: String, 202 version: String, 203 source_archive_sha256: String, 204 cargo_lock_sha256: String, 205 nix: RawNixMaterial, 206 rust_version: String, 207 host_feature_profile: String, 208 contract_versions: ContractVersions, 209 } 210 211 #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] 212 #[serde(tag = "material", rename_all = "snake_case", deny_unknown_fields)] 213 enum RawNixMaterial { 214 Absent, 215 Deferred { 216 lib_revision: String, 217 flake_lock_sha256: String, 218 }, 219 } 220 221 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 222 pub(crate) enum NixMaterialParts<'a> { 223 Absent, 224 Deferred { 225 lib_revision: &'a str, 226 flake_lock_sha256: &'a str, 227 }, 228 } 229 230 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 231 pub(crate) enum NixMaterialState { 232 Absent, 233 Deferred, 234 } 235 236 #[derive(Clone, Debug, Eq, PartialEq)] 237 pub(crate) struct DeferredNixMaterialEvidence { 238 lib_revision: String, 239 flake_lock_sha256: String, 240 } 241 242 impl DeferredNixMaterialEvidence { 243 pub(crate) fn lib_revision(&self) -> &str { 244 &self.lib_revision 245 } 246 247 pub(crate) fn flake_lock_sha256(&self) -> &str { 248 &self.flake_lock_sha256 249 } 250 } 251 252 pub(crate) fn validate_deferred_nix_material( 253 expression: &[u8], 254 lock: &[u8], 255 ) -> Result<DeferredNixMaterialEvidence, ServiceSourceLockError> { 256 if expression.len() > MAX_FLAKE_NIX_BYTES || lock.len() > MAX_FLAKE_LOCK_BYTES { 257 return Err(ServiceSourceLockError::InvalidNixMaterial); 258 } 259 let revision = validate_deferred_nix_lock(lock)?; 260 let text = 261 std::str::from_utf8(expression).map_err(|_| ServiceSourceLockError::InvalidNixMaterial)?; 262 let expected = format!("url = \"github:radrootslabs/lib/{revision}\";"); 263 let lines = text 264 .lines() 265 .map(str::trim) 266 .filter(|line| !line.is_empty()) 267 .collect::<Vec<_>>(); 268 let direct_blocks = lines 269 .windows(4) 270 .filter(|window| { 271 window[0] == "inputs.lib = {" 272 && window[1] == expected 273 && window[2] == "flake = false;" 274 && window[3] == "};" 275 }) 276 .count(); 277 let nested_blocks = lines 278 .windows(5) 279 .filter(|window| { 280 window[0] == "inputs = {" 281 && window[1] == "lib = {" 282 && window[2] == expected 283 && window[3] == "flake = false;" 284 && window[4] == "};" 285 }) 286 .count(); 287 let selecting_lines = lines 288 .iter() 289 .filter(|line| line.contains("github:radrootslabs/lib")) 290 .count(); 291 if direct_blocks + nested_blocks != 1 || selecting_lines != 1 { 292 return Err(ServiceSourceLockError::InvalidNixMaterial); 293 } 294 Ok(DeferredNixMaterialEvidence { 295 lib_revision: revision, 296 flake_lock_sha256: hex::encode(Sha256::digest(lock)), 297 }) 298 } 299 300 pub(crate) fn validate_deferred_nix_lock(bytes: &[u8]) -> Result<String, ServiceSourceLockError> { 301 let mut deserializer = serde_json::Deserializer::from_slice(bytes); 302 let value = NoDuplicateJsonValue::deserialize(&mut deserializer) 303 .map_err(|_| ServiceSourceLockError::InvalidNixMaterial)? 304 .0; 305 deserializer 306 .end() 307 .map_err(|_| ServiceSourceLockError::InvalidNixMaterial)?; 308 if value 309 .as_object() 310 .map(|object| object.keys().map(String::as_str).collect::<BTreeSet<_>>()) 311 != Some(BTreeSet::from(["nodes", "root", "version"])) 312 { 313 return Err(ServiceSourceLockError::InvalidNixMaterial); 314 } 315 let version = value.get("version").and_then(serde_json::Value::as_u64); 316 let root_name = value.get("root").and_then(serde_json::Value::as_str); 317 let nodes = value.get("nodes").and_then(serde_json::Value::as_object); 318 if version != Some(7) || root_name.is_none_or(str::is_empty) || nodes.is_none() { 319 return Err(ServiceSourceLockError::InvalidNixMaterial); 320 } 321 let nodes = nodes.ok_or(ServiceSourceLockError::InvalidNixMaterial)?; 322 let root = nodes 323 .get(root_name.ok_or(ServiceSourceLockError::InvalidNixMaterial)?) 324 .and_then(|node| node.get("inputs")) 325 .and_then(serde_json::Value::as_object) 326 .ok_or(ServiceSourceLockError::InvalidNixMaterial)?; 327 let direct_lib_node = root.get("lib").and_then(serde_json::Value::as_str); 328 let mut lib_nodes = 0_usize; 329 let mut exact_direct = 0_usize; 330 let mut selected_revision = None; 331 for (name, node) in nodes { 332 let locked = node.get("locked").and_then(serde_json::Value::as_object); 333 let original = node.get("original").and_then(serde_json::Value::as_object); 334 let is_lib = locked.is_some_and(|locked| { 335 locked.get("owner").and_then(serde_json::Value::as_str) == Some("radrootslabs") 336 && locked.get("repo").and_then(serde_json::Value::as_str) == Some("lib") 337 }) || original.is_some_and(|original| { 338 original.get("owner").and_then(serde_json::Value::as_str) == Some("radrootslabs") 339 && original.get("repo").and_then(serde_json::Value::as_str) == Some("lib") 340 }); 341 if !is_lib { 342 continue; 343 } 344 lib_nodes += 1; 345 let locked = locked.ok_or(ServiceSourceLockError::InvalidNixMaterial)?; 346 let original = original.ok_or(ServiceSourceLockError::InvalidNixMaterial)?; 347 let locked_keys = locked.keys().map(String::as_str).collect::<BTreeSet<_>>(); 348 let original_keys = original.keys().map(String::as_str).collect::<BTreeSet<_>>(); 349 let locked_revision = locked.get("rev").and_then(serde_json::Value::as_str); 350 let original_revision = original.get("rev").and_then(serde_json::Value::as_str); 351 let exact = locked_keys 352 == BTreeSet::from(["lastModified", "narHash", "owner", "repo", "rev", "type"]) 353 && original_keys == BTreeSet::from(["owner", "repo", "rev", "type"]) 354 && node.as_object().is_some_and(|node| { 355 node.keys().map(String::as_str).collect::<BTreeSet<_>>() 356 == BTreeSet::from(["locked", "original"]) 357 }) 358 && locked 359 .get("lastModified") 360 .and_then(serde_json::Value::as_u64) 361 .is_some() 362 && locked.get("type").and_then(serde_json::Value::as_str) == Some("github") 363 && locked.get("owner").and_then(serde_json::Value::as_str) == Some("radrootslabs") 364 && locked.get("repo").and_then(serde_json::Value::as_str) == Some("lib") 365 && locked_revision.is_some_and(|revision| valid_lower_hex(revision, 40)) 366 && locked 367 .get("narHash") 368 .and_then(serde_json::Value::as_str) 369 .is_some_and(valid_nix_sha256) 370 && original.get("type").and_then(serde_json::Value::as_str) == Some("github") 371 && original.get("owner").and_then(serde_json::Value::as_str) == Some("radrootslabs") 372 && original.get("repo").and_then(serde_json::Value::as_str) == Some("lib") 373 && original_revision == locked_revision 374 && original.get("ref").is_none(); 375 if direct_lib_node == Some(name) && root_name != Some(name.as_str()) && exact { 376 exact_direct += 1; 377 selected_revision = locked_revision.map(str::to_owned); 378 } 379 } 380 if lib_nodes == 1 && exact_direct == 1 { 381 selected_revision.ok_or(ServiceSourceLockError::InvalidNixMaterial) 382 } else { 383 Err(ServiceSourceLockError::InvalidNixMaterial) 384 } 385 } 386 387 struct NoDuplicateJsonValue(serde_json::Value); 388 389 impl<'de> Deserialize<'de> for NoDuplicateJsonValue { 390 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 391 where 392 D: Deserializer<'de>, 393 { 394 deserializer.deserialize_any(NoDuplicateJsonValueVisitor) 395 } 396 } 397 398 struct NoDuplicateJsonValueVisitor; 399 400 impl<'de> Visitor<'de> for NoDuplicateJsonValueVisitor { 401 type Value = NoDuplicateJsonValue; 402 403 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 404 formatter.write_str("JSON without duplicate object keys") 405 } 406 407 fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E> { 408 Ok(NoDuplicateJsonValue(serde_json::Value::Bool(value))) 409 } 410 411 fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E> { 412 Ok(NoDuplicateJsonValue(serde_json::Value::Number( 413 value.into(), 414 ))) 415 } 416 417 fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E> { 418 Ok(NoDuplicateJsonValue(serde_json::Value::Number( 419 value.into(), 420 ))) 421 } 422 423 fn visit_f64<E>(self, _value: f64) -> Result<Self::Value, E> 424 where 425 E: de::Error, 426 { 427 Err(E::custom("unsupported JSON number")) 428 } 429 430 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> { 431 Ok(NoDuplicateJsonValue(serde_json::Value::String( 432 value.to_owned(), 433 ))) 434 } 435 436 fn visit_string<E>(self, value: String) -> Result<Self::Value, E> { 437 Ok(NoDuplicateJsonValue(serde_json::Value::String(value))) 438 } 439 440 fn visit_none<E>(self) -> Result<Self::Value, E> { 441 Ok(NoDuplicateJsonValue(serde_json::Value::Null)) 442 } 443 444 fn visit_unit<E>(self) -> Result<Self::Value, E> { 445 Ok(NoDuplicateJsonValue(serde_json::Value::Null)) 446 } 447 448 fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error> 449 where 450 D: Deserializer<'de>, 451 { 452 Deserialize::deserialize(deserializer) 453 } 454 455 fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error> 456 where 457 A: SeqAccess<'de>, 458 { 459 let mut values = Vec::new(); 460 while let Some(value) = sequence.next_element::<NoDuplicateJsonValue>()? { 461 values.push(value.0); 462 } 463 Ok(NoDuplicateJsonValue(serde_json::Value::Array(values))) 464 } 465 466 fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error> 467 where 468 A: MapAccess<'de>, 469 { 470 let mut values = serde_json::Map::new(); 471 while let Some(key) = map.next_key::<String>()? { 472 if values.contains_key(&key) { 473 return Err(<A::Error as de::Error>::custom("duplicate JSON object key")); 474 } 475 let value = map.next_value::<NoDuplicateJsonValue>()?; 476 values.insert(key, value.0); 477 } 478 Ok(NoDuplicateJsonValue(serde_json::Value::Object(values))) 479 } 480 } 481 482 fn valid_nix_sha256(value: &str) -> bool { 483 let Some(encoded) = value.strip_prefix("sha256-") else { 484 return false; 485 }; 486 let bytes = encoded.as_bytes(); 487 bytes.len() == 44 488 && bytes[43] == b'=' 489 && bytes[..43] 490 .iter() 491 .copied() 492 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/')) 493 } 494 495 pub(crate) struct ServiceSourceLockParts<'a> { 496 pub(crate) service: &'a str, 497 pub(crate) revision: &'a str, 498 pub(crate) workspace_catalog_sha256: &'a str, 499 pub(crate) source_archive_sha256: &'a str, 500 pub(crate) cargo_lock_sha256: &'a str, 501 pub(crate) nix: NixMaterialParts<'a>, 502 pub(crate) contract_versions: ContractVersions, 503 } 504 505 #[derive(Clone, Eq, PartialEq)] 506 pub(crate) struct ServiceSourceLockV2 { 507 raw: RawServiceSourceLock, 508 canonical: Box<[u8]>, 509 } 510 511 impl fmt::Debug for ServiceSourceLockV2 { 512 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 513 formatter 514 .debug_struct("ServiceSourceLockV2") 515 .finish_non_exhaustive() 516 } 517 } 518 519 impl ServiceSourceLockV2 { 520 pub(crate) fn new(parts: ServiceSourceLockParts<'_>) -> Result<Self, ServiceSourceLockError> { 521 validate_parts(&parts)?; 522 let raw = RawServiceSourceLock { 523 schema: LOCK_SCHEMA.to_owned(), 524 contract_version: 2, 525 service: parts.service.to_owned(), 526 repository: LIB_REPOSITORY.to_owned(), 527 revision: parts.revision.to_owned(), 528 architecture: ARCHITECTURE.to_owned(), 529 workspace_catalog_sha256: parts.workspace_catalog_sha256.to_owned(), 530 version: LIB_VERSION.to_owned(), 531 source_archive_sha256: parts.source_archive_sha256.to_owned(), 532 cargo_lock_sha256: parts.cargo_lock_sha256.to_owned(), 533 nix: match parts.nix { 534 NixMaterialParts::Absent => RawNixMaterial::Absent, 535 NixMaterialParts::Deferred { 536 lib_revision, 537 flake_lock_sha256, 538 } => RawNixMaterial::Deferred { 539 lib_revision: lib_revision.to_owned(), 540 flake_lock_sha256: flake_lock_sha256.to_owned(), 541 }, 542 }, 543 rust_version: RUST_VERSION.to_owned(), 544 host_feature_profile: HOST_FEATURE_PROFILE.to_owned(), 545 contract_versions: parts.contract_versions, 546 }; 547 Ok(Self::from_validated_raw(raw)) 548 } 549 550 pub(crate) fn from_canonical_bytes(bytes: &[u8]) -> Result<Self, ServiceSourceLockError> { 551 if bytes.len() > MAX_LOCK_BYTES { 552 return Err(ServiceSourceLockError::TooLarge); 553 } 554 let text = std::str::from_utf8(bytes).map_err(|_| ServiceSourceLockError::Malformed)?; 555 let raw = toml::from_str::<RawServiceSourceLock>(text) 556 .map_err(|_| ServiceSourceLockError::Malformed)?; 557 validate_raw(&raw)?; 558 let lock = Self::from_validated_raw(raw); 559 if lock.canonical.as_ref() != bytes { 560 return Err(ServiceSourceLockError::Noncanonical); 561 } 562 Ok(lock) 563 } 564 565 fn from_validated_raw(raw: RawServiceSourceLock) -> Self { 566 let canonical = render(&raw).into_bytes().into_boxed_slice(); 567 debug_assert!(canonical.len() <= MAX_LOCK_BYTES); 568 Self { raw, canonical } 569 } 570 571 pub(crate) fn canonical_bytes(&self) -> &[u8] { 572 debug_assert_eq!(self.raw.schema, LOCK_SCHEMA); 573 &self.canonical 574 } 575 576 pub(crate) fn service(&self) -> &str { 577 &self.raw.service 578 } 579 580 pub(crate) fn revision(&self) -> &str { 581 &self.raw.revision 582 } 583 584 pub(crate) fn cargo_lock_sha256(&self) -> &str { 585 &self.raw.cargo_lock_sha256 586 } 587 588 pub(crate) const fn nix_material_state(&self) -> NixMaterialState { 589 match &self.raw.nix { 590 RawNixMaterial::Absent => NixMaterialState::Absent, 591 RawNixMaterial::Deferred { .. } => NixMaterialState::Deferred, 592 } 593 } 594 595 pub(crate) fn nix_lib_revision(&self) -> Option<&str> { 596 match &self.raw.nix { 597 RawNixMaterial::Absent => None, 598 RawNixMaterial::Deferred { lib_revision, .. } => Some(lib_revision), 599 } 600 } 601 602 pub(crate) fn flake_lock_sha256(&self) -> Option<&str> { 603 match &self.raw.nix { 604 RawNixMaterial::Absent => None, 605 RawNixMaterial::Deferred { 606 flake_lock_sha256, .. 607 } => Some(flake_lock_sha256), 608 } 609 } 610 611 pub(crate) const fn contract_versions(&self) -> ContractVersions { 612 self.raw.contract_versions 613 } 614 } 615 616 #[derive(Debug, Deserialize)] 617 #[serde(deny_unknown_fields)] 618 struct SourceLockDecision { 619 schema: String, 620 contract_version: u32, 621 decision_state: String, 622 predecessor: PredecessorDecision, 623 lock_filename: String, 624 lock_schema: String, 625 canonical_encoding: String, 626 maximum_lock_utf8_bytes: usize, 627 maximum_service_utf8_bytes: usize, 628 canonical_field_order_deferred: Vec<String>, 629 canonical_field_order_absent: Vec<String>, 630 fixed: FixedDecision, 631 revision_encoding: String, 632 digest_encoding: String, 633 digest_subjects: DigestSubjects, 634 service_identifier: String, 635 contract_version_rule: String, 636 negative_error_codes: Vec<String>, 637 canonical_vectors: CanonicalVectors, 638 operations: OperationsDecision, 639 deferred_operations: Vec<String>, 640 } 641 642 #[derive(Debug, Deserialize)] 643 #[serde(deny_unknown_fields)] 644 struct PredecessorDecision { 645 schema: String, 646 filename: String, 647 transition: String, 648 } 649 650 #[derive(Debug, Deserialize)] 651 #[serde(deny_unknown_fields)] 652 struct OperationsDecision { 653 command: String, 654 modes: Vec<String>, 655 required_arguments: Vec<String>, 656 service_metadata_path: String, 657 service_metadata_fields: Vec<String>, 658 lib_dependency_inventory: String, 659 source_cleanliness: String, 660 predecessor_lock_presence: String, 661 service_revision_stability: String, 662 active_revision_agreement: Vec<String>, 663 nix_material_states: Vec<String>, 664 deferred_nix_agreement: Vec<String>, 665 maximum_source_archive_bytes: u64, 666 } 667 668 #[derive(Debug, Deserialize)] 669 #[serde(deny_unknown_fields)] 670 struct DigestSubjects { 671 workspace_catalog_sha256: String, 672 source_archive_sha256: String, 673 cargo_lock_sha256: String, 674 flake_lock_sha256: String, 675 } 676 677 #[derive(Debug, Deserialize)] 678 #[serde(deny_unknown_fields)] 679 struct FixedDecision { 680 repository: String, 681 architecture: String, 682 version: String, 683 rust_version: String, 684 host_feature_profile: String, 685 } 686 687 #[derive(Debug, Deserialize)] 688 #[serde(deny_unknown_fields)] 689 struct CanonicalVector { 690 toml: String, 691 sha256: String, 692 } 693 694 #[derive(Debug, Deserialize)] 695 #[serde(deny_unknown_fields)] 696 struct CanonicalVectors { 697 deferred: CanonicalVector, 698 absent: CanonicalVector, 699 } 700 701 pub(crate) fn validate_contract(workspace_root: &Path) -> Result<(), String> { 702 validate_contract_inner(workspace_root).map_err(|error| error.to_string()) 703 } 704 705 fn validate_contract_inner(workspace_root: &Path) -> Result<(), ServiceSourceLockError> { 706 let path = workspace_root.join(CONTRACT_RELATIVE); 707 let metadata = fs::symlink_metadata(&path).map_err(|_| ServiceSourceLockError::Malformed)?; 708 if !metadata.is_file() || metadata.len() > MAX_CONTRACT_BYTES as u64 { 709 return Err(ServiceSourceLockError::Malformed); 710 } 711 let file = fs::File::open(path).map_err(|_| ServiceSourceLockError::Malformed)?; 712 let mut bytes = Vec::with_capacity(metadata.len() as usize); 713 file.take(MAX_CONTRACT_BYTES as u64 + 1) 714 .read_to_end(&mut bytes) 715 .map_err(|_| ServiceSourceLockError::Malformed)?; 716 if bytes.len() > MAX_CONTRACT_BYTES { 717 return Err(ServiceSourceLockError::Malformed); 718 } 719 let decision = serde_json::from_slice::<SourceLockDecision>(&bytes) 720 .map_err(|_| ServiceSourceLockError::Malformed)?; 721 validate_decision(&decision)?; 722 723 for (vector, expected) in [ 724 ( 725 &decision.canonical_vectors.deferred, 726 canonical_deferred_vector(), 727 ), 728 ( 729 &decision.canonical_vectors.absent, 730 canonical_absent_vector(), 731 ), 732 ] { 733 let parsed = ServiceSourceLockV2::from_canonical_bytes(vector.toml.as_bytes())?; 734 if parsed != expected 735 || hex::encode(Sha256::digest(parsed.canonical_bytes())) != vector.sha256 736 { 737 return Err(ServiceSourceLockError::Noncanonical); 738 } 739 } 740 Ok(()) 741 } 742 743 fn validate_decision(decision: &SourceLockDecision) -> Result<(), ServiceSourceLockError> { 744 let error_codes = [ 745 ServiceSourceLockError::InvalidContractVersion, 746 ServiceSourceLockError::InvalidDigest, 747 ServiceSourceLockError::InvalidFeatureProfile, 748 ServiceSourceLockError::InvalidFixedIdentity, 749 ServiceSourceLockError::InvalidNixMaterial, 750 ServiceSourceLockError::InvalidRevision, 751 ServiceSourceLockError::InvalidService, 752 ServiceSourceLockError::InvalidToolchain, 753 ServiceSourceLockError::Malformed, 754 ServiceSourceLockError::Noncanonical, 755 ServiceSourceLockError::TooLarge, 756 ] 757 .map(ServiceSourceLockError::code); 758 let exact = decision.schema == "radroots.services-hardening.source-lock-decisions.v2" 759 && decision.contract_version == 2 760 && decision.decision_state == "active" 761 && decision.predecessor.schema == "radroots.service.source-lock.v1" 762 && decision.predecessor.filename == PREDECESSOR_LOCK_FILENAME 763 && decision.predecessor.transition == "forward_only_replace" 764 && decision.lock_filename == LOCK_FILENAME 765 && decision.lock_schema == LOCK_SCHEMA 766 && decision.canonical_encoding == "compact_canonical_toml_with_final_newline" 767 && decision.maximum_lock_utf8_bytes == MAX_LOCK_BYTES 768 && decision.maximum_service_utf8_bytes == MAX_SERVICE_BYTES 769 && decision.canonical_field_order_deferred == DEFERRED_FIELD_ORDER 770 && decision.canonical_field_order_absent == ABSENT_FIELD_ORDER 771 && decision.fixed.repository == LIB_REPOSITORY 772 && decision.fixed.architecture == ARCHITECTURE 773 && decision.fixed.version == LIB_VERSION 774 && decision.fixed.rust_version == RUST_VERSION 775 && decision.fixed.host_feature_profile == HOST_FEATURE_PROFILE 776 && decision.revision_encoding == "git_oid_lowercase_hex_40" 777 && decision.digest_encoding == "sha256_lowercase_hex_64" 778 && decision.digest_subjects.workspace_catalog_sha256 779 == "lib/contracts/crates/catalog.v2.toml" 780 && decision.digest_subjects.source_archive_sha256 781 == "canonical_lib_revision_source_archive" 782 && decision.digest_subjects.cargo_lock_sha256 == "service/Cargo.lock" 783 && decision.digest_subjects.flake_lock_sha256 == "service/flake.lock" 784 && decision.service_identifier 785 == "ascii_lower_snake_case_starting_with_letter_ending_with_letter_or_digit_no_empty_segments_1_to_128_bytes" 786 && decision.contract_version_rule == "u32_nonzero" 787 && error_codes == ERROR_CODES 788 && decision.negative_error_codes == error_codes 789 && decision.operations.command == "cargo xtask service-source-lock" 790 && decision.operations.modes == ["check", "write"] 791 && decision.operations.required_arguments == ["mode", "service_root", "source_archive"] 792 && decision.operations.service_metadata_path 793 == "Cargo.toml.workspace.metadata.radroots.service_source_lock" 794 && decision.operations.service_metadata_fields 795 == [ 796 "service", 797 "host_feature_profile", 798 "nix_material", 799 "config_contract_version", 800 "state_contract_version", 801 "admin_contract_version", 802 "status_contract_version", 803 "provider_contract_version", 804 ] 805 && decision.operations.lib_dependency_inventory 806 == "verified_source_archive_workspace_catalog" 807 && decision.operations.source_cleanliness 808 == "all_changes_forbidden_except_exact_generated_lock_path" 809 && decision.operations.predecessor_lock_presence == "forbidden" 810 && decision.operations.service_revision_stability 811 == "same_head_before_and_after_evidence_and_output" 812 && decision.operations.active_revision_agreement 813 == [ 814 "cargo_manifests", 815 "cargo_lock", 816 "source_archive", 817 "canonical_public_remote", 818 ] 819 && decision.operations.nix_material_states == ["absent", "deferred"] 820 && decision.operations.deferred_nix_agreement 821 == [ 822 "flake_expression", 823 "flake_lock", 824 "source_lock_nix_revision", 825 "canonical_public_remote", 826 ] 827 && decision.operations.maximum_source_archive_bytes == 1_073_741_824 828 && decision.deferred_operations 829 == [ 830 "embedded_build_information_agreement", 831 "nix_qualification", 832 "nix_active_revision_alignment", 833 ]; 834 if exact { 835 Ok(()) 836 } else { 837 Err(ServiceSourceLockError::InvalidFixedIdentity) 838 } 839 } 840 841 fn canonical_deferred_vector() -> ServiceSourceLockV2 { 842 ServiceSourceLockV2::new(ServiceSourceLockParts { 843 service: "fixture_service", 844 revision: "2222222222222222222222222222222222222222", 845 workspace_catalog_sha256: "2222222222222222222222222222222222222222222222222222222222222222", 846 source_archive_sha256: "3333333333333333333333333333333333333333333333333333333333333333", 847 cargo_lock_sha256: "3f32f227550b26ffccf6ee73ceab7471b3d8ce40b3e7c345d2ed65af7e9affa0", 848 nix: NixMaterialParts::Deferred { 849 lib_revision: "1111111111111111111111111111111111111111", 850 flake_lock_sha256: "13638c254efcc7ccc5798242d2c095934e84fbc406a9af244fc754b18a6f9353", 851 }, 852 contract_versions: ContractVersions::new(1, 2, 3, 4, 5), 853 }) 854 .expect("the governed source-lock vector is valid") 855 } 856 857 fn canonical_absent_vector() -> ServiceSourceLockV2 { 858 ServiceSourceLockV2::new(ServiceSourceLockParts { 859 service: "fixture_service", 860 revision: "2222222222222222222222222222222222222222", 861 workspace_catalog_sha256: "2222222222222222222222222222222222222222222222222222222222222222", 862 source_archive_sha256: "3333333333333333333333333333333333333333333333333333333333333333", 863 cargo_lock_sha256: "3f32f227550b26ffccf6ee73ceab7471b3d8ce40b3e7c345d2ed65af7e9affa0", 864 nix: NixMaterialParts::Absent, 865 contract_versions: ContractVersions::new(1, 2, 3, 4, 5), 866 }) 867 .expect("the governed absent-Nix source-lock vector is valid") 868 } 869 870 fn validate_raw(raw: &RawServiceSourceLock) -> Result<(), ServiceSourceLockError> { 871 if raw.schema != LOCK_SCHEMA 872 || raw.contract_version != 2 873 || raw.repository != LIB_REPOSITORY 874 || raw.architecture != ARCHITECTURE 875 || raw.version != LIB_VERSION 876 { 877 return Err(ServiceSourceLockError::InvalidFixedIdentity); 878 } 879 if raw.rust_version != RUST_VERSION { 880 return Err(ServiceSourceLockError::InvalidToolchain); 881 } 882 if raw.host_feature_profile != HOST_FEATURE_PROFILE { 883 return Err(ServiceSourceLockError::InvalidFeatureProfile); 884 } 885 validate_parts(&ServiceSourceLockParts { 886 service: &raw.service, 887 revision: &raw.revision, 888 workspace_catalog_sha256: &raw.workspace_catalog_sha256, 889 source_archive_sha256: &raw.source_archive_sha256, 890 cargo_lock_sha256: &raw.cargo_lock_sha256, 891 nix: match &raw.nix { 892 RawNixMaterial::Absent => NixMaterialParts::Absent, 893 RawNixMaterial::Deferred { 894 lib_revision, 895 flake_lock_sha256, 896 } => NixMaterialParts::Deferred { 897 lib_revision, 898 flake_lock_sha256, 899 }, 900 }, 901 contract_versions: raw.contract_versions, 902 }) 903 } 904 905 fn validate_parts(parts: &ServiceSourceLockParts<'_>) -> Result<(), ServiceSourceLockError> { 906 if !valid_service(parts.service) { 907 return Err(ServiceSourceLockError::InvalidService); 908 } 909 if !valid_lower_hex(parts.revision, 40) { 910 return Err(ServiceSourceLockError::InvalidRevision); 911 } 912 if ![ 913 parts.workspace_catalog_sha256, 914 parts.source_archive_sha256, 915 parts.cargo_lock_sha256, 916 ] 917 .into_iter() 918 .all(|value| valid_lower_hex(value, 64)) 919 { 920 return Err(ServiceSourceLockError::InvalidDigest); 921 } 922 if let NixMaterialParts::Deferred { 923 lib_revision, 924 flake_lock_sha256, 925 } = parts.nix 926 && (!valid_lower_hex(lib_revision, 40) || !valid_lower_hex(flake_lock_sha256, 64)) 927 { 928 return Err(ServiceSourceLockError::InvalidNixMaterial); 929 } 930 if !parts.contract_versions.is_valid() { 931 return Err(ServiceSourceLockError::InvalidContractVersion); 932 } 933 Ok(()) 934 } 935 936 fn valid_service(value: &str) -> bool { 937 let bytes = value.as_bytes(); 938 !bytes.is_empty() 939 && bytes.len() <= MAX_SERVICE_BYTES 940 && bytes[0].is_ascii_lowercase() 941 && (bytes[bytes.len() - 1].is_ascii_lowercase() || bytes[bytes.len() - 1].is_ascii_digit()) 942 && bytes 943 .iter() 944 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'_') 945 && !bytes.windows(2).any(|pair| pair == b"__") 946 } 947 948 fn valid_lower_hex(value: &str, length: usize) -> bool { 949 value.len() == length 950 && value 951 .bytes() 952 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) 953 } 954 955 fn render(raw: &RawServiceSourceLock) -> String { 956 let mut output = String::with_capacity(1024); 957 writeln!(output, "schema = \"{}\"", raw.schema).expect("render to String"); 958 writeln!(output, "contract_version = {}", raw.contract_version).expect("render to String"); 959 writeln!(output, "service = \"{}\"", raw.service).expect("render to String"); 960 writeln!(output, "repository = \"{}\"", raw.repository).expect("render to String"); 961 writeln!(output, "revision = \"{}\"", raw.revision).expect("render to String"); 962 writeln!(output, "architecture = \"{}\"", raw.architecture).expect("render to String"); 963 writeln!( 964 output, 965 "workspace_catalog_sha256 = \"{}\"", 966 raw.workspace_catalog_sha256 967 ) 968 .expect("render to String"); 969 writeln!(output, "version = \"{}\"", raw.version).expect("render to String"); 970 writeln!( 971 output, 972 "source_archive_sha256 = \"{}\"", 973 raw.source_archive_sha256 974 ) 975 .expect("render to String"); 976 writeln!(output, "cargo_lock_sha256 = \"{}\"", raw.cargo_lock_sha256) 977 .expect("render to String"); 978 writeln!(output, "rust_version = \"{}\"", raw.rust_version).expect("render to String"); 979 writeln!( 980 output, 981 "host_feature_profile = \"{}\"", 982 raw.host_feature_profile 983 ) 984 .expect("render to String"); 985 output.push_str("\n[nix]\n"); 986 match &raw.nix { 987 RawNixMaterial::Absent => { 988 output.push_str("material = \"absent\"\n"); 989 } 990 RawNixMaterial::Deferred { 991 lib_revision, 992 flake_lock_sha256, 993 } => { 994 output.push_str("material = \"deferred\"\n"); 995 writeln!(output, "lib_revision = \"{lib_revision}\"").expect("render to String"); 996 writeln!(output, "flake_lock_sha256 = \"{flake_lock_sha256}\"") 997 .expect("render to String"); 998 } 999 } 1000 output.push_str("\n[contract_versions]\n"); 1001 writeln!(output, "config = {}", raw.contract_versions.config).expect("render to String"); 1002 writeln!(output, "state = {}", raw.contract_versions.state).expect("render to String"); 1003 writeln!(output, "admin = {}", raw.contract_versions.admin).expect("render to String"); 1004 writeln!(output, "status = {}", raw.contract_versions.status).expect("render to String"); 1005 writeln!(output, "provider = {}", raw.contract_versions.provider).expect("render to String"); 1006 output 1007 } 1008 1009 #[cfg(test)] 1010 mod tests { 1011 use std::error::Error as _; 1012 1013 use crate::workspace_root; 1014 1015 use super::*; 1016 1017 #[test] 1018 fn canonical_vectors_round_trip_with_exact_digests() { 1019 let lock = canonical_deferred_vector(); 1020 assert_eq!( 1021 hex::encode(Sha256::digest(lock.canonical_bytes())), 1022 "da7b8894a6480e7022d9937369a5c9bafbf90128a901652923e501c52aced1a2" 1023 ); 1024 assert_eq!( 1025 ServiceSourceLockV2::from_canonical_bytes(lock.canonical_bytes()), 1026 Ok(lock) 1027 ); 1028 let lock = canonical_absent_vector(); 1029 assert_eq!( 1030 hex::encode(Sha256::digest(lock.canonical_bytes())), 1031 "2af058ba042509c77efd6dc264c60a7abf4371378223e52e34eb441cab7e263c" 1032 ); 1033 assert_eq!( 1034 ServiceSourceLockV2::from_canonical_bytes(lock.canonical_bytes()), 1035 Ok(lock) 1036 ); 1037 } 1038 1039 #[test] 1040 fn deferred_material_parser_accepts_both_exact_layouts_and_rejects_ambiguous_selection() { 1041 let revision = "1".repeat(40); 1042 let mut original = serde_json::json!({"version":7,"root":"root","nodes":{ 1043 "root":{"inputs":{"lib":"lib"}}, 1044 "lib":{ 1045 "locked":{"lastModified":1,"narHash":format!("sha256-{}=", "A".repeat(43)),"owner":"radrootslabs","repo":"lib","rev":revision,"type":"github"}, 1046 "original":{"owner":"radrootslabs","repo":"lib","rev":revision,"type":"github"} 1047 } 1048 }}); 1049 let bytes = serde_json::to_vec(&original).unwrap(); 1050 assert_eq!(validate_deferred_nix_lock(&bytes).unwrap(), revision); 1051 let direct = format!( 1052 "inputs.lib = {{\nurl = \"github:radrootslabs/lib/{revision}\";\nflake = false;\n}};\n" 1053 ); 1054 let nested = format!( 1055 "inputs = {{\nlib = {{\nurl = \"github:radrootslabs/lib/{revision}\";\nflake = false;\n}};\n}};\n" 1056 ); 1057 for expression in [&direct, &nested] { 1058 assert_eq!( 1059 validate_deferred_nix_material(expression.as_bytes(), &bytes) 1060 .unwrap() 1061 .lib_revision(), 1062 revision 1063 ); 1064 for changed in [ 1065 expression.replace("flake = false;", "flake = true;"), 1066 expression.replace(&revision, &"2".repeat(40)), 1067 format!("{expression}{direct}"), 1068 ] { 1069 assert_eq!( 1070 validate_deferred_nix_material(changed.as_bytes(), &bytes), 1071 Err(ServiceSourceLockError::InvalidNixMaterial) 1072 ); 1073 } 1074 } 1075 assert!(validate_deferred_nix_material(&[255], &bytes).is_err()); 1076 for (pointer, value) in [ 1077 ("/root", serde_json::json!("")), 1078 ("/root", serde_json::json!(null)), 1079 ("/nodes", serde_json::json!([])), 1080 ("/nodes/root/inputs/lib", serde_json::json!([])), 1081 ("/nodes/lib/locked/owner", serde_json::json!("elsewhere")), 1082 ("/nodes/lib/original/owner", serde_json::json!("elsewhere")), 1083 ] { 1084 let mut changed = original.clone(); 1085 *changed.pointer_mut(pointer).unwrap() = value; 1086 assert!( 1087 validate_deferred_nix_lock(&serde_json::to_vec(&changed).unwrap()).is_err(), 1088 "{pointer}" 1089 ); 1090 } 1091 original["nodes"]["unrelated"] = 1092 serde_json::json!({"values":[null,true,1,-1,"text",{},[]]}); 1093 assert_eq!( 1094 validate_deferred_nix_lock(&serde_json::to_vec(&original).unwrap()).unwrap(), 1095 revision 1096 ); 1097 original["unknown"] = serde_json::json!(true); 1098 assert!(validate_deferred_nix_lock(&serde_json::to_vec(&original).unwrap()).is_err()); 1099 for digest in [ 1100 "invalid".to_owned(), 1101 format!("sha256-{}x", "A".repeat(43)), 1102 format!("sha256-{}?=", "A".repeat(42)), 1103 ] { 1104 assert!(!valid_nix_sha256(&digest)); 1105 } 1106 for service in ["a_", "a-b", "a.B"] { 1107 assert!(!valid_service(service)); 1108 } 1109 } 1110 1111 #[test] 1112 fn source_lock_contract_input_must_be_a_bounded_regular_file() { 1113 let root = tempfile::TempDir::new().unwrap(); 1114 let contract = root.path().join(CONTRACT_RELATIVE); 1115 fs::create_dir_all(contract.parent().unwrap()).unwrap(); 1116 fs::create_dir(&contract).unwrap(); 1117 assert!(validate_contract_inner(root.path()).is_err()); 1118 fs::remove_dir(&contract).unwrap(); 1119 fs::write(&contract, vec![b' '; MAX_CONTRACT_BYTES + 1]).unwrap(); 1120 assert!(validate_contract_inner(root.path()).is_err()); 1121 } 1122 1123 #[test] 1124 fn decision_rejects_every_independent_governed_field_drift() { 1125 let bytes = fs::read(workspace_root().join(CONTRACT_RELATIVE)).expect("decision"); 1126 let canonical = serde_json::from_slice::<serde_json::Value>(&bytes).expect("decision json"); 1127 for (pointer, replacement) in [ 1128 ("/schema", serde_json::json!("other")), 1129 ("/contract_version", serde_json::json!(1)), 1130 ("/decision_state", serde_json::json!("draft")), 1131 ("/predecessor/schema", serde_json::json!("other")), 1132 ("/predecessor/filename", serde_json::json!("other")), 1133 ("/predecessor/transition", serde_json::json!("other")), 1134 ("/lock_filename", serde_json::json!("other")), 1135 ("/lock_schema", serde_json::json!("other")), 1136 ("/canonical_encoding", serde_json::json!("other")), 1137 ("/maximum_lock_utf8_bytes", serde_json::json!(1)), 1138 ("/maximum_service_utf8_bytes", serde_json::json!(1)), 1139 ("/canonical_field_order_deferred", serde_json::json!([])), 1140 ("/canonical_field_order_absent", serde_json::json!([])), 1141 ("/fixed/repository", serde_json::json!("other")), 1142 ("/fixed/architecture", serde_json::json!("other")), 1143 ("/fixed/version", serde_json::json!("other")), 1144 ("/fixed/rust_version", serde_json::json!("other")), 1145 ("/fixed/host_feature_profile", serde_json::json!("other")), 1146 ("/revision_encoding", serde_json::json!("other")), 1147 ("/digest_encoding", serde_json::json!("other")), 1148 ( 1149 "/digest_subjects/workspace_catalog_sha256", 1150 serde_json::json!("other"), 1151 ), 1152 ( 1153 "/digest_subjects/source_archive_sha256", 1154 serde_json::json!("other"), 1155 ), 1156 ( 1157 "/digest_subjects/cargo_lock_sha256", 1158 serde_json::json!("other"), 1159 ), 1160 ( 1161 "/digest_subjects/flake_lock_sha256", 1162 serde_json::json!("other"), 1163 ), 1164 ("/service_identifier", serde_json::json!("other")), 1165 ("/contract_version_rule", serde_json::json!("other")), 1166 ("/negative_error_codes", serde_json::json!([])), 1167 ("/operations/command", serde_json::json!("other")), 1168 ("/operations/modes", serde_json::json!([])), 1169 ("/operations/required_arguments", serde_json::json!([])), 1170 ( 1171 "/operations/service_metadata_path", 1172 serde_json::json!("other"), 1173 ), 1174 ("/operations/service_metadata_fields", serde_json::json!([])), 1175 ( 1176 "/operations/lib_dependency_inventory", 1177 serde_json::json!("other"), 1178 ), 1179 ("/operations/source_cleanliness", serde_json::json!("other")), 1180 ( 1181 "/operations/predecessor_lock_presence", 1182 serde_json::json!("other"), 1183 ), 1184 ( 1185 "/operations/service_revision_stability", 1186 serde_json::json!("other"), 1187 ), 1188 ( 1189 "/operations/active_revision_agreement", 1190 serde_json::json!([]), 1191 ), 1192 ("/operations/nix_material_states", serde_json::json!([])), 1193 ("/operations/deferred_nix_agreement", serde_json::json!([])), 1194 ( 1195 "/operations/maximum_source_archive_bytes", 1196 serde_json::json!(1), 1197 ), 1198 ("/deferred_operations", serde_json::json!(["future"])), 1199 ] { 1200 let mut drifted = canonical.clone(); 1201 *drifted.pointer_mut(pointer).expect("governed field") = replacement; 1202 let decision = serde_json::from_value::<SourceLockDecision>(drifted) 1203 .expect("structurally valid drift"); 1204 assert_eq!( 1205 validate_decision(&decision), 1206 Err(ServiceSourceLockError::InvalidFixedIdentity), 1207 "accepted drift at {pointer}" 1208 ); 1209 } 1210 } 1211 1212 #[test] 1213 fn parser_rejects_noncanonical_and_ambiguous_toml() { 1214 let canonical = String::from_utf8(canonical_deferred_vector().canonical_bytes().to_vec()) 1215 .expect("canonical UTF-8"); 1216 for malformed in [ 1217 canonical.replacen("schema =", "unknown = 1\nschema =", 1), 1218 canonical.replacen( 1219 "contract_version = 2\nservice = \"fixture_service\"", 1220 "service = \"fixture_service\"\ncontract_version = 2", 1221 1, 1222 ), 1223 format!(" {canonical}"), 1224 canonical.replacen("schema =", "schema=", 1), 1225 canonical.replacen("contract_version = 2", "contract_version = 02", 1), 1226 ] { 1227 assert!(matches!( 1228 ServiceSourceLockV2::from_canonical_bytes(malformed.as_bytes()), 1229 Err(ServiceSourceLockError::Malformed | ServiceSourceLockError::Noncanonical) 1230 )); 1231 } 1232 let duplicate = canonical.replacen( 1233 "service = \"fixture_service\"", 1234 "service = \"fixture_service\"\nservice = \"fixture_service\"", 1235 1, 1236 ); 1237 assert_eq!( 1238 ServiceSourceLockV2::from_canonical_bytes(duplicate.as_bytes()), 1239 Err(ServiceSourceLockError::Malformed) 1240 ); 1241 } 1242 1243 #[test] 1244 fn nix_material_variants_are_closed_and_independently_bounded() { 1245 let absent = canonical_absent_vector(); 1246 assert_eq!(absent.nix_material_state(), NixMaterialState::Absent); 1247 assert_eq!(absent.nix_lib_revision(), None); 1248 assert_eq!(absent.flake_lock_sha256(), None); 1249 1250 let deferred = canonical_deferred_vector(); 1251 assert_eq!(deferred.nix_material_state(), NixMaterialState::Deferred); 1252 assert_eq!( 1253 deferred.nix_lib_revision(), 1254 Some("1111111111111111111111111111111111111111") 1255 ); 1256 assert_eq!( 1257 deferred.flake_lock_sha256(), 1258 Some("13638c254efcc7ccc5798242d2c095934e84fbc406a9af244fc754b18a6f9353") 1259 ); 1260 1261 let absent_text = String::from_utf8(absent.canonical_bytes().to_vec()).expect("UTF-8"); 1262 let deferred_text = String::from_utf8(deferred.canonical_bytes().to_vec()).expect("UTF-8"); 1263 for malformed in [ 1264 absent_text.replacen( 1265 "material = \"absent\"", 1266 "material = \"absent\"\nlib_revision = \"1111111111111111111111111111111111111111\"", 1267 1, 1268 ), 1269 absent_text.replacen("material = \"absent\"", "material = \"other\"", 1), 1270 deferred_text.replacen( 1271 "lib_revision = \"1111111111111111111111111111111111111111\"\n", 1272 "", 1273 1, 1274 ), 1275 deferred_text.replacen( 1276 "flake_lock_sha256 = \"13638c254efcc7ccc5798242d2c095934e84fbc406a9af244fc754b18a6f9353\"\n", 1277 "", 1278 1, 1279 ), 1280 ] { 1281 assert!(matches!( 1282 ServiceSourceLockV2::from_canonical_bytes(malformed.as_bytes()), 1283 Err(ServiceSourceLockError::Malformed | ServiceSourceLockError::Noncanonical) 1284 )); 1285 } 1286 1287 assert_eq!( 1288 validate_deferred_nix_material(&vec![b'x'; MAX_FLAKE_NIX_BYTES + 1], b"{}"), 1289 Err(ServiceSourceLockError::InvalidNixMaterial) 1290 ); 1291 assert_eq!( 1292 validate_deferred_nix_material(b"", &vec![b'x'; MAX_FLAKE_LOCK_BYTES + 1]), 1293 Err(ServiceSourceLockError::InvalidNixMaterial) 1294 ); 1295 } 1296 1297 #[test] 1298 fn parser_rejects_every_identity_and_bound_drift() { 1299 let canonical = String::from_utf8(canonical_deferred_vector().canonical_bytes().to_vec()) 1300 .expect("canonical UTF-8"); 1301 let cases = [ 1302 ( 1303 "radroots.service.source-lock.v2", 1304 "wrong", 1305 ServiceSourceLockError::InvalidFixedIdentity, 1306 ), 1307 ( 1308 "contract_version = 2", 1309 "contract_version = 1", 1310 ServiceSourceLockError::InvalidFixedIdentity, 1311 ), 1312 ( 1313 LIB_REPOSITORY, 1314 "https://example.invalid/lib", 1315 ServiceSourceLockError::InvalidFixedIdentity, 1316 ), 1317 ( 1318 ARCHITECTURE, 1319 "radroots.crates.release.v1", 1320 ServiceSourceLockError::InvalidFixedIdentity, 1321 ), 1322 ( 1323 LIB_VERSION, 1324 "0.1.1", 1325 ServiceSourceLockError::InvalidFixedIdentity, 1326 ), 1327 ( 1328 "fixture_service", 1329 "Fixture", 1330 ServiceSourceLockError::InvalidService, 1331 ), 1332 ( 1333 "service = \"fixture_service\"", 1334 "service = \"\"", 1335 ServiceSourceLockError::InvalidService, 1336 ), 1337 ( 1338 "2222222222222222222222222222222222222222", 1339 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", 1340 ServiceSourceLockError::InvalidRevision, 1341 ), 1342 ( 1343 "2222222222222222222222222222222222222222", 1344 "222222222222222222222222222222222222222", 1345 ServiceSourceLockError::InvalidRevision, 1346 ), 1347 ( 1348 "2222222222222222222222222222222222222222222222222222222222222222", 1349 "GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG", 1350 ServiceSourceLockError::InvalidDigest, 1351 ), 1352 ( 1353 "3333333333333333333333333333333333333333333333333333333333333333", 1354 "333333333333333333333333333333333333333333333333333333333333333", 1355 ServiceSourceLockError::InvalidDigest, 1356 ), 1357 ( 1358 "3f32f227550b26ffccf6ee73ceab7471b3d8ce40b3e7c345d2ed65af7e9affa0", 1359 "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", 1360 ServiceSourceLockError::InvalidDigest, 1361 ), 1362 ( 1363 "13638c254efcc7ccc5798242d2c095934e84fbc406a9af244fc754b18a6f9353", 1364 "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", 1365 ServiceSourceLockError::InvalidNixMaterial, 1366 ), 1367 ( 1368 "1111111111111111111111111111111111111111", 1369 "invalid", 1370 ServiceSourceLockError::InvalidNixMaterial, 1371 ), 1372 ( 1373 RUST_VERSION, 1374 "1.97.0", 1375 ServiceSourceLockError::InvalidToolchain, 1376 ), 1377 ( 1378 HOST_FEATURE_PROFILE, 1379 "default", 1380 ServiceSourceLockError::InvalidFeatureProfile, 1381 ), 1382 ( 1383 "config = 1", 1384 "config = 0", 1385 ServiceSourceLockError::InvalidContractVersion, 1386 ), 1387 ( 1388 "state = 2", 1389 "state = 0", 1390 ServiceSourceLockError::InvalidContractVersion, 1391 ), 1392 ( 1393 "admin = 3", 1394 "admin = 0", 1395 ServiceSourceLockError::InvalidContractVersion, 1396 ), 1397 ( 1398 "status = 4", 1399 "status = 0", 1400 ServiceSourceLockError::InvalidContractVersion, 1401 ), 1402 ( 1403 "provider = 5", 1404 "provider = 0", 1405 ServiceSourceLockError::InvalidContractVersion, 1406 ), 1407 ]; 1408 for (from, to, expected) in cases { 1409 let mutated = canonical.replacen(from, to, 1); 1410 assert_eq!( 1411 ServiceSourceLockV2::from_canonical_bytes(mutated.as_bytes()), 1412 Err(expected) 1413 ); 1414 } 1415 1416 let too_large = vec![b' '; MAX_LOCK_BYTES + 1]; 1417 assert_eq!( 1418 ServiceSourceLockV2::from_canonical_bytes(&too_large), 1419 Err(ServiceSourceLockError::TooLarge) 1420 ); 1421 } 1422 1423 #[test] 1424 fn exact_service_and_contract_version_maxima_are_admitted() { 1425 let service = "a".repeat(MAX_SERVICE_BYTES); 1426 let maximum = ServiceSourceLockV2::new(ServiceSourceLockParts { 1427 service: &service, 1428 revision: &"a".repeat(40), 1429 workspace_catalog_sha256: &"b".repeat(64), 1430 source_archive_sha256: &"c".repeat(64), 1431 cargo_lock_sha256: &"d".repeat(64), 1432 nix: NixMaterialParts::Deferred { 1433 lib_revision: &"f".repeat(40), 1434 flake_lock_sha256: &"e".repeat(64), 1435 }, 1436 contract_versions: ContractVersions::new( 1437 u32::MAX, 1438 u32::MAX, 1439 u32::MAX, 1440 u32::MAX, 1441 u32::MAX, 1442 ), 1443 }) 1444 .expect("maximum lock"); 1445 assert!(maximum.canonical_bytes().len() <= MAX_LOCK_BYTES); 1446 1447 let overlong_service = "a".repeat(MAX_SERVICE_BYTES + 1); 1448 assert_eq!( 1449 ServiceSourceLockV2::new(ServiceSourceLockParts { 1450 service: &overlong_service, 1451 revision: &"a".repeat(40), 1452 workspace_catalog_sha256: &"b".repeat(64), 1453 source_archive_sha256: &"c".repeat(64), 1454 cargo_lock_sha256: &"d".repeat(64), 1455 nix: NixMaterialParts::Absent, 1456 contract_versions: ContractVersions::new(1, 1, 1, 1, 1), 1457 }), 1458 Err(ServiceSourceLockError::InvalidService) 1459 ); 1460 1461 for invalid in [ 1462 "1service", 1463 "_service", 1464 "service_", 1465 "service__name", 1466 "service-name", 1467 ] { 1468 assert_eq!( 1469 ServiceSourceLockV2::new(ServiceSourceLockParts { 1470 service: invalid, 1471 revision: &"a".repeat(40), 1472 workspace_catalog_sha256: &"b".repeat(64), 1473 source_archive_sha256: &"c".repeat(64), 1474 cargo_lock_sha256: &"d".repeat(64), 1475 nix: NixMaterialParts::Absent, 1476 contract_versions: ContractVersions::new(1, 1, 1, 1, 1), 1477 }), 1478 Err(ServiceSourceLockError::InvalidService) 1479 ); 1480 } 1481 } 1482 1483 #[test] 1484 fn diagnostics_are_fixed_and_source_free() { 1485 let lock = canonical_deferred_vector(); 1486 let debug = format!("{lock:?}"); 1487 assert_eq!(debug, "ServiceSourceLockV2 { .. }"); 1488 for sensitive in [ 1489 "fixture_service", 1490 "radrootslabs", 1491 "1111111111111111111111111111111111111111", 1492 "2222222222222222222222222222222222222222222222222222222222222222", 1493 ] { 1494 assert!(!debug.contains(sensitive)); 1495 } 1496 let errors = [ 1497 ServiceSourceLockError::TooLarge, 1498 ServiceSourceLockError::Malformed, 1499 ServiceSourceLockError::Noncanonical, 1500 ServiceSourceLockError::InvalidFixedIdentity, 1501 ServiceSourceLockError::InvalidNixMaterial, 1502 ServiceSourceLockError::InvalidService, 1503 ServiceSourceLockError::InvalidRevision, 1504 ServiceSourceLockError::InvalidDigest, 1505 ServiceSourceLockError::InvalidToolchain, 1506 ServiceSourceLockError::InvalidFeatureProfile, 1507 ServiceSourceLockError::InvalidContractVersion, 1508 ]; 1509 for error in errors { 1510 assert!(error.source().is_none()); 1511 assert!(!error.to_string().contains("fixture_service")); 1512 } 1513 let mut codes = errors.map(ServiceSourceLockError::code); 1514 codes.sort_unstable(); 1515 assert_eq!(codes, ERROR_CODES); 1516 } 1517 }