commit e3badfb7b9891fbd249c789a38b68f5f42a0fce2
parent 09838bdc2b55123a15f1bdea18b284b341c73d0e
Author: triesap <tyson@radroots.org>
Date: Wed, 5 Aug 2026 19:31:17 +0000
refactor(lib): complete envelope v2 cutover
Diffstat:
2 files changed, 233 insertions(+), 7 deletions(-)
diff --git a/crates/storage/src/private_artifact.rs b/crates/storage/src/private_artifact.rs
@@ -19,7 +19,6 @@ pub const PRIVATE_ARTIFACT_ENVELOPE_SUBJECT_TYPE: &str = "private_artifact";
const ENVELOPE_CONTEXT_DOMAIN: &[u8] = b"radroots.envelope_context.v1";
const ENVELOPE_CONTEXT_VERSION: u16 = 1;
-#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct PrivateArtifactId([u8; 16]);
@@ -41,7 +40,6 @@ impl fmt::Debug for PrivateArtifactId {
}
}
-#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct ArtifactKind(String);
@@ -58,7 +56,6 @@ impl ArtifactKind {
}
}
-#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct ArtifactSchemaId(String);
@@ -250,7 +247,6 @@ impl<'de> serde::Deserialize<'de> for DurableSecretReference {
}
/// Minimum retention and optional automatic expiry policy.
-#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct RetentionPolicy {
delete_not_before_unix_ms: Option<u64>,
@@ -290,7 +286,6 @@ impl RetentionPolicy {
}
}
-#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct PrivateArtifactRevision(u64);
@@ -315,7 +310,6 @@ impl PrivateArtifactRevision {
}
/// Host-generated idempotency identity for one reseal commit.
-#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct PrivateArtifactResealId([u8; 16]);
@@ -573,7 +567,6 @@ impl ArtifactTombstone {
}
/// Metadata for one protected artifact; no protected bytes are present.
-#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PrivateArtifactMetadata {
artifact_id: PrivateArtifactId,
@@ -864,6 +857,213 @@ pub trait PrivateArtifactStore: Send + Sync {
fn status(&self) -> BoxFuture<'_, Result<PrivateArtifactStatus, Error>>;
}
+#[cfg(feature = "serde")]
+impl serde::Serialize for PrivateArtifactId {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ self.0.serialize(serializer)
+ }
+}
+
+#[cfg(feature = "serde")]
+impl<'de> serde::Deserialize<'de> for PrivateArtifactId {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ let bytes = <[u8; 16]>::deserialize(deserializer)?;
+ Self::new(bytes).map_err(serde::de::Error::custom)
+ }
+}
+
+#[cfg(feature = "serde")]
+impl serde::Serialize for ArtifactKind {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ self.0.serialize(serializer)
+ }
+}
+
+#[cfg(feature = "serde")]
+impl<'de> serde::Deserialize<'de> for ArtifactKind {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ let value = String::deserialize(deserializer)?;
+ Self::parse(value).map_err(serde::de::Error::custom)
+ }
+}
+
+#[cfg(feature = "serde")]
+impl serde::Serialize for ArtifactSchemaId {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ self.0.serialize(serializer)
+ }
+}
+
+#[cfg(feature = "serde")]
+impl<'de> serde::Deserialize<'de> for ArtifactSchemaId {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ let value = String::deserialize(deserializer)?;
+ Self::parse(value).map_err(serde::de::Error::custom)
+ }
+}
+
+#[cfg(feature = "serde")]
+impl serde::Serialize for RetentionPolicy {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ use serde::ser::SerializeStruct;
+ let mut state = serializer.serialize_struct("RetentionPolicy", 2)?;
+ state.serialize_field("delete_not_before_unix_ms", &self.delete_not_before_unix_ms)?;
+ state.serialize_field("expires_at_unix_ms", &self.expires_at_unix_ms)?;
+ state.end()
+ }
+}
+
+#[cfg(feature = "serde")]
+impl<'de> serde::Deserialize<'de> for RetentionPolicy {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ #[derive(serde::Deserialize)]
+ #[serde(deny_unknown_fields)]
+ struct Wire {
+ delete_not_before_unix_ms: Option<u64>,
+ expires_at_unix_ms: Option<u64>,
+ }
+ let wire = Wire::deserialize(deserializer)?;
+ Self::new(wire.delete_not_before_unix_ms, wire.expires_at_unix_ms)
+ .map_err(serde::de::Error::custom)
+ }
+}
+
+#[cfg(feature = "serde")]
+impl serde::Serialize for PrivateArtifactRevision {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ self.0.serialize(serializer)
+ }
+}
+
+#[cfg(feature = "serde")]
+impl<'de> serde::Deserialize<'de> for PrivateArtifactRevision {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ Self::new(u64::deserialize(deserializer)?).map_err(serde::de::Error::custom)
+ }
+}
+
+#[cfg(feature = "serde")]
+impl serde::Serialize for PrivateArtifactResealId {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ self.0.serialize(serializer)
+ }
+}
+
+#[cfg(feature = "serde")]
+impl<'de> serde::Deserialize<'de> for PrivateArtifactResealId {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ let bytes = <[u8; 16]>::deserialize(deserializer)?;
+ Self::new(bytes).map_err(serde::de::Error::custom)
+ }
+}
+
+#[cfg(feature = "serde")]
+impl serde::Serialize for PrivateArtifactMetadata {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ use serde::ser::SerializeStruct;
+ let mut state = serializer.serialize_struct("PrivateArtifactMetadata", 12)?;
+ state.serialize_field("artifact_id", &self.artifact_id)?;
+ state.serialize_field("kind", &self.kind)?;
+ state.serialize_field("schema_id", &self.schema_id)?;
+ state.serialize_field("commitment", &self.commitment)?;
+ state.serialize_field("protected_size_bytes", &self.protected_size_bytes)?;
+ state.serialize_field("secret_reference", &self.secret_reference)?;
+ state.serialize_field("retention", &self.retention)?;
+ state.serialize_field("revision", &self.revision)?;
+ state.serialize_field("stage", &self.stage)?;
+ state.serialize_field("created_at_unix_ms", &self.created_at_unix_ms)?;
+ state.serialize_field("updated_at_unix_ms", &self.updated_at_unix_ms)?;
+ state.serialize_field("tombstone", &self.tombstone)?;
+ state.end()
+ }
+}
+
+#[cfg(feature = "serde")]
+impl<'de> serde::Deserialize<'de> for PrivateArtifactMetadata {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ #[derive(serde::Deserialize)]
+ #[serde(deny_unknown_fields)]
+ struct Wire {
+ artifact_id: PrivateArtifactId,
+ kind: ArtifactKind,
+ schema_id: ArtifactSchemaId,
+ commitment: ArtifactCommitment,
+ protected_size_bytes: u64,
+ secret_reference: DurableSecretReference,
+ retention: RetentionPolicy,
+ revision: PrivateArtifactRevision,
+ stage: PrivateArtifactStage,
+ created_at_unix_ms: u64,
+ updated_at_unix_ms: u64,
+ tombstone: Option<ArtifactTombstone>,
+ }
+ let wire = Wire::deserialize(deserializer)?;
+ Self::from_durable_parts(
+ wire.artifact_id,
+ wire.kind,
+ wire.schema_id,
+ wire.commitment,
+ wire.protected_size_bytes,
+ wire.secret_reference,
+ wire.retention,
+ wire.revision,
+ wire.stage,
+ wire.created_at_unix_ms,
+ wire.updated_at_unix_ms,
+ wire.tombstone.map(|tombstone| {
+ (
+ tombstone.deleted_at_unix_ms(),
+ tombstone.reason(),
+ tombstone.commitment(),
+ )
+ }),
+ )
+ .map_err(serde::de::Error::custom)
+ }
+}
+
fn valid_label(value: &str, max: usize) -> bool {
!value.is_empty()
&& value.len() <= max
diff --git a/crates/storage/tests/private_artifact.rs b/crates/storage/tests/private_artifact.rs
@@ -565,3 +565,29 @@ fn migration_status_is_bounded_and_overflow_safe() {
None
);
}
+
+#[test]
+#[cfg(feature = "serde")]
+fn serde_cannot_bypass_private_artifact_invariants() {
+ let value = metadata(RetentionPolicy::indefinite());
+ let encoded = serde_json::to_string(&value).expect("metadata JSON");
+ let decoded: PrivateArtifactMetadata =
+ serde_json::from_str(&encoded).expect("validated metadata round trip");
+ assert_eq!(decoded, value);
+
+ assert!(serde_json::from_str::<PrivateArtifactId>(&format!("{:?}", [0_u8; 16])).is_err());
+ assert!(serde_json::from_str::<PrivateArtifactResealId>(&format!("{:?}", [0_u8; 16])).is_err());
+ assert!(serde_json::from_str::<ArtifactKind>(r#""not_namespaced""#).is_err());
+ assert!(serde_json::from_str::<ArtifactSchemaId>(r#""trade.private_terms.latest""#).is_err());
+ assert!(serde_json::from_str::<PrivateArtifactRevision>("0").is_err());
+ assert!(
+ serde_json::from_str::<RetentionPolicy>(
+ r#"{"delete_not_before_unix_ms":0,"expires_at_unix_ms":null}"#,
+ )
+ .is_err()
+ );
+
+ let mut forged = serde_json::to_value(&value).expect("metadata value");
+ forged["revision"] = serde_json::json!(2);
+ assert!(serde_json::from_value::<PrivateArtifactMetadata>(forged).is_err());
+}