build_info.rs (18361B)
1 //! Deterministic build identity shared by hardened services. 2 3 use core::fmt; 4 5 use serde::Serialize; 6 7 /// Environment variable containing the consuming service's exact source revision. 8 pub const SERVICE_REVISION_ENV: &str = "RADROOTS_SERVICE_REVISION"; 9 /// Environment variable containing the exact Radroots Lib revision. 10 pub const LIB_REVISION_ENV: &str = "RADROOTS_LIB_REVISION"; 11 /// Environment variable containing the exact governed Rust version. 12 pub const RUST_VERSION_ENV: &str = "RADROOTS_RUST_VERSION"; 13 /// Environment variable containing the exact Rust target triple. 14 pub const BUILD_TARGET_ENV: &str = "RADROOTS_BUILD_TARGET"; 15 16 /// Maximum byte length of service versions, Rust versions, targets, and feature profiles. 17 pub const BUILD_INFO_TEXT_MAX_BYTES: usize = 128; 18 const DEVELOPMENT_REVISION: &str = "0000000000000000000000000000000000000000"; 19 const DEVELOPMENT_VALUE: &str = "development"; 20 21 /// Determines whether missing compile-time metadata is an error. 22 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 23 pub enum BuildMode { 24 Development, 25 Release, 26 } 27 28 /// Exact versions for every service-host contract family. 29 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] 30 #[serde(deny_unknown_fields)] 31 pub struct ContractVersions { 32 config: u32, 33 state: u32, 34 admin: u32, 35 status: u32, 36 provider: u32, 37 } 38 39 impl ContractVersions { 40 /// Creates a contract-version cohort, rejecting zero as an unpublished identity. 41 pub fn new( 42 config: u32, 43 state: u32, 44 admin: u32, 45 status: u32, 46 provider: u32, 47 ) -> Result<Self, BuildInfoError> { 48 let versions = Self { 49 config, 50 state, 51 admin, 52 status, 53 provider, 54 }; 55 if [config, state, admin, status, provider].contains(&0) { 56 return Err(BuildInfoError::InvalidValue( 57 BuildInfoField::ContractVersion, 58 )); 59 } 60 Ok(versions) 61 } 62 63 #[must_use] 64 pub const fn config(self) -> u32 { 65 self.config 66 } 67 68 #[must_use] 69 pub const fn state(self) -> u32 { 70 self.state 71 } 72 73 #[must_use] 74 pub const fn admin(self) -> u32 { 75 self.admin 76 } 77 78 #[must_use] 79 pub const fn status(self) -> u32 { 80 self.status 81 } 82 83 #[must_use] 84 pub const fn provider(self) -> u32 { 85 self.provider 86 } 87 } 88 89 /// Compile-time strings captured by [`crate::compile_time_build_info!`]. 90 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 91 pub struct BuildInfoEnvironment<'a> { 92 pub service_version: Option<&'a str>, 93 pub service_commit: Option<&'a str>, 94 pub lib_revision: Option<&'a str>, 95 pub rust_version: Option<&'a str>, 96 pub target: Option<&'a str>, 97 pub feature_profile: Option<&'a str>, 98 pub contract_versions: ContractVersions, 99 } 100 101 /// A deterministic, timestamp-free service build identity. 102 #[derive(Clone, Debug, PartialEq, Eq, Serialize)] 103 #[serde(deny_unknown_fields)] 104 pub struct BuildInfo { 105 service_version: String, 106 service_commit: String, 107 lib_revision: String, 108 rust_version: String, 109 target: String, 110 feature_profile: String, 111 contract_versions: ContractVersions, 112 } 113 114 /// The exact build-information projection frozen by the service status contracts. 115 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] 116 #[serde(deny_unknown_fields)] 117 pub struct StatusBuildInfo<'a> { 118 version: &'a str, 119 revision: &'a str, 120 toolchain: &'a str, 121 contract_versions: ContractVersions, 122 } 123 124 impl BuildInfo { 125 /// Validates captured compile-time values and constructs one stable build identity. 126 pub fn from_compile_time( 127 mode: BuildMode, 128 environment: BuildInfoEnvironment<'_>, 129 ) -> Result<Self, BuildInfoError> { 130 let service_version = required_text( 131 mode, 132 environment.service_version, 133 "CARGO_PKG_VERSION", 134 BuildInfoField::ServiceVersion, 135 )?; 136 let service_commit = required_revision( 137 mode, 138 environment.service_commit, 139 SERVICE_REVISION_ENV, 140 BuildInfoField::ServiceCommit, 141 )?; 142 let lib_revision = required_revision( 143 mode, 144 environment.lib_revision, 145 LIB_REVISION_ENV, 146 BuildInfoField::LibRevision, 147 )?; 148 let rust_version = required_text( 149 mode, 150 environment.rust_version, 151 RUST_VERSION_ENV, 152 BuildInfoField::RustVersion, 153 )?; 154 let target = required_text( 155 mode, 156 environment.target, 157 BUILD_TARGET_ENV, 158 BuildInfoField::Target, 159 )?; 160 let feature_profile = required_text( 161 mode, 162 environment.feature_profile, 163 "feature_profile", 164 BuildInfoField::FeatureProfile, 165 )?; 166 167 Ok(Self { 168 service_version, 169 service_commit, 170 lib_revision, 171 rust_version, 172 target, 173 feature_profile, 174 contract_versions: environment.contract_versions, 175 }) 176 } 177 178 #[must_use] 179 pub fn service_version(&self) -> &str { 180 &self.service_version 181 } 182 183 #[must_use] 184 pub fn service_commit(&self) -> &str { 185 &self.service_commit 186 } 187 188 #[must_use] 189 pub fn lib_revision(&self) -> &str { 190 &self.lib_revision 191 } 192 193 #[must_use] 194 pub fn rust_version(&self) -> &str { 195 &self.rust_version 196 } 197 198 #[must_use] 199 pub fn target(&self) -> &str { 200 &self.target 201 } 202 203 #[must_use] 204 pub fn feature_profile(&self) -> &str { 205 &self.feature_profile 206 } 207 208 #[must_use] 209 pub const fn contract_versions(&self) -> ContractVersions { 210 self.contract_versions 211 } 212 213 /// Projects the complete identity into the frozen Myc/RHI status shape. 214 /// 215 /// The full Lib revision, target, and feature profile remain available on `BuildInfo` and are 216 /// bound by source-lock verification; the status contract intentionally exposes only these 217 /// four fields. 218 #[must_use] 219 pub fn status_projection(&self) -> StatusBuildInfo<'_> { 220 StatusBuildInfo { 221 version: &self.service_version, 222 revision: &self.service_commit, 223 toolchain: &self.rust_version, 224 contract_versions: self.contract_versions, 225 } 226 } 227 } 228 229 /// Identifies the invalid field without retaining rejected input. 230 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 231 pub enum BuildInfoField { 232 ServiceVersion, 233 ServiceCommit, 234 LibRevision, 235 RustVersion, 236 Target, 237 FeatureProfile, 238 ContractVersion, 239 } 240 241 impl fmt::Display for BuildInfoField { 242 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 243 formatter.write_str(match self { 244 Self::ServiceVersion => "service_version", 245 Self::ServiceCommit => "service_commit", 246 Self::LibRevision => "lib_revision", 247 Self::RustVersion => "rust_version", 248 Self::Target => "target", 249 Self::FeatureProfile => "feature_profile", 250 Self::ContractVersion => "contract_version", 251 }) 252 } 253 } 254 255 /// Validation failure for deterministic build metadata. 256 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 257 pub enum BuildInfoError { 258 MissingVariable(&'static str), 259 InvalidValue(BuildInfoField), 260 } 261 262 impl fmt::Display for BuildInfoError { 263 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 264 match self { 265 Self::MissingVariable(variable) => { 266 write!(formatter, "required build variable {variable} is missing") 267 } 268 Self::InvalidValue(field) => write!(formatter, "build field {field} is invalid"), 269 } 270 } 271 } 272 273 impl std::error::Error for BuildInfoError {} 274 275 fn required_text( 276 mode: BuildMode, 277 value: Option<&str>, 278 variable: &'static str, 279 field: BuildInfoField, 280 ) -> Result<String, BuildInfoError> { 281 match value { 282 Some(value) if valid_text(value) => Ok(value.to_owned()), 283 Some(_) => Err(BuildInfoError::InvalidValue(field)), 284 None if mode == BuildMode::Development => Ok(DEVELOPMENT_VALUE.to_owned()), 285 None => Err(BuildInfoError::MissingVariable(variable)), 286 } 287 } 288 289 fn required_revision( 290 mode: BuildMode, 291 value: Option<&str>, 292 variable: &'static str, 293 field: BuildInfoField, 294 ) -> Result<String, BuildInfoError> { 295 match value { 296 Some(value) 297 if valid_revision(value) 298 && (mode != BuildMode::Release || value != DEVELOPMENT_REVISION) => 299 { 300 Ok(value.to_owned()) 301 } 302 Some(_) => Err(BuildInfoError::InvalidValue(field)), 303 None if mode == BuildMode::Development => Ok(DEVELOPMENT_REVISION.to_owned()), 304 None => Err(BuildInfoError::MissingVariable(variable)), 305 } 306 } 307 308 fn valid_text(value: &str) -> bool { 309 let mut bytes = value.bytes(); 310 let Some(first) = bytes.next() else { 311 return false; 312 }; 313 value.len() <= BUILD_INFO_TEXT_MAX_BYTES 314 && first.is_ascii_alphanumeric() 315 && bytes 316 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-')) 317 } 318 319 fn valid_revision(value: &str) -> bool { 320 value.len() == 40 321 && value 322 .bytes() 323 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) 324 } 325 326 #[cfg(test)] 327 mod tests { 328 use super::*; 329 330 const SERVICE_REVISION: &str = "0123456789abcdef0123456789abcdef01234567"; 331 const LIB_REVISION: &str = "89abcdef0123456789abcdef0123456789abcdef"; 332 333 fn contracts() -> ContractVersions { 334 ContractVersions::new(1, 2, 3, 4, 5).expect("contract versions") 335 } 336 337 fn complete_environment() -> BuildInfoEnvironment<'static> { 338 BuildInfoEnvironment { 339 service_version: Some("0.1.0-alpha"), 340 service_commit: Some(SERVICE_REVISION), 341 lib_revision: Some(LIB_REVISION), 342 rust_version: Some("1.97.1"), 343 target: Some("x86_64-unknown-linux-gnu"), 344 feature_profile: Some("service-host"), 345 contract_versions: contracts(), 346 } 347 } 348 349 #[test] 350 fn serialization_is_an_exact_timestamp_free_snapshot() { 351 let build = BuildInfo::from_compile_time(BuildMode::Release, complete_environment()) 352 .expect("valid build information"); 353 let json = serde_json::to_string_pretty(&build).expect("serialize build information"); 354 355 assert_eq!( 356 json, 357 r#"{ 358 "service_version": "0.1.0-alpha", 359 "service_commit": "0123456789abcdef0123456789abcdef01234567", 360 "lib_revision": "89abcdef0123456789abcdef0123456789abcdef", 361 "rust_version": "1.97.1", 362 "target": "x86_64-unknown-linux-gnu", 363 "feature_profile": "service-host", 364 "contract_versions": { 365 "config": 1, 366 "state": 2, 367 "admin": 3, 368 "status": 4, 369 "provider": 5 370 } 371 }"# 372 ); 373 assert!(!json.contains("time")); 374 assert!(!json.contains("date")); 375 376 let status_json = serde_json::to_string_pretty(&build.status_projection()) 377 .expect("serialize status build information"); 378 assert_eq!( 379 status_json, 380 r#"{ 381 "version": "0.1.0-alpha", 382 "revision": "0123456789abcdef0123456789abcdef01234567", 383 "toolchain": "1.97.1", 384 "contract_versions": { 385 "config": 1, 386 "state": 2, 387 "admin": 3, 388 "status": 4, 389 "provider": 5 390 } 391 }"# 392 ); 393 } 394 395 #[test] 396 fn release_mode_fails_closed_for_every_missing_build_variable() { 397 let complete = complete_environment(); 398 let cases = [ 399 ( 400 BuildInfoEnvironment { 401 service_version: None, 402 ..complete 403 }, 404 "CARGO_PKG_VERSION", 405 ), 406 ( 407 BuildInfoEnvironment { 408 service_commit: None, 409 ..complete 410 }, 411 SERVICE_REVISION_ENV, 412 ), 413 ( 414 BuildInfoEnvironment { 415 lib_revision: None, 416 ..complete 417 }, 418 LIB_REVISION_ENV, 419 ), 420 ( 421 BuildInfoEnvironment { 422 rust_version: None, 423 ..complete 424 }, 425 RUST_VERSION_ENV, 426 ), 427 ( 428 BuildInfoEnvironment { 429 target: None, 430 ..complete 431 }, 432 BUILD_TARGET_ENV, 433 ), 434 ( 435 BuildInfoEnvironment { 436 feature_profile: None, 437 ..complete 438 }, 439 "feature_profile", 440 ), 441 ]; 442 443 for (environment, variable) in cases { 444 assert_eq!( 445 BuildInfo::from_compile_time(BuildMode::Release, environment), 446 Err(BuildInfoError::MissingVariable(variable)) 447 ); 448 } 449 } 450 451 #[test] 452 fn identical_inputs_are_equal_and_development_fallbacks_are_stable() { 453 let left = BuildInfo::from_compile_time(BuildMode::Release, complete_environment()) 454 .expect("left build information"); 455 let right = BuildInfo::from_compile_time(BuildMode::Release, complete_environment()) 456 .expect("right build information"); 457 assert_eq!(left, right); 458 459 let development = BuildInfo::from_compile_time( 460 BuildMode::Development, 461 BuildInfoEnvironment { 462 service_version: None, 463 service_commit: None, 464 lib_revision: None, 465 rust_version: None, 466 target: None, 467 feature_profile: None, 468 contract_versions: contracts(), 469 }, 470 ) 471 .expect("development fallbacks"); 472 assert_eq!(development.service_version(), DEVELOPMENT_VALUE); 473 assert_eq!(development.service_commit(), DEVELOPMENT_REVISION); 474 assert_eq!(development.lib_revision(), DEVELOPMENT_REVISION); 475 assert_eq!(development.rust_version(), DEVELOPMENT_VALUE); 476 assert_eq!(development.target(), DEVELOPMENT_VALUE); 477 assert_eq!(development.feature_profile(), DEVELOPMENT_VALUE); 478 } 479 480 #[test] 481 fn malformed_values_and_zero_contract_versions_are_rejected() { 482 for revision in [ 483 "short", 484 "ABCDEF0123456789abcdef0123456789abcdef01", 485 DEVELOPMENT_REVISION, 486 ] { 487 let mut environment = complete_environment(); 488 environment.service_commit = Some(revision); 489 assert_eq!( 490 BuildInfo::from_compile_time(BuildMode::Release, environment), 491 Err(BuildInfoError::InvalidValue(BuildInfoField::ServiceCommit)) 492 ); 493 494 let mut environment = complete_environment(); 495 environment.lib_revision = Some(revision); 496 assert_eq!( 497 BuildInfo::from_compile_time(BuildMode::Release, environment), 498 Err(BuildInfoError::InvalidValue(BuildInfoField::LibRevision)) 499 ); 500 } 501 assert_eq!( 502 ContractVersions::new(1, 1, 0, 1, 1), 503 Err(BuildInfoError::InvalidValue( 504 BuildInfoField::ContractVersion 505 )) 506 ); 507 508 for invalid in ["bad version", " rustc", ".1.97.1", "rustc/+nightly"] { 509 let mut environment = complete_environment(); 510 environment.service_version = Some(invalid); 511 assert_eq!( 512 BuildInfo::from_compile_time(BuildMode::Release, environment), 513 Err(BuildInfoError::InvalidValue(BuildInfoField::ServiceVersion)) 514 ); 515 516 let mut environment = complete_environment(); 517 environment.rust_version = Some(invalid); 518 assert_eq!( 519 BuildInfo::from_compile_time(BuildMode::Release, environment), 520 Err(BuildInfoError::InvalidValue(BuildInfoField::RustVersion)) 521 ); 522 } 523 } 524 525 #[test] 526 fn macro_captures_the_consuming_crate_version() { 527 let result = crate::compile_time_build_info!( 528 feature_profile: "service-host", 529 contract_versions: contracts(), 530 ); 531 532 if cfg!(debug_assertions) { 533 let build = result.expect("debug builds accept missing release variables"); 534 assert_eq!(build.service_version(), env!("CARGO_PKG_VERSION")); 535 assert_eq!(build.contract_versions(), contracts()); 536 } else { 537 assert_eq!( 538 result, 539 Err(BuildInfoError::MissingVariable(SERVICE_REVISION_ENV)) 540 ); 541 } 542 } 543 544 #[test] 545 fn public_accessors_and_text_boundaries_are_exact() { 546 let build = 547 BuildInfo::from_compile_time(BuildMode::Release, complete_environment()).unwrap(); 548 assert_eq!(build.service_commit(), SERVICE_REVISION); 549 assert_eq!(build.lib_revision(), LIB_REVISION); 550 assert_eq!(build.rust_version(), "1.97.1"); 551 assert_eq!(build.target(), "x86_64-unknown-linux-gnu"); 552 assert_eq!(build.feature_profile(), "service-host"); 553 assert_eq!(build.contract_versions().config(), 1); 554 assert_eq!(build.contract_versions().state(), 2); 555 assert_eq!(build.contract_versions().admin(), 3); 556 assert_eq!(build.contract_versions().status(), 4); 557 assert_eq!(build.contract_versions().provider(), 5); 558 559 assert!(!valid_text("")); 560 assert!(valid_text(&"a".repeat(BUILD_INFO_TEXT_MAX_BYTES))); 561 assert!(!valid_text(&"a".repeat(BUILD_INFO_TEXT_MAX_BYTES + 1))); 562 assert!(valid_text("a.b_c:d-e")); 563 assert!(!valid_text("a/b")); 564 565 let mut environment = complete_environment(); 566 environment.service_commit = Some(DEVELOPMENT_REVISION); 567 environment.lib_revision = Some(DEVELOPMENT_REVISION); 568 assert!(BuildInfo::from_compile_time(BuildMode::Development, environment).is_ok()); 569 570 for error in [ 571 BuildInfoError::InvalidValue(BuildInfoField::Target), 572 BuildInfoError::MissingVariable(BUILD_TARGET_ENV), 573 ] { 574 assert!(!error.to_string().is_empty()); 575 assert!(std::error::Error::source(&error).is_none()); 576 } 577 } 578 }