lib

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

private_artifact.rs (37411B)


      1 //! Protected private-artifact metadata contracts.
      2 //!
      3 //! These contracts intentionally contain neither plaintext nor ciphertext.
      4 //! Encryption, key access, and backend schema remain implementation details.
      5 
      6 use core::fmt;
      7 use radroots_transport::BoxFuture;
      8 use sha2::{Digest, Sha256};
      9 
     10 use crate::Error;
     11 
     12 pub const ARTIFACT_KIND_MAX_BYTES: usize = 96;
     13 pub const ARTIFACT_SCHEMA_MAX_BYTES: usize = 128;
     14 pub const SECRET_PROVIDER_MAX_BYTES: usize = 64;
     15 pub const SECRET_REFERENCE_MAX_BYTES: usize = 512;
     16 pub const EXPIRED_ARTIFACT_QUERY_LIMIT_MAX: u16 = 256;
     17 pub const PRIVATE_ARTIFACT_ENVELOPE_PURPOSE_PREFIX: &str = "radroots.private_artifact.";
     18 pub const PRIVATE_ARTIFACT_ENVELOPE_SUBJECT_TYPE: &str = "private_artifact";
     19 const ENVELOPE_CONTEXT_DOMAIN: &[u8] = b"radroots.envelope_context.v1";
     20 const ENVELOPE_CONTEXT_VERSION: u16 = 1;
     21 
     22 #[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
     23 pub struct PrivateArtifactId([u8; 16]);
     24 
     25 impl PrivateArtifactId {
     26     pub const fn new(bytes: [u8; 16]) -> Result<Self, Error> {
     27         if bytes_are_zero(&bytes) {
     28             return Err(Error::InvalidPrivateArtifactId);
     29         }
     30         Ok(Self(bytes))
     31     }
     32     pub const fn as_bytes(&self) -> &[u8; 16] {
     33         &self.0
     34     }
     35 }
     36 
     37 impl fmt::Debug for PrivateArtifactId {
     38     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
     39         formatter.write_str("PrivateArtifactId(<redacted>)")
     40     }
     41 }
     42 
     43 #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
     44 pub struct ArtifactKind(String);
     45 
     46 impl ArtifactKind {
     47     pub fn parse(value: impl Into<String>) -> Result<Self, Error> {
     48         let value = value.into();
     49         if !valid_namespaced(value.as_str(), ARTIFACT_KIND_MAX_BYTES, 2) {
     50             return Err(Error::InvalidPrivateArtifactKind);
     51         }
     52         Ok(Self(value))
     53     }
     54     pub fn as_str(&self) -> &str {
     55         self.0.as_str()
     56     }
     57 }
     58 
     59 #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
     60 pub struct ArtifactSchemaId(String);
     61 
     62 impl ArtifactSchemaId {
     63     pub fn parse(value: impl Into<String>) -> Result<Self, Error> {
     64         let value = value.into();
     65         if !valid_schema(value.as_str()) {
     66             return Err(Error::InvalidPrivateArtifactSchema);
     67         }
     68         Ok(Self(value))
     69     }
     70     pub fn as_str(&self) -> &str {
     71         self.0.as_str()
     72     }
     73 }
     74 
     75 /// Opaque envelope context derived only from immutable artifact metadata.
     76 #[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
     77 pub struct PrivateArtifactEnvelopeContext {
     78     purpose: String,
     79     subject_type: &'static str,
     80     subject: String,
     81     payload_schema: String,
     82 }
     83 
     84 impl PrivateArtifactEnvelopeContext {
     85     fn derive(
     86         artifact_id: PrivateArtifactId,
     87         kind: &ArtifactKind,
     88         schema_id: &ArtifactSchemaId,
     89     ) -> Self {
     90         Self {
     91             purpose: format!(
     92                 "{PRIVATE_ARTIFACT_ENVELOPE_PURPOSE_PREFIX}{}",
     93                 kind.as_str()
     94             ),
     95             subject_type: PRIVATE_ARTIFACT_ENVELOPE_SUBJECT_TYPE,
     96             subject: hex_artifact_id(artifact_id),
     97             payload_schema: schema_id.as_str().to_owned(),
     98         }
     99     }
    100 
    101     pub fn purpose(&self) -> &str {
    102         self.purpose.as_str()
    103     }
    104     pub const fn subject_type(&self) -> &'static str {
    105         self.subject_type
    106     }
    107     pub fn subject(&self) -> &str {
    108         self.subject.as_str()
    109     }
    110     pub fn payload_schema(&self) -> &str {
    111         self.payload_schema.as_str()
    112     }
    113     pub fn fingerprint(&self) -> [u8; 32] {
    114         Sha256::digest(self.canonical_bytes()).into()
    115     }
    116 
    117     fn canonical_bytes(&self) -> Vec<u8> {
    118         let mut encoded = Vec::new();
    119         encoded.extend_from_slice(&ENVELOPE_CONTEXT_VERSION.to_be_bytes());
    120         encoded.extend_from_slice(ENVELOPE_CONTEXT_DOMAIN);
    121         for value in [
    122             self.purpose.as_bytes(),
    123             self.subject_type.as_bytes(),
    124             self.subject.as_bytes(),
    125             self.payload_schema.as_bytes(),
    126         ] {
    127             let length = u16::try_from(value.len())
    128                 .expect("validated private-artifact envelope context fits u16");
    129             encoded.extend_from_slice(&length.to_be_bytes());
    130             encoded.extend_from_slice(value);
    131         }
    132         encoded
    133     }
    134 }
    135 
    136 impl fmt::Debug for PrivateArtifactEnvelopeContext {
    137     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    138         formatter
    139             .debug_struct("PrivateArtifactEnvelopeContext")
    140             .field("purpose", &"<derived>")
    141             .field("subject_type", &self.subject_type)
    142             .field("subject", &"<redacted>")
    143             .field("payload_schema", &"<derived>")
    144             .finish()
    145     }
    146 }
    147 
    148 /// SHA-256 commitment to the exact protected representation.
    149 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    150 #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
    151 pub struct ArtifactCommitment([u8; 32]);
    152 
    153 impl ArtifactCommitment {
    154     pub const fn new(bytes: [u8; 32]) -> Self {
    155         Self(bytes)
    156     }
    157     pub const fn as_bytes(&self) -> &[u8; 32] {
    158         &self.0
    159     }
    160 }
    161 
    162 /// Persistable provider reference metadata, never a secret capability itself.
    163 #[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
    164 pub struct DurableSecretReference {
    165     provider: String,
    166     opaque_reference: String,
    167     key_version: u32,
    168 }
    169 
    170 impl DurableSecretReference {
    171     pub fn new(
    172         provider: impl Into<String>,
    173         opaque_reference: impl Into<String>,
    174         key_version: u32,
    175     ) -> Result<Self, Error> {
    176         let provider = provider.into();
    177         let opaque_reference = opaque_reference.into();
    178         if !valid_label(provider.as_str(), SECRET_PROVIDER_MAX_BYTES)
    179             || opaque_reference.is_empty()
    180             || opaque_reference.len() > SECRET_REFERENCE_MAX_BYTES
    181             || opaque_reference != opaque_reference.trim()
    182             || opaque_reference.chars().any(char::is_control)
    183             || key_version == 0
    184         {
    185             return Err(Error::InvalidPrivateArtifactSecretReference);
    186         }
    187         Ok(Self {
    188             provider,
    189             opaque_reference,
    190             key_version,
    191         })
    192     }
    193     pub fn provider(&self) -> &str {
    194         self.provider.as_str()
    195     }
    196     pub fn opaque_reference(&self) -> &str {
    197         self.opaque_reference.as_str()
    198     }
    199     pub const fn key_version(&self) -> u32 {
    200         self.key_version
    201     }
    202 }
    203 
    204 impl fmt::Debug for DurableSecretReference {
    205     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    206         formatter
    207             .debug_struct("DurableSecretReference")
    208             .field("provider", &self.provider)
    209             .field("opaque_reference", &"[REDACTED]")
    210             .field("key_version", &self.key_version)
    211             .finish()
    212     }
    213 }
    214 
    215 #[cfg(feature = "serde")]
    216 impl serde::Serialize for DurableSecretReference {
    217     fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    218     where
    219         S: serde::Serializer,
    220     {
    221         use serde::ser::SerializeStruct;
    222         let mut state = serializer.serialize_struct("DurableSecretReference", 3)?;
    223         state.serialize_field("provider", &self.provider)?;
    224         state.serialize_field("opaque_reference", &self.opaque_reference)?;
    225         state.serialize_field("key_version", &self.key_version)?;
    226         state.end()
    227     }
    228 }
    229 
    230 #[cfg(feature = "serde")]
    231 impl<'de> serde::Deserialize<'de> for DurableSecretReference {
    232     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    233     where
    234         D: serde::Deserializer<'de>,
    235     {
    236         #[derive(serde::Deserialize)]
    237         #[serde(deny_unknown_fields)]
    238         struct Wire {
    239             provider: String,
    240             opaque_reference: String,
    241             key_version: u32,
    242         }
    243         let wire = Wire::deserialize(deserializer)?;
    244         Self::new(wire.provider, wire.opaque_reference, wire.key_version)
    245             .map_err(serde::de::Error::custom)
    246     }
    247 }
    248 
    249 /// Minimum retention and optional automatic expiry policy.
    250 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
    251 pub struct RetentionPolicy {
    252     delete_not_before_unix_ms: Option<u64>,
    253     expires_at_unix_ms: Option<u64>,
    254 }
    255 
    256 impl RetentionPolicy {
    257     pub const fn indefinite() -> Self {
    258         Self {
    259             delete_not_before_unix_ms: None,
    260             expires_at_unix_ms: None,
    261         }
    262     }
    263     pub const fn new(
    264         delete_not_before_unix_ms: Option<u64>,
    265         expires_at_unix_ms: Option<u64>,
    266     ) -> Result<Self, Error> {
    267         if matches!(delete_not_before_unix_ms, Some(0)) || matches!(expires_at_unix_ms, Some(0)) {
    268             return Err(Error::InvalidPrivateArtifactRetention);
    269         }
    270         Ok(Self {
    271             delete_not_before_unix_ms,
    272             expires_at_unix_ms,
    273         })
    274     }
    275     pub const fn delete_not_before_unix_ms(self) -> Option<u64> {
    276         self.delete_not_before_unix_ms
    277     }
    278     pub const fn expires_at_unix_ms(self) -> Option<u64> {
    279         self.expires_at_unix_ms
    280     }
    281     pub const fn is_expired_at(self, unix_ms: u64) -> bool {
    282         matches!(self.expires_at_unix_ms, Some(expires) if unix_ms >= expires)
    283     }
    284     pub const fn permits_deletion_at(self, unix_ms: u64) -> bool {
    285         !matches!(self.delete_not_before_unix_ms, Some(not_before) if unix_ms < not_before)
    286     }
    287 }
    288 
    289 #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
    290 pub struct PrivateArtifactRevision(u64);
    291 
    292 impl PrivateArtifactRevision {
    293     pub const INITIAL: Self = Self(1);
    294     pub const fn new(value: u64) -> Result<Self, Error> {
    295         if value == 0 {
    296             Err(Error::InvalidPrivateArtifactRevision)
    297         } else {
    298             Ok(Self(value))
    299         }
    300     }
    301     pub const fn get(self) -> u64 {
    302         self.0
    303     }
    304     fn next(self) -> Result<Self, Error> {
    305         self.0
    306             .checked_add(1)
    307             .map(Self)
    308             .ok_or(Error::CorruptPrivateArtifactMetadata)
    309     }
    310 }
    311 
    312 /// Host-generated idempotency identity for one reseal commit.
    313 #[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
    314 pub struct PrivateArtifactResealId([u8; 16]);
    315 
    316 impl PrivateArtifactResealId {
    317     pub const fn new(bytes: [u8; 16]) -> Result<Self, Error> {
    318         if bytes_are_zero(&bytes) {
    319             return Err(Error::InvalidPrivateArtifactResealId);
    320         }
    321         Ok(Self(bytes))
    322     }
    323     pub const fn as_bytes(&self) -> &[u8; 16] {
    324         &self.0
    325     }
    326 }
    327 
    328 impl fmt::Debug for PrivateArtifactResealId {
    329     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    330         formatter.write_str("PrivateArtifactResealId(<redacted>)")
    331     }
    332 }
    333 
    334 /// Backend-neutral metadata fence for one atomic envelope reseal.
    335 #[derive(Clone, Eq, PartialEq)]
    336 pub struct PrivateArtifactResealRequest {
    337     reseal_id: PrivateArtifactResealId,
    338     artifact_id: PrivateArtifactId,
    339     expected_revision: PrivateArtifactRevision,
    340     expected_commitment: ArtifactCommitment,
    341     next_commitment: ArtifactCommitment,
    342     next_protected_size_bytes: u64,
    343     next_secret_reference: DurableSecretReference,
    344     committed_at_unix_ms: u64,
    345 }
    346 
    347 impl fmt::Debug for PrivateArtifactResealRequest {
    348     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    349         formatter
    350             .debug_struct("PrivateArtifactResealRequest")
    351             .field("reseal_id", &self.reseal_id)
    352             .field("artifact_id", &self.artifact_id)
    353             .field("expected_revision", &self.expected_revision)
    354             .field("expected_commitment", &"<commitment>")
    355             .field("next_commitment", &"<commitment>")
    356             .field("next_protected_size_bytes", &self.next_protected_size_bytes)
    357             .field("next_secret_reference", &self.next_secret_reference)
    358             .field("committed_at_unix_ms", &self.committed_at_unix_ms)
    359             .finish()
    360     }
    361 }
    362 
    363 impl PrivateArtifactResealRequest {
    364     #[allow(clippy::too_many_arguments)]
    365     pub fn new(
    366         reseal_id: PrivateArtifactResealId,
    367         artifact_id: PrivateArtifactId,
    368         expected_revision: PrivateArtifactRevision,
    369         expected_commitment: ArtifactCommitment,
    370         next_commitment: ArtifactCommitment,
    371         next_protected_size_bytes: u64,
    372         next_secret_reference: DurableSecretReference,
    373         committed_at_unix_ms: u64,
    374     ) -> Result<Self, Error> {
    375         if expected_commitment == next_commitment
    376             || next_protected_size_bytes == 0
    377             || committed_at_unix_ms == 0
    378         {
    379             return Err(Error::InvalidPrivateArtifactResealRequest);
    380         }
    381         Ok(Self {
    382             reseal_id,
    383             artifact_id,
    384             expected_revision,
    385             expected_commitment,
    386             next_commitment,
    387             next_protected_size_bytes,
    388             next_secret_reference,
    389             committed_at_unix_ms,
    390         })
    391     }
    392 
    393     pub const fn reseal_id(&self) -> PrivateArtifactResealId {
    394         self.reseal_id
    395     }
    396     pub const fn artifact_id(&self) -> PrivateArtifactId {
    397         self.artifact_id
    398     }
    399     pub const fn expected_revision(&self) -> PrivateArtifactRevision {
    400         self.expected_revision
    401     }
    402     pub const fn expected_commitment(&self) -> ArtifactCommitment {
    403         self.expected_commitment
    404     }
    405     pub const fn next_commitment(&self) -> ArtifactCommitment {
    406         self.next_commitment
    407     }
    408     pub const fn next_protected_size_bytes(&self) -> u64 {
    409         self.next_protected_size_bytes
    410     }
    411     pub const fn next_secret_reference(&self) -> &DurableSecretReference {
    412         &self.next_secret_reference
    413     }
    414     pub const fn committed_at_unix_ms(&self) -> u64 {
    415         self.committed_at_unix_ms
    416     }
    417     pub fn fingerprint(&self) -> [u8; 32] {
    418         let mut hasher = Sha256::new();
    419         hasher.update(self.reseal_id.as_bytes());
    420         hasher.update(self.artifact_id.as_bytes());
    421         hasher.update(self.expected_revision.get().to_be_bytes());
    422         hasher.update(self.expected_commitment.as_bytes());
    423         hasher.update(self.next_commitment.as_bytes());
    424         hasher.update(self.next_protected_size_bytes.to_be_bytes());
    425         hash_string(&mut hasher, self.next_secret_reference.provider());
    426         hash_string(&mut hasher, self.next_secret_reference.opaque_reference());
    427         hasher.update(self.next_secret_reference.key_version().to_be_bytes());
    428         hasher.update(self.committed_at_unix_ms.to_be_bytes());
    429         hasher.finalize().into()
    430     }
    431 }
    432 
    433 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    434 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
    435 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
    436 pub enum PrivateArtifactResealDisposition {
    437     Committed,
    438     Replayed,
    439 }
    440 
    441 /// Durable receipt used to distinguish exact replay from conflicting reuse.
    442 #[derive(Clone, Copy, Eq, PartialEq)]
    443 pub struct PrivateArtifactResealReceipt {
    444     reseal_id: PrivateArtifactResealId,
    445     artifact_id: PrivateArtifactId,
    446     committed_revision: PrivateArtifactRevision,
    447     request_fingerprint: [u8; 32],
    448     disposition: PrivateArtifactResealDisposition,
    449 }
    450 
    451 impl PrivateArtifactResealReceipt {
    452     pub fn committed(
    453         request: &PrivateArtifactResealRequest,
    454         committed_revision: PrivateArtifactRevision,
    455     ) -> Self {
    456         Self {
    457             reseal_id: request.reseal_id,
    458             artifact_id: request.artifact_id,
    459             committed_revision,
    460             request_fingerprint: request.fingerprint(),
    461             disposition: PrivateArtifactResealDisposition::Committed,
    462         }
    463     }
    464 
    465     pub fn replay(&self, request: &PrivateArtifactResealRequest) -> Result<Self, Error> {
    466         if self.reseal_id != request.reseal_id
    467             || self.artifact_id != request.artifact_id
    468             || self.request_fingerprint != request.fingerprint()
    469         {
    470             return Err(Error::PrivateArtifactResealConflict);
    471         }
    472         Ok(Self {
    473             disposition: PrivateArtifactResealDisposition::Replayed,
    474             ..*self
    475         })
    476     }
    477 
    478     pub const fn reseal_id(self) -> PrivateArtifactResealId {
    479         self.reseal_id
    480     }
    481     pub const fn artifact_id(self) -> PrivateArtifactId {
    482         self.artifact_id
    483     }
    484     pub const fn committed_revision(self) -> PrivateArtifactRevision {
    485         self.committed_revision
    486     }
    487     pub const fn disposition(self) -> PrivateArtifactResealDisposition {
    488         self.disposition
    489     }
    490 
    491     pub const fn request_fingerprint(self) -> [u8; 32] {
    492         self.request_fingerprint
    493     }
    494 }
    495 
    496 impl fmt::Debug for PrivateArtifactResealReceipt {
    497     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    498         formatter
    499             .debug_struct("PrivateArtifactResealReceipt")
    500             .field("reseal_id", &self.reseal_id)
    501             .field("artifact_id", &self.artifact_id)
    502             .field("committed_revision", &self.committed_revision)
    503             .field("request_fingerprint", &"<commitment>")
    504             .field("disposition", &self.disposition)
    505             .finish()
    506     }
    507 }
    508 
    509 /// Bounded migration inventory without artifact or user identity.
    510 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
    511 pub struct PrivateArtifactEnvelopeMigrationStatus {
    512     pub v1_pending: u64,
    513     pub v2_current: u64,
    514     pub corrupt: u64,
    515     pub blocked_provider: u64,
    516     pub conflicted: u64,
    517 }
    518 
    519 impl PrivateArtifactEnvelopeMigrationStatus {
    520     pub fn total(self) -> Option<u64> {
    521         self.v1_pending
    522             .checked_add(self.v2_current)?
    523             .checked_add(self.corrupt)?
    524             .checked_add(self.blocked_provider)?
    525             .checked_add(self.conflicted)
    526     }
    527 }
    528 
    529 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    530 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
    531 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
    532 pub enum PrivateArtifactStage {
    533     Active,
    534     Expired,
    535     Tombstoned,
    536 }
    537 
    538 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    539 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
    540 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
    541 pub enum DeletionReason {
    542     UserRequested,
    543     RetentionExpired,
    544     KeyRevoked,
    545     IntegrityFailure,
    546     OperatorRequested,
    547 }
    548 
    549 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    550 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
    551 pub struct ArtifactTombstone {
    552     deleted_at_unix_ms: u64,
    553     reason: DeletionReason,
    554     commitment: ArtifactCommitment,
    555 }
    556 
    557 impl ArtifactTombstone {
    558     pub const fn deleted_at_unix_ms(self) -> u64 {
    559         self.deleted_at_unix_ms
    560     }
    561     pub const fn reason(self) -> DeletionReason {
    562         self.reason
    563     }
    564     pub const fn commitment(self) -> ArtifactCommitment {
    565         self.commitment
    566     }
    567 }
    568 
    569 /// Metadata for one protected artifact; no protected bytes are present.
    570 #[derive(Clone, Debug, Eq, PartialEq)]
    571 pub struct PrivateArtifactMetadata {
    572     artifact_id: PrivateArtifactId,
    573     kind: ArtifactKind,
    574     schema_id: ArtifactSchemaId,
    575     commitment: ArtifactCommitment,
    576     protected_size_bytes: u64,
    577     secret_reference: DurableSecretReference,
    578     retention: RetentionPolicy,
    579     revision: PrivateArtifactRevision,
    580     stage: PrivateArtifactStage,
    581     created_at_unix_ms: u64,
    582     updated_at_unix_ms: u64,
    583     tombstone: Option<ArtifactTombstone>,
    584 }
    585 
    586 impl PrivateArtifactMetadata {
    587     #[allow(clippy::too_many_arguments)]
    588     pub fn new(
    589         artifact_id: PrivateArtifactId,
    590         kind: ArtifactKind,
    591         schema_id: ArtifactSchemaId,
    592         commitment: ArtifactCommitment,
    593         protected_size_bytes: u64,
    594         secret_reference: DurableSecretReference,
    595         retention: RetentionPolicy,
    596         created_at_unix_ms: u64,
    597     ) -> Result<Self, Error> {
    598         if protected_size_bytes == 0
    599             || created_at_unix_ms == 0
    600             || matches!(retention.delete_not_before_unix_ms(), Some(value) if value < created_at_unix_ms)
    601             || matches!(retention.expires_at_unix_ms(), Some(value) if value < created_at_unix_ms)
    602         {
    603             return Err(Error::InvalidPrivateArtifactMetadata);
    604         }
    605         Ok(Self {
    606             artifact_id,
    607             kind,
    608             schema_id,
    609             commitment,
    610             protected_size_bytes,
    611             secret_reference,
    612             retention,
    613             revision: PrivateArtifactRevision::INITIAL,
    614             stage: PrivateArtifactStage::Active,
    615             created_at_unix_ms,
    616             updated_at_unix_ms: created_at_unix_ms,
    617             tombstone: None,
    618         })
    619     }
    620 
    621     /// Reconstructs and validates metadata at a durable backend boundary.
    622     #[allow(clippy::too_many_arguments)]
    623     pub fn from_durable_parts(
    624         artifact_id: PrivateArtifactId,
    625         kind: ArtifactKind,
    626         schema_id: ArtifactSchemaId,
    627         commitment: ArtifactCommitment,
    628         protected_size_bytes: u64,
    629         secret_reference: DurableSecretReference,
    630         retention: RetentionPolicy,
    631         revision: PrivateArtifactRevision,
    632         stage: PrivateArtifactStage,
    633         created_at_unix_ms: u64,
    634         updated_at_unix_ms: u64,
    635         tombstone: Option<(u64, DeletionReason, ArtifactCommitment)>,
    636     ) -> Result<Self, Error> {
    637         let initial = Self::new(
    638             artifact_id,
    639             kind,
    640             schema_id,
    641             commitment,
    642             protected_size_bytes,
    643             secret_reference,
    644             retention,
    645             created_at_unix_ms,
    646         )?;
    647         if updated_at_unix_ms < created_at_unix_ms {
    648             return Err(Error::CorruptPrivateArtifactMetadata);
    649         }
    650         let tombstone =
    651             tombstone.map(
    652                 |(deleted_at_unix_ms, reason, tombstone_commitment)| ArtifactTombstone {
    653                     deleted_at_unix_ms,
    654                     reason,
    655                     commitment: tombstone_commitment,
    656                 },
    657             );
    658         let valid = match (stage, revision.get(), tombstone) {
    659             (PrivateArtifactStage::Active, revision, None) => {
    660                 (revision == 1 && updated_at_unix_ms == created_at_unix_ms)
    661                     || (revision > 1 && updated_at_unix_ms > created_at_unix_ms)
    662             }
    663             (PrivateArtifactStage::Expired, revision, None) => {
    664                 revision >= 2 && retention.is_expired_at(updated_at_unix_ms)
    665             }
    666             (PrivateArtifactStage::Tombstoned, revision, Some(tombstone)) => {
    667                 revision >= 2
    668                     && tombstone.deleted_at_unix_ms == updated_at_unix_ms
    669                     && tombstone.commitment == commitment
    670                     && retention.permits_deletion_at(updated_at_unix_ms)
    671                     && (tombstone.reason != DeletionReason::RetentionExpired
    672                         || retention.is_expired_at(updated_at_unix_ms))
    673             }
    674             _ => false,
    675         };
    676         if !valid {
    677             return Err(Error::CorruptPrivateArtifactMetadata);
    678         }
    679         Ok(Self {
    680             revision,
    681             stage,
    682             updated_at_unix_ms,
    683             tombstone,
    684             ..initial
    685         })
    686     }
    687     pub const fn artifact_id(&self) -> PrivateArtifactId {
    688         self.artifact_id
    689     }
    690     pub const fn kind(&self) -> &ArtifactKind {
    691         &self.kind
    692     }
    693     pub const fn schema_id(&self) -> &ArtifactSchemaId {
    694         &self.schema_id
    695     }
    696     pub const fn commitment(&self) -> ArtifactCommitment {
    697         self.commitment
    698     }
    699     pub const fn protected_size_bytes(&self) -> u64 {
    700         self.protected_size_bytes
    701     }
    702     pub const fn secret_reference(&self) -> &DurableSecretReference {
    703         &self.secret_reference
    704     }
    705     pub const fn retention(&self) -> RetentionPolicy {
    706         self.retention
    707     }
    708     pub const fn revision(&self) -> PrivateArtifactRevision {
    709         self.revision
    710     }
    711     pub const fn stage(&self) -> PrivateArtifactStage {
    712         self.stage
    713     }
    714     pub const fn created_at_unix_ms(&self) -> u64 {
    715         self.created_at_unix_ms
    716     }
    717     pub const fn updated_at_unix_ms(&self) -> u64 {
    718         self.updated_at_unix_ms
    719     }
    720     pub const fn tombstone_record(&self) -> Option<ArtifactTombstone> {
    721         self.tombstone
    722     }
    723 
    724     /// Derives the only valid envelope context for this artifact.
    725     pub fn envelope_context(&self) -> PrivateArtifactEnvelopeContext {
    726         PrivateArtifactEnvelopeContext::derive(self.artifact_id, &self.kind, &self.schema_id)
    727     }
    728 
    729     /// Applies the metadata half of a fenced envelope reseal.
    730     pub fn resealed(&self, request: &PrivateArtifactResealRequest) -> Result<Self, Error> {
    731         if self.stage != PrivateArtifactStage::Active
    732             || request.artifact_id != self.artifact_id
    733             || request.expected_revision != self.revision
    734             || request.expected_commitment != self.commitment
    735         {
    736             return Err(Error::PrivateArtifactResealConflict);
    737         }
    738         if request.committed_at_unix_ms <= self.updated_at_unix_ms {
    739             return Err(Error::InvalidPrivateArtifactTimestamp);
    740         }
    741         let mut next = self.clone();
    742         next.commitment = request.next_commitment;
    743         next.protected_size_bytes = request.next_protected_size_bytes;
    744         next.secret_reference = request.next_secret_reference.clone();
    745         next.revision = self.revision.next()?;
    746         next.updated_at_unix_ms = request.committed_at_unix_ms;
    747         Ok(next)
    748     }
    749 
    750     pub fn mark_expired(
    751         &self,
    752         expected_revision: PrivateArtifactRevision,
    753         at_unix_ms: u64,
    754     ) -> Result<Self, Error> {
    755         self.validate_transition(expected_revision, at_unix_ms)?;
    756         if self.stage != PrivateArtifactStage::Active || !self.retention.is_expired_at(at_unix_ms) {
    757             return Err(Error::PrivateArtifactNotExpired);
    758         }
    759         let mut next = self.clone();
    760         next.revision = self.revision.next()?;
    761         next.stage = PrivateArtifactStage::Expired;
    762         next.updated_at_unix_ms = at_unix_ms;
    763         Ok(next)
    764     }
    765 
    766     pub fn tombstone(
    767         &self,
    768         expected_revision: PrivateArtifactRevision,
    769         at_unix_ms: u64,
    770         reason: DeletionReason,
    771     ) -> Result<Self, Error> {
    772         self.validate_transition(expected_revision, at_unix_ms)?;
    773         if self.stage == PrivateArtifactStage::Tombstoned {
    774             return Err(Error::PrivateArtifactTombstoned);
    775         }
    776         if !self.retention.permits_deletion_at(at_unix_ms) {
    777             return Err(Error::PrivateArtifactRetentionActive);
    778         }
    779         if reason == DeletionReason::RetentionExpired && !self.retention.is_expired_at(at_unix_ms) {
    780             return Err(Error::PrivateArtifactNotExpired);
    781         }
    782         let mut next = self.clone();
    783         next.revision = self.revision.next()?;
    784         next.stage = PrivateArtifactStage::Tombstoned;
    785         next.updated_at_unix_ms = at_unix_ms;
    786         next.tombstone = Some(ArtifactTombstone {
    787             deleted_at_unix_ms: at_unix_ms,
    788             reason,
    789             commitment: self.commitment,
    790         });
    791         Ok(next)
    792     }
    793 
    794     fn validate_transition(
    795         &self,
    796         expected_revision: PrivateArtifactRevision,
    797         at_unix_ms: u64,
    798     ) -> Result<(), Error> {
    799         if expected_revision != self.revision {
    800             return Err(Error::PrivateArtifactRevisionConflict);
    801         }
    802         if at_unix_ms < self.updated_at_unix_ms {
    803             return Err(Error::InvalidPrivateArtifactTimestamp);
    804         }
    805         Ok(())
    806     }
    807 }
    808 
    809 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    810 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
    811 pub struct PrivateArtifactStatus {
    812     pub active: u64,
    813     pub expired: u64,
    814     pub tombstoned: u64,
    815 }
    816 
    817 impl PrivateArtifactStatus {
    818     pub fn total(self) -> Option<u64> {
    819         self.active
    820             .checked_add(self.expired)?
    821             .checked_add(self.tombstoned)
    822     }
    823 }
    824 
    825 /// Backend-neutral metadata-only private-artifact SPI.
    826 pub trait PrivateArtifactStore: Send + Sync {
    827     fn put_metadata(
    828         &self,
    829         metadata: PrivateArtifactMetadata,
    830     ) -> BoxFuture<'_, Result<PrivateArtifactMetadata, Error>>;
    831     fn metadata(
    832         &self,
    833         artifact_id: PrivateArtifactId,
    834     ) -> BoxFuture<'_, Result<Option<PrivateArtifactMetadata>, Error>>;
    835     fn reseal_metadata(
    836         &self,
    837         request: PrivateArtifactResealRequest,
    838     ) -> BoxFuture<'_, Result<PrivateArtifactResealReceipt, Error>>;
    839     fn mark_expired(
    840         &self,
    841         artifact_id: PrivateArtifactId,
    842         expected_revision: PrivateArtifactRevision,
    843         at_unix_ms: u64,
    844     ) -> BoxFuture<'_, Result<PrivateArtifactMetadata, Error>>;
    845     fn tombstone(
    846         &self,
    847         artifact_id: PrivateArtifactId,
    848         expected_revision: PrivateArtifactRevision,
    849         at_unix_ms: u64,
    850         reason: DeletionReason,
    851     ) -> BoxFuture<'_, Result<PrivateArtifactMetadata, Error>>;
    852     fn expired(
    853         &self,
    854         at_unix_ms: u64,
    855         limit: u16,
    856     ) -> BoxFuture<'_, Result<Vec<PrivateArtifactMetadata>, Error>>;
    857     fn status(&self) -> BoxFuture<'_, Result<PrivateArtifactStatus, Error>>;
    858 }
    859 
    860 #[cfg(feature = "serde")]
    861 impl serde::Serialize for PrivateArtifactId {
    862     fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    863     where
    864         S: serde::Serializer,
    865     {
    866         self.0.serialize(serializer)
    867     }
    868 }
    869 
    870 #[cfg(feature = "serde")]
    871 impl<'de> serde::Deserialize<'de> for PrivateArtifactId {
    872     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    873     where
    874         D: serde::Deserializer<'de>,
    875     {
    876         let bytes = <[u8; 16]>::deserialize(deserializer)?;
    877         Self::new(bytes).map_err(serde::de::Error::custom)
    878     }
    879 }
    880 
    881 #[cfg(feature = "serde")]
    882 impl serde::Serialize for ArtifactKind {
    883     fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    884     where
    885         S: serde::Serializer,
    886     {
    887         self.0.serialize(serializer)
    888     }
    889 }
    890 
    891 #[cfg(feature = "serde")]
    892 impl<'de> serde::Deserialize<'de> for ArtifactKind {
    893     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    894     where
    895         D: serde::Deserializer<'de>,
    896     {
    897         let value = String::deserialize(deserializer)?;
    898         Self::parse(value).map_err(serde::de::Error::custom)
    899     }
    900 }
    901 
    902 #[cfg(feature = "serde")]
    903 impl serde::Serialize for ArtifactSchemaId {
    904     fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    905     where
    906         S: serde::Serializer,
    907     {
    908         self.0.serialize(serializer)
    909     }
    910 }
    911 
    912 #[cfg(feature = "serde")]
    913 impl<'de> serde::Deserialize<'de> for ArtifactSchemaId {
    914     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    915     where
    916         D: serde::Deserializer<'de>,
    917     {
    918         let value = String::deserialize(deserializer)?;
    919         Self::parse(value).map_err(serde::de::Error::custom)
    920     }
    921 }
    922 
    923 #[cfg(feature = "serde")]
    924 impl serde::Serialize for RetentionPolicy {
    925     fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    926     where
    927         S: serde::Serializer,
    928     {
    929         use serde::ser::SerializeStruct;
    930         let mut state = serializer.serialize_struct("RetentionPolicy", 2)?;
    931         state.serialize_field("delete_not_before_unix_ms", &self.delete_not_before_unix_ms)?;
    932         state.serialize_field("expires_at_unix_ms", &self.expires_at_unix_ms)?;
    933         state.end()
    934     }
    935 }
    936 
    937 #[cfg(feature = "serde")]
    938 impl<'de> serde::Deserialize<'de> for RetentionPolicy {
    939     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    940     where
    941         D: serde::Deserializer<'de>,
    942     {
    943         #[derive(serde::Deserialize)]
    944         #[serde(deny_unknown_fields)]
    945         struct Wire {
    946             delete_not_before_unix_ms: Option<u64>,
    947             expires_at_unix_ms: Option<u64>,
    948         }
    949         let wire = Wire::deserialize(deserializer)?;
    950         Self::new(wire.delete_not_before_unix_ms, wire.expires_at_unix_ms)
    951             .map_err(serde::de::Error::custom)
    952     }
    953 }
    954 
    955 #[cfg(feature = "serde")]
    956 impl serde::Serialize for PrivateArtifactRevision {
    957     fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    958     where
    959         S: serde::Serializer,
    960     {
    961         self.0.serialize(serializer)
    962     }
    963 }
    964 
    965 #[cfg(feature = "serde")]
    966 impl<'de> serde::Deserialize<'de> for PrivateArtifactRevision {
    967     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    968     where
    969         D: serde::Deserializer<'de>,
    970     {
    971         Self::new(u64::deserialize(deserializer)?).map_err(serde::de::Error::custom)
    972     }
    973 }
    974 
    975 #[cfg(feature = "serde")]
    976 impl serde::Serialize for PrivateArtifactResealId {
    977     fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    978     where
    979         S: serde::Serializer,
    980     {
    981         self.0.serialize(serializer)
    982     }
    983 }
    984 
    985 #[cfg(feature = "serde")]
    986 impl<'de> serde::Deserialize<'de> for PrivateArtifactResealId {
    987     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    988     where
    989         D: serde::Deserializer<'de>,
    990     {
    991         let bytes = <[u8; 16]>::deserialize(deserializer)?;
    992         Self::new(bytes).map_err(serde::de::Error::custom)
    993     }
    994 }
    995 
    996 #[cfg(feature = "serde")]
    997 impl serde::Serialize for PrivateArtifactMetadata {
    998     fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    999     where
   1000         S: serde::Serializer,
   1001     {
   1002         use serde::ser::SerializeStruct;
   1003         let mut state = serializer.serialize_struct("PrivateArtifactMetadata", 12)?;
   1004         state.serialize_field("artifact_id", &self.artifact_id)?;
   1005         state.serialize_field("kind", &self.kind)?;
   1006         state.serialize_field("schema_id", &self.schema_id)?;
   1007         state.serialize_field("commitment", &self.commitment)?;
   1008         state.serialize_field("protected_size_bytes", &self.protected_size_bytes)?;
   1009         state.serialize_field("secret_reference", &self.secret_reference)?;
   1010         state.serialize_field("retention", &self.retention)?;
   1011         state.serialize_field("revision", &self.revision)?;
   1012         state.serialize_field("stage", &self.stage)?;
   1013         state.serialize_field("created_at_unix_ms", &self.created_at_unix_ms)?;
   1014         state.serialize_field("updated_at_unix_ms", &self.updated_at_unix_ms)?;
   1015         state.serialize_field("tombstone", &self.tombstone)?;
   1016         state.end()
   1017     }
   1018 }
   1019 
   1020 #[cfg(feature = "serde")]
   1021 impl<'de> serde::Deserialize<'de> for PrivateArtifactMetadata {
   1022     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
   1023     where
   1024         D: serde::Deserializer<'de>,
   1025     {
   1026         #[derive(serde::Deserialize)]
   1027         #[serde(deny_unknown_fields)]
   1028         struct Wire {
   1029             artifact_id: PrivateArtifactId,
   1030             kind: ArtifactKind,
   1031             schema_id: ArtifactSchemaId,
   1032             commitment: ArtifactCommitment,
   1033             protected_size_bytes: u64,
   1034             secret_reference: DurableSecretReference,
   1035             retention: RetentionPolicy,
   1036             revision: PrivateArtifactRevision,
   1037             stage: PrivateArtifactStage,
   1038             created_at_unix_ms: u64,
   1039             updated_at_unix_ms: u64,
   1040             tombstone: Option<ArtifactTombstone>,
   1041         }
   1042         let wire = Wire::deserialize(deserializer)?;
   1043         Self::from_durable_parts(
   1044             wire.artifact_id,
   1045             wire.kind,
   1046             wire.schema_id,
   1047             wire.commitment,
   1048             wire.protected_size_bytes,
   1049             wire.secret_reference,
   1050             wire.retention,
   1051             wire.revision,
   1052             wire.stage,
   1053             wire.created_at_unix_ms,
   1054             wire.updated_at_unix_ms,
   1055             wire.tombstone.map(|tombstone| {
   1056                 (
   1057                     tombstone.deleted_at_unix_ms(),
   1058                     tombstone.reason(),
   1059                     tombstone.commitment(),
   1060                 )
   1061             }),
   1062         )
   1063         .map_err(serde::de::Error::custom)
   1064     }
   1065 }
   1066 
   1067 fn valid_label(value: &str, max: usize) -> bool {
   1068     !value.is_empty()
   1069         && value.len() <= max
   1070         && value == value.trim()
   1071         && value.bytes().all(|byte| {
   1072             byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'-' | b'.')
   1073         })
   1074 }
   1075 
   1076 fn valid_schema(value: &str) -> bool {
   1077     if !valid_namespaced(value, ARTIFACT_SCHEMA_MAX_BYTES, 3) {
   1078         return false;
   1079     }
   1080     value.rsplit('.').next().is_some_and(|last| {
   1081         last.strip_prefix('v').is_some_and(|version| {
   1082             !version.is_empty() && version.bytes().all(|byte| byte.is_ascii_digit())
   1083         })
   1084     })
   1085 }
   1086 
   1087 fn valid_namespaced(value: &str, max: usize, minimum_segments: usize) -> bool {
   1088     valid_label(value, max)
   1089         && value.split('.').count() >= minimum_segments
   1090         && value.split('.').all(|segment| {
   1091             let mut bytes = segment.bytes();
   1092             bytes.next().is_some_and(|byte| byte.is_ascii_lowercase())
   1093                 && bytes.all(|byte| {
   1094                     byte.is_ascii_lowercase()
   1095                         || byte.is_ascii_digit()
   1096                         || matches!(byte, b'_' | b'-')
   1097                 })
   1098         })
   1099 }
   1100 
   1101 fn hex_artifact_id(artifact_id: PrivateArtifactId) -> String {
   1102     const HEX: &[u8; 16] = b"0123456789abcdef";
   1103     let mut encoded = String::with_capacity(32);
   1104     for byte in artifact_id.as_bytes() {
   1105         encoded.push(char::from(HEX[usize::from(byte >> 4)]));
   1106         encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
   1107     }
   1108     encoded
   1109 }
   1110 
   1111 fn hash_string(hasher: &mut Sha256, value: &str) {
   1112     let length = u32::try_from(value.len()).expect("validated private-artifact field fits u32");
   1113     hasher.update(length.to_be_bytes());
   1114     hasher.update(value.as_bytes());
   1115 }
   1116 
   1117 const fn bytes_are_zero(bytes: &[u8; 16]) -> bool {
   1118     let mut index = 0;
   1119     while index < bytes.len() {
   1120         if bytes[index] != 0 {
   1121             return false;
   1122         }
   1123         index += 1;
   1124     }
   1125     true
   1126 }