lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

service_artifact.rs (10139B)


      1 use std::collections::BTreeMap;
      2 
      3 use radroots_runtime_paths::ServiceId;
      4 use serde::{Deserialize, Deserializer};
      5 
      6 use crate::service::ServiceTier1Target;
      7 
      8 const OUTPUT_INVENTORY: [&str; 12] = [
      9     "LICENSE",
     10     "SHA256SUMS",
     11     "THIRD-PARTY-NOTICES.txt",
     12     "artifact-manifest.v1.json",
     13     "binary.tar.gz",
     14     "config.example.toml",
     15     "config.schema.json",
     16     "provenance-input.v1.json",
     17     "radroots.service.source-lock.v2.toml",
     18     "sbom.cdx.json",
     19     "service-source.tar.gz",
     20     "systemd.service",
     21 ];
     22 
     23 #[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
     24 #[serde(rename_all = "snake_case")]
     25 pub enum ServiceArtifactChannel {
     26     Stable,
     27 }
     28 
     29 impl ServiceArtifactChannel {
     30     #[must_use]
     31     pub const fn as_str(self) -> &'static str {
     32         match self {
     33             Self::Stable => "stable",
     34         }
     35     }
     36 }
     37 
     38 /// A validated lowercase SHA-256 value.
     39 #[derive(Clone, Copy, PartialEq, Eq)]
     40 pub struct ServiceArtifactSha256([u8; 32]);
     41 
     42 impl ServiceArtifactSha256 {
     43     #[must_use]
     44     pub const fn from_bytes(bytes: [u8; 32]) -> Self {
     45         Self(bytes)
     46     }
     47     #[must_use]
     48     pub const fn as_bytes(&self) -> &[u8; 32] {
     49         &self.0
     50     }
     51 }
     52 
     53 impl core::fmt::Debug for ServiceArtifactSha256 {
     54     fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
     55         formatter.write_str("ServiceArtifactSha256(<redacted>)")
     56     }
     57 }
     58 
     59 impl<'de> Deserialize<'de> for ServiceArtifactSha256 {
     60     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
     61     where
     62         D: Deserializer<'de>,
     63     {
     64         let value = String::deserialize(deserializer)?;
     65         if value.len() != 64
     66             || !value
     67                 .bytes()
     68                 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
     69         {
     70             return Err(serde::de::Error::custom("invalid sha256"));
     71         }
     72         let mut bytes = [0_u8; 32];
     73         for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
     74             bytes[index] = (hex_nibble(pair[0])
     75                 .ok_or_else(|| serde::de::Error::custom("invalid sha256"))?
     76                 << 4)
     77                 | hex_nibble(pair[1]).ok_or_else(|| serde::de::Error::custom("invalid sha256"))?;
     78         }
     79         Ok(Self(bytes))
     80     }
     81 }
     82 
     83 fn hex_nibble(byte: u8) -> Option<u8> {
     84     match byte {
     85         b'0'..=b'9' => Some(byte - b'0'),
     86         b'a'..=b'f' => Some(byte - b'a' + 10),
     87         _ => None,
     88     }
     89 }
     90 
     91 #[derive(Clone, Deserialize, PartialEq, Eq)]
     92 #[serde(try_from = "HardenedServiceArtifactWire")]
     93 pub struct HardenedServiceArtifact {
     94     service_id: ServiceId,
     95     service_revision: String,
     96     release_contract: String,
     97     release_contract_sha256: ServiceArtifactSha256,
     98     source_lock_sha256: ServiceArtifactSha256,
     99     package_name: String,
    100     binary_name: String,
    101     version: String,
    102     channel: ServiceArtifactChannel,
    103     binary_archive_name: String,
    104     artifact_manifest_name: String,
    105     checksums_name: String,
    106     output_inventory: Vec<String>,
    107     tier_1_targets: Vec<ServiceTier1Target>,
    108 }
    109 
    110 impl core::fmt::Debug for HardenedServiceArtifact {
    111     fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
    112         formatter.write_str("HardenedServiceArtifact(<redacted>)")
    113     }
    114 }
    115 
    116 #[derive(Deserialize)]
    117 #[serde(deny_unknown_fields)]
    118 struct HardenedServiceArtifactWire {
    119     service_id: ServiceId,
    120     service_revision: String,
    121     release_contract: String,
    122     release_contract_sha256: ServiceArtifactSha256,
    123     source_lock_sha256: ServiceArtifactSha256,
    124     package_name: String,
    125     binary_name: String,
    126     version: String,
    127     channel: ServiceArtifactChannel,
    128     binary_archive_name: String,
    129     artifact_manifest_name: String,
    130     checksums_name: String,
    131     output_inventory: Vec<String>,
    132     tier_1_targets: Vec<ServiceTier1Target>,
    133 }
    134 
    135 impl HardenedServiceArtifact {
    136     #[must_use]
    137     pub fn service_id(&self) -> &ServiceId {
    138         &self.service_id
    139     }
    140     #[must_use]
    141     pub fn service_revision(&self) -> &str {
    142         &self.service_revision
    143     }
    144     #[must_use]
    145     pub fn release_contract(&self) -> &str {
    146         &self.release_contract
    147     }
    148     #[must_use]
    149     pub const fn release_contract_sha256(&self) -> ServiceArtifactSha256 {
    150         self.release_contract_sha256
    151     }
    152     #[must_use]
    153     pub const fn source_lock_sha256(&self) -> ServiceArtifactSha256 {
    154         self.source_lock_sha256
    155     }
    156     #[must_use]
    157     pub fn package_name(&self) -> &str {
    158         &self.package_name
    159     }
    160     #[must_use]
    161     pub fn binary_name(&self) -> &str {
    162         &self.binary_name
    163     }
    164     #[must_use]
    165     pub fn version(&self) -> &str {
    166         &self.version
    167     }
    168     #[must_use]
    169     pub const fn channel(&self) -> ServiceArtifactChannel {
    170         self.channel
    171     }
    172     #[must_use]
    173     pub fn binary_archive_name(&self) -> &str {
    174         &self.binary_archive_name
    175     }
    176     #[must_use]
    177     pub fn artifact_manifest_name(&self) -> &str {
    178         &self.artifact_manifest_name
    179     }
    180     #[must_use]
    181     pub fn checksums_name(&self) -> &str {
    182         &self.checksums_name
    183     }
    184     #[must_use]
    185     pub fn output_inventory(&self) -> &[String] {
    186         &self.output_inventory
    187     }
    188     #[must_use]
    189     pub fn tier_1_targets(&self) -> &[ServiceTier1Target] {
    190         &self.tier_1_targets
    191     }
    192 
    193     fn has_exact_contract(&self) -> bool {
    194         let (revision, release_contract, release_hash, source_lock_hash) =
    195             match self.service_id.as_str() {
    196                 "myc" => (
    197                     "77b381648ed1e586efb696888beb05b9215c69cf",
    198                     "contracts/services_hardening/native_release.v2.json",
    199                     "4b3ba5789fac6aa219e84e1e5c002cf8230b72f95fd6d95a6419d2fdf2915f83",
    200                     "f5ebb390a480830d51d502facc623bd1b10eda27b12dad9f3dbb6a1f1f949217",
    201                 ),
    202                 "rhi" => (
    203                     "07aa6ea988da5372654bb3d1ee183ac099a77cae",
    204                     "contracts/services_hardening/native_release.v1.json",
    205                     "06a973176b4b8c11dad13000604576527df829dd0bbe2f501158662f75e70b94",
    206                     "3cc8bfac0d98730937754abae2ccfe20e40d0a9bbdefe02ebd94264c20f0d0ff",
    207                 ),
    208                 _ => return false,
    209             };
    210         self.service_revision == revision
    211             && self.release_contract == release_contract
    212             && self.release_contract_sha256 == sha256_literal(release_hash)
    213             && self.source_lock_sha256 == sha256_literal(source_lock_hash)
    214             && self.package_name == self.service_id.as_str()
    215             && self.binary_name == self.service_id.as_str()
    216             && self.version == "0.1.0"
    217             && self.channel == ServiceArtifactChannel::Stable
    218             && self.binary_archive_name == "binary.tar.gz"
    219             && self.artifact_manifest_name == "artifact-manifest.v1.json"
    220             && self.checksums_name == "SHA256SUMS"
    221             && self
    222                 .output_inventory
    223                 .iter()
    224                 .map(String::as_str)
    225                 .eq(OUTPUT_INVENTORY)
    226             && self.tier_1_targets == ServiceTier1Target::ALL
    227     }
    228 }
    229 
    230 fn sha256_literal(value: &str) -> ServiceArtifactSha256 {
    231     let mut bytes = [0_u8; 32];
    232     for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
    233         bytes[index] = (hex_nibble(pair[0]).expect("literal sha256") << 4)
    234             | hex_nibble(pair[1]).expect("literal sha256");
    235     }
    236     ServiceArtifactSha256(bytes)
    237 }
    238 
    239 impl TryFrom<HardenedServiceArtifactWire> for HardenedServiceArtifact {
    240     type Error = &'static str;
    241 
    242     fn try_from(wire: HardenedServiceArtifactWire) -> Result<Self, Self::Error> {
    243         let artifact = Self {
    244             service_id: wire.service_id,
    245             service_revision: wire.service_revision,
    246             release_contract: wire.release_contract,
    247             release_contract_sha256: wire.release_contract_sha256,
    248             source_lock_sha256: wire.source_lock_sha256,
    249             package_name: wire.package_name,
    250             binary_name: wire.binary_name,
    251             version: wire.version,
    252             channel: wire.channel,
    253             binary_archive_name: wire.binary_archive_name,
    254             artifact_manifest_name: wire.artifact_manifest_name,
    255             checksums_name: wire.checksums_name,
    256             output_inventory: wire.output_inventory,
    257             tier_1_targets: wire.tier_1_targets,
    258         };
    259         artifact
    260             .has_exact_contract()
    261             .then_some(artifact)
    262             .ok_or("invalid hardened service artifact")
    263     }
    264 }
    265 
    266 #[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
    267 #[serde(try_from = "BTreeMap<String, HardenedServiceArtifact>")]
    268 pub struct HardenedServiceArtifacts(BTreeMap<String, HardenedServiceArtifact>);
    269 
    270 impl HardenedServiceArtifacts {
    271     #[must_use]
    272     pub fn get(&self, service_id: &ServiceId) -> Option<&HardenedServiceArtifact> {
    273         self.0.get(service_id.as_str())
    274     }
    275     pub fn iter(&self) -> impl ExactSizeIterator<Item = (&str, &HardenedServiceArtifact)> {
    276         self.0.iter().map(|(key, value)| (key.as_str(), value))
    277     }
    278     #[must_use]
    279     pub fn len(&self) -> usize {
    280         self.0.len()
    281     }
    282     #[must_use]
    283     pub fn is_empty(&self) -> bool {
    284         self.0.is_empty()
    285     }
    286 }
    287 
    288 impl TryFrom<BTreeMap<String, HardenedServiceArtifact>> for HardenedServiceArtifacts {
    289     type Error = &'static str;
    290 
    291     fn try_from(artifacts: BTreeMap<String, HardenedServiceArtifact>) -> Result<Self, Self::Error> {
    292         if artifacts.len() != 2 {
    293             return Err("service artifact inventory must contain exactly Myc and RHI");
    294         }
    295         for service in ["myc", "rhi"] {
    296             let artifact = artifacts
    297                 .get(service)
    298                 .ok_or("service artifact inventory is incomplete")?;
    299             if artifact.service_id.as_str() != service || !artifact.has_exact_contract() {
    300                 return Err("service artifact inventory is invalid");
    301             }
    302         }
    303         if artifacts
    304             .iter()
    305             .any(|(key, artifact)| key != artifact.service_id.as_str())
    306         {
    307             return Err("service artifact key mismatch");
    308         }
    309         Ok(Self(artifacts))
    310     }
    311 }