lib

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

commit 5c8fb3c3b8a93ff806fbd8f9be2653074f48cfd3
parent d12f7985b3c2e097e6ae01ae392c5d0e4394a502
Author: triesap <tyson@radroots.org>
Date:   Wed,  5 Aug 2026 01:42:36 +0000

feat(storage): model authored operations

- add ordered authored operations and exact durable artifact plans
- enforce signing admission origin claim and retry state invariants
- retain exact signed JSON digests and typed bounded failures
- verify reconstruction serde features package clippy and storage suites


Diffstat:
MCargo.lock | 2++
Mcrates/storage/Cargo.toml | 2++
Acrates/storage/src/authored.rs | 1295+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/storage/src/error.rs | 18++++++++++++++++++
Mcrates/storage/src/lib.rs | 1+
Acrates/storage/tests/authored.rs | 226+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 1544 insertions(+), 0 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -2788,11 +2788,13 @@ version = "0.1.0-alpha" dependencies = [ "futures-executor", "radroots_event", + "radroots_event_codec", "radroots_protocol", "radroots_trade", "radroots_transport", "serde", "serde_json", + "sha2", ] [[package]] diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml @@ -34,6 +34,7 @@ serde = [ [dependencies] radroots_event = { workspace = true, default-features = false } +radroots_event_codec = { workspace = true, default-features = false, features = ["json"] } radroots_protocol = { workspace = true, default-features = false } radroots_trade = { workspace = true, default-features = false } radroots_transport = { workspace = true, default-features = false } @@ -41,6 +42,7 @@ serde = { workspace = true, default-features = false, features = [ "alloc", "derive", ], optional = true } +sha2 = { workspace = true, default-features = false } [dev-dependencies] futures-executor = { workspace = true } diff --git a/crates/storage/src/authored.rs b/crates/storage/src/authored.rs @@ -0,0 +1,1295 @@ +//! Durable authored-operation, artifact, claim, failure, and settlement models. + +use core::num::{NonZeroU32, NonZeroU64}; +use radroots_event::SignedEvent; +use radroots_event_codec::authoring::{AuthoredEventPlan, HistoricalPlanIntegrity, PlanWireV1}; +use sha2::{Digest, Sha256}; +use std::{collections::BTreeSet, string::String, vec::Vec}; + +use crate::{Error, journal::OperationInstanceId}; + +pub const AUTHORED_OPERATION_ARTIFACTS_MAX: usize = 256; +pub const WORK_CLAIM_OWNER_MAX_BYTES: usize = 128; +pub const WORK_FAILURE_CODE_MAX_BYTES: usize = 64; +pub const WORK_FAILURE_DIAGNOSTIC_MAX_BYTES: usize = 1_024; + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(try_from = "[u8; 16]", into = "[u8; 16]"))] +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct AuthoredArtifactId([u8; 16]); + +impl AuthoredArtifactId { + pub const fn new(value: [u8; 16]) -> Result<Self, Error> { + if all_zero(&value) { + Err(Error::InvalidAuthoredArtifact) + } else { + Ok(Self(value)) + } + } + + pub const fn as_bytes(&self) -> &[u8; 16] { + &self.0 + } +} + +impl TryFrom<[u8; 16]> for AuthoredArtifactId { + type Error = Error; + + fn try_from(value: [u8; 16]) -> Result<Self, Self::Error> { + Self::new(value) + } +} + +impl From<AuthoredArtifactId> for [u8; 16] { + fn from(value: AuthoredArtifactId) -> Self { + value.0 + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +pub enum ArtifactOrigin { + Planned, + ImportedSigned, +} + +impl ArtifactOrigin { + pub const fn is_resignable(self) -> bool { + matches!(self, Self::Planned) + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +pub enum SigningState { + Planned, + Signed, + Retryable, + Indeterminate, + FailedTerminal, + Cancelled, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +pub enum AdmissionState { + Pending, + Inserted, + Duplicate, + Retryable, + Rejected, + Cancelled, +} + +impl AdmissionState { + pub const fn is_admitted(self) -> bool { + matches!(self, Self::Inserted | Self::Duplicate) + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +pub enum WorkPhase { + Signing, + Admission, + Delivery, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +pub enum FailureClass { + Retryable, + Terminal, + Indeterminate, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr( + feature = "serde", + serde(try_from = "WorkClaimWire", into = "WorkClaimWire") +)] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WorkClaim { + token: [u8; 16], + owner: String, + generation: NonZeroU64, + acquired_at_unix_ms: u64, + expires_at_unix_ms: u64, + row_revision: NonZeroU64, +} + +impl WorkClaim { + pub fn new( + token: [u8; 16], + owner: impl Into<String>, + generation: NonZeroU64, + acquired_at_unix_ms: u64, + expires_at_unix_ms: u64, + row_revision: NonZeroU64, + ) -> Result<Self, Error> { + let claim = Self { + token, + owner: owner.into(), + generation, + acquired_at_unix_ms, + expires_at_unix_ms, + row_revision, + }; + claim.validate()?; + Ok(claim) + } + + pub fn validate(&self) -> Result<(), Error> { + if all_zero(&self.token) + || !valid_text(self.owner.as_str(), WORK_CLAIM_OWNER_MAX_BYTES) + || self.acquired_at_unix_ms == 0 + || self.expires_at_unix_ms <= self.acquired_at_unix_ms + { + return Err(Error::InvalidWorkClaim); + } + Ok(()) + } + + pub const fn token(&self) -> &[u8; 16] { + &self.token + } + pub fn owner(&self) -> &str { + self.owner.as_str() + } + pub const fn generation(&self) -> NonZeroU64 { + self.generation + } + pub const fn acquired_at_unix_ms(&self) -> u64 { + self.acquired_at_unix_ms + } + pub const fn expires_at_unix_ms(&self) -> u64 { + self.expires_at_unix_ms + } + pub const fn row_revision(&self) -> NonZeroU64 { + self.row_revision + } + + pub fn matches_fence( + &self, + token: &[u8; 16], + generation: NonZeroU64, + row_revision: NonZeroU64, + now_unix_ms: u64, + ) -> bool { + &self.token == token + && self.generation == generation + && self.row_revision == row_revision + && now_unix_ms >= self.acquired_at_unix_ms + && now_unix_ms < self.expires_at_unix_ms + } +} + +#[cfg(feature = "serde")] +#[derive(serde::Serialize, serde::Deserialize)] +struct WorkClaimWire { + token: [u8; 16], + owner: String, + generation: NonZeroU64, + acquired_at_unix_ms: u64, + expires_at_unix_ms: u64, + row_revision: NonZeroU64, +} + +#[cfg(feature = "serde")] +impl TryFrom<WorkClaimWire> for WorkClaim { + type Error = Error; + fn try_from(value: WorkClaimWire) -> Result<Self, Self::Error> { + Self::new( + value.token, + value.owner, + value.generation, + value.acquired_at_unix_ms, + value.expires_at_unix_ms, + value.row_revision, + ) + } +} + +#[cfg(feature = "serde")] +impl From<WorkClaim> for WorkClaimWire { + fn from(value: WorkClaim) -> Self { + Self { + token: value.token, + owner: value.owner, + generation: value.generation, + acquired_at_unix_ms: value.acquired_at_unix_ms, + expires_at_unix_ms: value.expires_at_unix_ms, + row_revision: value.row_revision, + } + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr( + feature = "serde", + serde(try_from = "WorkFailureWire", into = "WorkFailureWire") +)] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WorkFailure { + code: String, + phase: WorkPhase, + class: FailureClass, + retry_after_unix_ms: Option<u64>, + diagnostic: Option<String>, +} + +impl WorkFailure { + pub fn new( + code: impl Into<String>, + phase: WorkPhase, + class: FailureClass, + retry_after_unix_ms: Option<u64>, + diagnostic: Option<String>, + ) -> Result<Self, Error> { + let failure = Self { + code: code.into(), + phase, + class, + retry_after_unix_ms, + diagnostic, + }; + failure.validate()?; + Ok(failure) + } + + pub fn validate(&self) -> Result<(), Error> { + if !valid_code(self.code.as_str()) + || matches!(self.retry_after_unix_ms, Some(0)) + || (self.retry_after_unix_ms.is_some() + && !matches!(self.class, FailureClass::Retryable)) + || self + .diagnostic + .as_deref() + .is_some_and(|value| !valid_text(value, WORK_FAILURE_DIAGNOSTIC_MAX_BYTES)) + { + return Err(Error::InvalidWorkFailure); + } + Ok(()) + } + + pub fn code(&self) -> &str { + self.code.as_str() + } + pub const fn phase(&self) -> WorkPhase { + self.phase + } + pub const fn class(&self) -> FailureClass { + self.class + } + pub const fn retry_after_unix_ms(&self) -> Option<u64> { + self.retry_after_unix_ms + } + pub fn diagnostic(&self) -> Option<&str> { + self.diagnostic.as_deref() + } +} + +#[cfg(feature = "serde")] +#[derive(serde::Serialize, serde::Deserialize)] +struct WorkFailureWire { + code: String, + phase: WorkPhase, + class: FailureClass, + retry_after_unix_ms: Option<u64>, + diagnostic: Option<String>, +} + +#[cfg(feature = "serde")] +impl TryFrom<WorkFailureWire> for WorkFailure { + type Error = Error; + fn try_from(value: WorkFailureWire) -> Result<Self, Self::Error> { + Self::new( + value.code, + value.phase, + value.class, + value.retry_after_unix_ms, + value.diagnostic, + ) + } +} + +#[cfg(feature = "serde")] +impl From<WorkFailure> for WorkFailureWire { + fn from(value: WorkFailure) -> Self { + Self { + code: value.code, + phase: value.phase, + class: value.class, + retry_after_unix_ms: value.retry_after_unix_ms, + diagnostic: value.diagnostic, + } + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr( + feature = "serde", + serde(try_from = "RetryScheduleWire", into = "RetryScheduleWire") +)] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RetrySchedule { + attempt: NonZeroU32, + not_before_unix_ms: u64, + failure: WorkFailure, +} + +impl RetrySchedule { + pub fn new( + attempt: NonZeroU32, + not_before_unix_ms: u64, + failure: WorkFailure, + ) -> Result<Self, Error> { + if not_before_unix_ms == 0 + || !matches!(failure.class(), FailureClass::Retryable) + || failure + .retry_after_unix_ms() + .is_some_and(|retry_after| retry_after != not_before_unix_ms) + { + return Err(Error::InvalidRetrySchedule); + } + Ok(Self { + attempt, + not_before_unix_ms, + failure, + }) + } + + pub fn next_attempt( + &self, + not_before_unix_ms: u64, + failure: WorkFailure, + ) -> Result<Self, Error> { + let attempt = self + .attempt + .get() + .checked_add(1) + .and_then(NonZeroU32::new) + .ok_or(Error::InvalidRetrySchedule)?; + Self::new(attempt, not_before_unix_ms, failure) + } + + pub const fn attempt(&self) -> NonZeroU32 { + self.attempt + } + pub const fn not_before_unix_ms(&self) -> u64 { + self.not_before_unix_ms + } + pub const fn failure(&self) -> &WorkFailure { + &self.failure + } +} + +#[cfg(feature = "serde")] +#[derive(serde::Serialize, serde::Deserialize)] +struct RetryScheduleWire { + attempt: NonZeroU32, + not_before_unix_ms: u64, + failure: WorkFailure, +} + +#[cfg(feature = "serde")] +impl TryFrom<RetryScheduleWire> for RetrySchedule { + type Error = Error; + fn try_from(value: RetryScheduleWire) -> Result<Self, Self::Error> { + Self::new(value.attempt, value.not_before_unix_ms, value.failure) + } +} + +#[cfg(feature = "serde")] +impl From<RetrySchedule> for RetryScheduleWire { + fn from(value: RetrySchedule) -> Self { + Self { + attempt: value.attempt, + not_before_unix_ms: value.not_before_unix_ms, + failure: value.failure, + } + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr( + feature = "serde", + serde(try_from = "ExactSignedArtifactWire", into = "ExactSignedArtifactWire") +)] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ExactSignedArtifact { + event: SignedEvent, + raw_json_sha256: [u8; 32], +} + +#[cfg(feature = "serde")] +#[derive(serde::Serialize, serde::Deserialize)] +struct ExactSignedArtifactWire { + event: SignedEvent, + raw_json_sha256: [u8; 32], +} + +#[cfg(feature = "serde")] +impl TryFrom<ExactSignedArtifactWire> for ExactSignedArtifact { + type Error = Error; + fn try_from(value: ExactSignedArtifactWire) -> Result<Self, Self::Error> { + Self::reconstruct(value.event, value.raw_json_sha256) + } +} + +#[cfg(feature = "serde")] +impl From<ExactSignedArtifact> for ExactSignedArtifactWire { + fn from(value: ExactSignedArtifact) -> Self { + Self { + event: value.event, + raw_json_sha256: value.raw_json_sha256, + } + } +} + +/// Exact versioned authored-plan wire retained for durable reconstruction. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr( + feature = "serde", + serde(try_from = "DurableAuthoredPlanWire", into = "DurableAuthoredPlanWire") +)] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DurableAuthoredPlan { + wire_json: Vec<u8>, +} + +#[cfg(feature = "serde")] +#[derive(serde::Serialize, serde::Deserialize)] +struct DurableAuthoredPlanWire { + wire_json: Vec<u8>, +} + +#[cfg(feature = "serde")] +impl TryFrom<DurableAuthoredPlanWire> for DurableAuthoredPlan { + type Error = Error; + fn try_from(value: DurableAuthoredPlanWire) -> Result<Self, Self::Error> { + Self::reconstruct(value.wire_json) + } +} + +#[cfg(feature = "serde")] +impl From<DurableAuthoredPlan> for DurableAuthoredPlanWire { + fn from(value: DurableAuthoredPlan) -> Self { + Self { + wire_json: value.wire_json, + } + } +} + +impl DurableAuthoredPlan { + pub fn from_plan(plan: &AuthoredEventPlan) -> Result<Self, Error> { + let wire_json = PlanWireV1::from_plan(plan) + .to_json() + .map_err(|_| Error::InvalidAuthoredArtifact)?; + Ok(Self { wire_json }) + } + + pub fn reconstruct(wire_json: Vec<u8>) -> Result<Self, Error> { + PlanWireV1::from_json(wire_json.as_slice()).map_err(|_| Error::InvalidAuthoredArtifact)?; + Ok(Self { wire_json }) + } + + pub fn decode(&self) -> Result<HistoricalPlanIntegrity, Error> { + PlanWireV1::from_json(self.wire_json.as_slice()).map_err(|_| Error::InvalidAuthoredArtifact) + } + + pub fn wire_json(&self) -> &[u8] { + self.wire_json.as_slice() + } +} + +impl ExactSignedArtifact { + pub fn new(event: SignedEvent) -> Self { + let raw_json_sha256 = Sha256::digest(event.raw_json().as_bytes()).into(); + Self { + event, + raw_json_sha256, + } + } + + pub fn reconstruct(event: SignedEvent, raw_json_sha256: [u8; 32]) -> Result<Self, Error> { + let artifact = Self { + event, + raw_json_sha256, + }; + if Sha256::digest(artifact.event.raw_json().as_bytes()).as_slice() + != artifact.raw_json_sha256 + { + return Err(Error::InvalidAuthoredArtifact); + } + Ok(artifact) + } + + pub const fn event(&self) -> &SignedEvent { + &self.event + } + pub const fn raw_json_sha256(&self) -> &[u8; 32] { + &self.raw_json_sha256 + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr( + feature = "serde", + serde(try_from = "AuthoredArtifactWire", into = "AuthoredArtifactWire") +)] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AuthoredArtifact { + artifact_id: AuthoredArtifactId, + operation_id: OperationInstanceId, + ordinal: u16, + origin: ArtifactOrigin, + plan: Option<DurableAuthoredPlan>, + signing_state: SigningState, + admission_state: AdmissionState, + signed: Option<ExactSignedArtifact>, + signing_claim: Option<WorkClaim>, + admission_claim: Option<WorkClaim>, + signing_retry: Option<RetrySchedule>, + admission_retry: Option<RetrySchedule>, + last_failure: Option<WorkFailure>, + created_at_unix_ms: u64, + updated_at_unix_ms: u64, + revision: NonZeroU64, +} + +#[cfg(feature = "serde")] +#[derive(serde::Serialize, serde::Deserialize)] +struct AuthoredArtifactWire { + artifact_id: AuthoredArtifactId, + operation_id: OperationInstanceId, + ordinal: u16, + origin: ArtifactOrigin, + plan: Option<DurableAuthoredPlan>, + signing_state: SigningState, + admission_state: AdmissionState, + signed: Option<ExactSignedArtifact>, + signing_claim: Option<WorkClaim>, + admission_claim: Option<WorkClaim>, + signing_retry: Option<RetrySchedule>, + admission_retry: Option<RetrySchedule>, + last_failure: Option<WorkFailure>, + created_at_unix_ms: u64, + updated_at_unix_ms: u64, + revision: NonZeroU64, +} + +#[cfg(feature = "serde")] +impl TryFrom<AuthoredArtifactWire> for AuthoredArtifact { + type Error = Error; + fn try_from(value: AuthoredArtifactWire) -> Result<Self, Self::Error> { + Self::reconstruct(Self { + artifact_id: value.artifact_id, + operation_id: value.operation_id, + ordinal: value.ordinal, + origin: value.origin, + plan: value.plan, + signing_state: value.signing_state, + admission_state: value.admission_state, + signed: value.signed, + signing_claim: value.signing_claim, + admission_claim: value.admission_claim, + signing_retry: value.signing_retry, + admission_retry: value.admission_retry, + last_failure: value.last_failure, + created_at_unix_ms: value.created_at_unix_ms, + updated_at_unix_ms: value.updated_at_unix_ms, + revision: value.revision, + }) + } +} + +#[cfg(feature = "serde")] +impl From<AuthoredArtifact> for AuthoredArtifactWire { + fn from(value: AuthoredArtifact) -> Self { + Self { + artifact_id: value.artifact_id, + operation_id: value.operation_id, + ordinal: value.ordinal, + origin: value.origin, + plan: value.plan, + signing_state: value.signing_state, + admission_state: value.admission_state, + signed: value.signed, + signing_claim: value.signing_claim, + admission_claim: value.admission_claim, + signing_retry: value.signing_retry, + admission_retry: value.admission_retry, + last_failure: value.last_failure, + created_at_unix_ms: value.created_at_unix_ms, + updated_at_unix_ms: value.updated_at_unix_ms, + revision: value.revision, + } + } +} + +impl AuthoredArtifact { + pub fn planned( + artifact_id: AuthoredArtifactId, + operation_id: OperationInstanceId, + ordinal: u16, + plan: &AuthoredEventPlan, + created_at_unix_ms: u64, + ) -> Result<Self, Error> { + Self::reconstruct(Self { + artifact_id, + operation_id, + ordinal, + origin: ArtifactOrigin::Planned, + plan: Some(DurableAuthoredPlan::from_plan(plan)?), + signing_state: SigningState::Planned, + admission_state: AdmissionState::Pending, + signed: None, + signing_claim: None, + admission_claim: None, + signing_retry: None, + admission_retry: None, + last_failure: None, + created_at_unix_ms, + updated_at_unix_ms: created_at_unix_ms, + revision: NonZeroU64::MIN, + }) + } + + pub fn imported_signed( + artifact_id: AuthoredArtifactId, + operation_id: OperationInstanceId, + ordinal: u16, + event: SignedEvent, + created_at_unix_ms: u64, + ) -> Result<Self, Error> { + Self::reconstruct(Self { + artifact_id, + operation_id, + ordinal, + origin: ArtifactOrigin::ImportedSigned, + plan: None, + signing_state: SigningState::Signed, + admission_state: AdmissionState::Pending, + signed: Some(ExactSignedArtifact::new(event)), + signing_claim: None, + admission_claim: None, + signing_retry: None, + admission_retry: None, + last_failure: None, + created_at_unix_ms, + updated_at_unix_ms: created_at_unix_ms, + revision: NonZeroU64::MIN, + }) + } + + pub fn reconstruct(value: Self) -> Result<Self, Error> { + value.validate()?; + Ok(value) + } + + pub fn validate(&self) -> Result<(), Error> { + if self.created_at_unix_ms == 0 || self.updated_at_unix_ms < self.created_at_unix_ms { + return Err(Error::InvalidAuthoredArtifact); + } + match self.origin { + ArtifactOrigin::Planned if self.plan.is_none() => { + return Err(Error::InvalidAuthoredArtifact); + } + ArtifactOrigin::ImportedSigned + if self.plan.is_some() + || self.signing_state != SigningState::Signed + || self.signed.is_none() + || self.signing_claim.is_some() + || self.signing_retry.is_some() => + { + return Err(Error::InvalidAuthoredArtifact); + } + ArtifactOrigin::Planned | ArtifactOrigin::ImportedSigned => {} + } + if self + .plan + .as_ref() + .is_some_and(|plan| plan.decode().is_err()) + { + return Err(Error::InvalidAuthoredArtifact); + } + if matches!(self.signing_state, SigningState::Signed) != self.signed.is_some() { + return Err(Error::InvalidAuthoredArtifact); + } + if self.signed.is_none() && self.admission_state != AdmissionState::Pending { + return Err(Error::InvalidAuthoredArtifact); + } + if matches!(self.signing_state, SigningState::Retryable) != self.signing_retry.is_some() + || matches!(self.admission_state, AdmissionState::Retryable) + != self.admission_retry.is_some() + { + return Err(Error::InvalidAuthoredArtifact); + } + if self.signing_claim.as_ref().is_some_and(|claim| { + claim.validate().is_err() + || !matches!( + self.signing_state, + SigningState::Planned | SigningState::Retryable + ) + }) || self.admission_claim.as_ref().is_some_and(|claim| { + claim.validate().is_err() + || self.signed.is_none() + || !matches!( + self.admission_state, + AdmissionState::Pending | AdmissionState::Retryable + ) + }) { + return Err(Error::InvalidAuthoredArtifact); + } + if self + .signing_retry + .as_ref() + .is_some_and(|retry| retry.failure().phase() != WorkPhase::Signing) + || self + .admission_retry + .as_ref() + .is_some_and(|retry| retry.failure().phase() != WorkPhase::Admission) + { + return Err(Error::InvalidAuthoredArtifact); + } + if self + .last_failure + .as_ref() + .is_some_and(|failure| failure.validate().is_err()) + { + return Err(Error::InvalidAuthoredArtifact); + } + let expected_failure = match self.signing_state { + SigningState::Retryable => self.signing_retry.as_ref().map(RetrySchedule::failure), + SigningState::Indeterminate => self.last_failure.as_ref().filter(|failure| { + failure.phase() == WorkPhase::Signing + && failure.class() == FailureClass::Indeterminate + }), + SigningState::FailedTerminal => self.last_failure.as_ref().filter(|failure| { + failure.phase() == WorkPhase::Signing && failure.class() == FailureClass::Terminal + }), + SigningState::Signed => match self.admission_state { + AdmissionState::Retryable => { + self.admission_retry.as_ref().map(RetrySchedule::failure) + } + AdmissionState::Rejected | AdmissionState::Cancelled => { + self.last_failure.as_ref().filter(|failure| { + failure.phase() == WorkPhase::Admission + && failure.class() == FailureClass::Terminal + }) + } + AdmissionState::Pending | AdmissionState::Inserted | AdmissionState::Duplicate => { + None + } + }, + SigningState::Planned | SigningState::Cancelled => None, + }; + let failure_required = matches!( + self.signing_state, + SigningState::Retryable | SigningState::Indeterminate | SigningState::FailedTerminal + ) || (self.signing_state == SigningState::Signed + && matches!( + self.admission_state, + AdmissionState::Retryable | AdmissionState::Rejected | AdmissionState::Cancelled + )); + if failure_required { + if expected_failure != self.last_failure.as_ref() { + return Err(Error::InvalidAuthoredArtifact); + } + } else if self.last_failure.is_some() { + return Err(Error::InvalidAuthoredArtifact); + } + if let Some(signed) = &self.signed { + ExactSignedArtifact::reconstruct(signed.event.clone(), signed.raw_json_sha256)?; + if let Some(plan) = &self.plan { + let integrity = plan.decode()?; + let plan = integrity.plan(); + let event = signed.event(); + if event.id() != plan.expected_event_id() + || event.pubkey() != plan.author() + || event.created_at() != plan.created_at() + || event.wire().kind != plan.body().kind() + || event.wire().tags != plan.body().tags() + || event.wire().content != plan.body().content() + { + return Err(Error::InvalidAuthoredArtifact); + } + } + } + Ok(()) + } + + pub fn record_signed(&mut self, event: SignedEvent, at_unix_ms: u64) -> Result<(), Error> { + if !self.origin.is_resignable() + || !matches!( + self.signing_state, + SigningState::Planned | SigningState::Retryable + ) + { + return Err(Error::InvalidAuthoredTransition); + } + let previous = self.clone(); + self.signed = Some(ExactSignedArtifact::new(event)); + self.signing_state = SigningState::Signed; + self.signing_claim = None; + self.signing_retry = None; + self.last_failure = None; + if let Err(error) = self.advance(at_unix_ms) { + *self = previous; + return Err(error); + } + if let Err(error) = self.validate() { + *self = previous; + return Err(error); + } + Ok(()) + } + + pub fn set_signing_claim(&mut self, claim: WorkClaim, at_unix_ms: u64) -> Result<(), Error> { + if !self.origin.is_resignable() + || !matches!( + self.signing_state, + SigningState::Planned | SigningState::Retryable + ) + || self.signing_claim.is_some() + || claim.row_revision() != self.revision + { + return Err(Error::InvalidAuthoredTransition); + } + claim.validate()?; + let previous = self.clone(); + self.signing_claim = Some(claim); + if let Err(error) = self.advance(at_unix_ms) { + *self = previous; + return Err(error); + } + self.validate() + } + + pub fn record_signing_failure( + &mut self, + failure: WorkFailure, + retry: Option<RetrySchedule>, + at_unix_ms: u64, + ) -> Result<(), Error> { + if failure.phase() != WorkPhase::Signing + || !self.origin.is_resignable() + || !matches!( + self.signing_state, + SigningState::Planned | SigningState::Retryable + ) + || retry + .as_ref() + .is_some_and(|schedule| schedule.failure() != &failure) + { + return Err(Error::InvalidAuthoredTransition); + } + let previous = self.clone(); + self.signing_state = match failure.class() { + FailureClass::Retryable if retry.is_some() => SigningState::Retryable, + FailureClass::Terminal if retry.is_none() => SigningState::FailedTerminal, + FailureClass::Indeterminate if retry.is_none() => SigningState::Indeterminate, + FailureClass::Retryable | FailureClass::Terminal | FailureClass::Indeterminate => { + return Err(Error::InvalidAuthoredTransition); + } + }; + self.signing_claim = None; + self.signing_retry = retry; + self.last_failure = Some(failure); + if let Err(error) = self.advance(at_unix_ms) { + *self = previous; + return Err(error); + } + if let Err(error) = self.validate() { + *self = previous; + return Err(error); + } + Ok(()) + } + + pub fn cancel_signing(&mut self, at_unix_ms: u64) -> Result<(), Error> { + if !self.origin.is_resignable() + || !matches!( + self.signing_state, + SigningState::Planned | SigningState::Retryable + ) + { + return Err(Error::InvalidAuthoredTransition); + } + let previous = self.clone(); + self.signing_state = SigningState::Cancelled; + self.signing_claim = None; + self.signing_retry = None; + self.last_failure = None; + if let Err(error) = self.advance(at_unix_ms) { + *self = previous; + return Err(error); + } + if let Err(error) = self.validate() { + *self = previous; + return Err(error); + } + Ok(()) + } + + pub fn set_admission_claim(&mut self, claim: WorkClaim, at_unix_ms: u64) -> Result<(), Error> { + if self.signing_state != SigningState::Signed + || !matches!( + self.admission_state, + AdmissionState::Pending | AdmissionState::Retryable + ) + || self.admission_claim.is_some() + || claim.row_revision() != self.revision + { + return Err(Error::InvalidAuthoredTransition); + } + claim.validate()?; + let previous = self.clone(); + self.admission_claim = Some(claim); + if let Err(error) = self.advance(at_unix_ms) { + *self = previous; + return Err(error); + } + self.validate() + } + + pub fn record_admission( + &mut self, + state: AdmissionState, + failure: Option<WorkFailure>, + retry: Option<RetrySchedule>, + at_unix_ms: u64, + ) -> Result<(), Error> { + if self.signing_state != SigningState::Signed + || self.signed.is_none() + || !matches!( + self.admission_state, + AdmissionState::Pending | AdmissionState::Retryable + ) + || matches!(state, AdmissionState::Pending) + || failure + .as_ref() + .is_some_and(|value| value.phase() != WorkPhase::Admission) + || (matches!(state, AdmissionState::Retryable) != retry.is_some()) + || retry.as_ref().is_some_and(|schedule| { + failure + .as_ref() + .is_none_or(|value| schedule.failure() != value) + }) + || (matches!(state, AdmissionState::Retryable) + && !matches!( + failure.as_ref().map(WorkFailure::class), + Some(FailureClass::Retryable) + )) + || (matches!(state, AdmissionState::Rejected | AdmissionState::Cancelled) + && (failure.is_none() || retry.is_some())) + || (matches!(state, AdmissionState::Inserted | AdmissionState::Duplicate) + && (failure.is_some() || retry.is_some())) + { + return Err(Error::InvalidAuthoredTransition); + } + let previous = self.clone(); + self.admission_state = state; + self.admission_claim = None; + self.admission_retry = retry; + self.last_failure = failure; + if let Err(error) = self.advance(at_unix_ms) { + *self = previous; + return Err(error); + } + if let Err(error) = self.validate() { + *self = previous; + return Err(error); + } + Ok(()) + } + + fn advance(&mut self, at_unix_ms: u64) -> Result<(), Error> { + if at_unix_ms < self.updated_at_unix_ms { + return Err(Error::InvalidAuthoredTransition); + } + self.revision = self + .revision + .get() + .checked_add(1) + .and_then(NonZeroU64::new) + .ok_or(Error::InvalidAuthoredTransition)?; + self.updated_at_unix_ms = at_unix_ms; + Ok(()) + } + + pub const fn artifact_id(&self) -> AuthoredArtifactId { + self.artifact_id + } + pub const fn operation_id(&self) -> OperationInstanceId { + self.operation_id + } + pub const fn ordinal(&self) -> u16 { + self.ordinal + } + pub const fn origin(&self) -> ArtifactOrigin { + self.origin + } + pub const fn plan(&self) -> Option<&DurableAuthoredPlan> { + self.plan.as_ref() + } + pub const fn signing_state(&self) -> SigningState { + self.signing_state + } + pub const fn admission_state(&self) -> AdmissionState { + self.admission_state + } + pub const fn signed(&self) -> Option<&ExactSignedArtifact> { + self.signed.as_ref() + } + pub const fn signing_claim(&self) -> Option<&WorkClaim> { + self.signing_claim.as_ref() + } + pub const fn admission_claim(&self) -> Option<&WorkClaim> { + self.admission_claim.as_ref() + } + pub const fn signing_retry(&self) -> Option<&RetrySchedule> { + self.signing_retry.as_ref() + } + pub const fn admission_retry(&self) -> Option<&RetrySchedule> { + self.admission_retry.as_ref() + } + pub const fn last_failure(&self) -> Option<&WorkFailure> { + self.last_failure.as_ref() + } + pub const fn revision(&self) -> NonZeroU64 { + self.revision + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr( + feature = "serde", + serde(try_from = "AuthoredOperationWire", into = "AuthoredOperationWire") +)] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AuthoredOperation { + operation_id: OperationInstanceId, + artifact_ids: Vec<AuthoredArtifactId>, + created_at_unix_ms: u64, + updated_at_unix_ms: u64, + revision: NonZeroU64, +} + +#[cfg(feature = "serde")] +#[derive(serde::Serialize, serde::Deserialize)] +struct AuthoredOperationWire { + operation_id: OperationInstanceId, + artifact_ids: Vec<AuthoredArtifactId>, + created_at_unix_ms: u64, + updated_at_unix_ms: u64, + revision: NonZeroU64, +} + +#[cfg(feature = "serde")] +impl TryFrom<AuthoredOperationWire> for AuthoredOperation { + type Error = Error; + fn try_from(value: AuthoredOperationWire) -> Result<Self, Self::Error> { + Self::reconstruct( + value.operation_id, + value.artifact_ids, + value.created_at_unix_ms, + value.updated_at_unix_ms, + value.revision, + ) + } +} + +#[cfg(feature = "serde")] +impl From<AuthoredOperation> for AuthoredOperationWire { + fn from(value: AuthoredOperation) -> Self { + Self { + operation_id: value.operation_id, + artifact_ids: value.artifact_ids, + created_at_unix_ms: value.created_at_unix_ms, + updated_at_unix_ms: value.updated_at_unix_ms, + revision: value.revision, + } + } +} + +impl AuthoredOperation { + pub fn new( + operation_id: OperationInstanceId, + artifact_ids: Vec<AuthoredArtifactId>, + created_at_unix_ms: u64, + ) -> Result<Self, Error> { + Self::reconstruct( + operation_id, + artifact_ids, + created_at_unix_ms, + created_at_unix_ms, + NonZeroU64::MIN, + ) + } + + pub fn reconstruct( + operation_id: OperationInstanceId, + artifact_ids: Vec<AuthoredArtifactId>, + created_at_unix_ms: u64, + updated_at_unix_ms: u64, + revision: NonZeroU64, + ) -> Result<Self, Error> { + if artifact_ids.is_empty() + || artifact_ids.len() > AUTHORED_OPERATION_ARTIFACTS_MAX + || created_at_unix_ms == 0 + || updated_at_unix_ms < created_at_unix_ms + || artifact_ids.iter().collect::<BTreeSet<_>>().len() != artifact_ids.len() + { + return Err(Error::InvalidAuthoredOperation); + } + Ok(Self { + operation_id, + artifact_ids, + created_at_unix_ms, + updated_at_unix_ms, + revision, + }) + } + + pub const fn operation_id(&self) -> OperationInstanceId { + self.operation_id + } + pub fn artifact_ids(&self) -> &[AuthoredArtifactId] { + self.artifact_ids.as_slice() + } + pub const fn created_at_unix_ms(&self) -> u64 { + self.created_at_unix_ms + } + pub const fn updated_at_unix_ms(&self) -> u64 { + self.updated_at_unix_ms + } + pub const fn revision(&self) -> NonZeroU64 { + self.revision + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct OperationSettlement { + artifacts: u16, + signed: u16, + admitted: u16, + pending: u16, + retryable: u16, + indeterminate: u16, + failed_terminal: u16, + cancelled: u16, +} + +impl OperationSettlement { + pub fn evaluate( + operation: &AuthoredOperation, + artifacts: &[AuthoredArtifact], + ) -> Result<Self, Error> { + if artifacts.len() != operation.artifact_ids.len() + || artifacts.iter().enumerate().any(|(ordinal, artifact)| { + artifact.operation_id != operation.operation_id + || artifact.artifact_id != operation.artifact_ids[ordinal] + || usize::from(artifact.ordinal) != ordinal + || artifact.validate().is_err() + }) + { + return Err(Error::InvalidAuthoredOperation); + } + let mut settlement = Self { + artifacts: u16::try_from(artifacts.len()) + .map_err(|_| Error::InvalidAuthoredOperation)?, + signed: 0, + admitted: 0, + pending: 0, + retryable: 0, + indeterminate: 0, + failed_terminal: 0, + cancelled: 0, + }; + for artifact in artifacts { + if artifact.signed.is_some() { + settlement.signed += 1; + } + if artifact.admission_state.is_admitted() { + settlement.admitted += 1; + } + match artifact.signing_state { + SigningState::Planned => settlement.pending += 1, + SigningState::Retryable => settlement.retryable += 1, + SigningState::Indeterminate => settlement.indeterminate += 1, + SigningState::FailedTerminal => settlement.failed_terminal += 1, + SigningState::Cancelled => settlement.cancelled += 1, + SigningState::Signed => match artifact.admission_state { + AdmissionState::Pending => settlement.pending += 1, + AdmissionState::Retryable => settlement.retryable += 1, + AdmissionState::Rejected => settlement.failed_terminal += 1, + AdmissionState::Cancelled => settlement.cancelled += 1, + AdmissionState::Inserted | AdmissionState::Duplicate => {} + }, + } + } + Ok(settlement) + } + + pub const fn artifacts(self) -> u16 { + self.artifacts + } + pub const fn signed(self) -> u16 { + self.signed + } + pub const fn admitted(self) -> u16 { + self.admitted + } + pub const fn pending(self) -> u16 { + self.pending + } + pub const fn retryable(self) -> u16 { + self.retryable + } + pub const fn indeterminate(self) -> u16 { + self.indeterminate + } + pub const fn failed_terminal(self) -> u16 { + self.failed_terminal + } + pub const fn cancelled(self) -> u16 { + self.cancelled + } + pub const fn is_settled(self) -> bool { + self.pending == 0 && self.retryable == 0 && self.indeterminate == 0 + } +} + +const fn all_zero(bytes: &[u8; 16]) -> bool { + let mut index = 0; + while index < bytes.len() { + if bytes[index] != 0 { + return false; + } + index += 1; + } + true +} + +fn valid_text(value: &str, max: usize) -> bool { + !value.is_empty() + && value.len() <= max + && value == value.trim() + && !value.chars().any(char::is_control) +} + +fn valid_code(value: &str) -> bool { + !value.is_empty() + && value.len() <= WORK_FAILURE_CODE_MAX_BYTES + && value.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'-' | b'.') + }) +} diff --git a/crates/storage/src/error.rs b/crates/storage/src/error.rs @@ -6,6 +6,12 @@ use core::fmt; #[non_exhaustive] #[derive(Clone, Debug, Eq, PartialEq)] pub enum Error { + InvalidAuthoredOperation, + InvalidAuthoredArtifact, + InvalidAuthoredTransition, + InvalidWorkClaim, + InvalidWorkFailure, + InvalidRetrySchedule, InvalidSourceGeneration, InvalidEventSequence, InvalidEventQueryLimit, @@ -117,6 +123,12 @@ pub enum Error { impl fmt::Display for Error { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(match self { + Self::InvalidAuthoredOperation => "storage authored operation is invalid", + Self::InvalidAuthoredArtifact => "storage authored artifact is invalid", + Self::InvalidAuthoredTransition => "storage authored transition is invalid", + Self::InvalidWorkClaim => "storage authored work claim is invalid", + Self::InvalidWorkFailure => "storage authored work failure is invalid", + Self::InvalidRetrySchedule => "storage authored retry schedule is invalid", Self::InvalidSourceGeneration => "storage source generation is invalid", Self::InvalidEventSequence => "storage event sequence is invalid", Self::InvalidEventQueryLimit => "storage event query limit is invalid", @@ -256,6 +268,12 @@ mod tests { #[test] fn every_storage_error_has_a_stable_nonempty_message() { let errors = [ + InvalidAuthoredOperation, + InvalidAuthoredArtifact, + InvalidAuthoredTransition, + InvalidWorkClaim, + InvalidWorkFailure, + InvalidRetrySchedule, InvalidSourceGeneration, InvalidEventSequence, InvalidEventQueryLimit, diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs @@ -2,6 +2,7 @@ #![forbid(unsafe_code)] pub mod atomic; +pub mod authored; pub mod backup; mod error; pub mod event; diff --git a/crates/storage/tests/authored.rs b/crates/storage/tests/authored.rs @@ -0,0 +1,226 @@ +use core::num::{NonZeroU32, NonZeroU64}; +use radroots_event::{GenericEventDraft, SignedEvent, wire::v1::Nip01EventWire}; +use radroots_event_codec::authoring::AuthoredEventPlan; +use radroots_storage::{ + Error, + authored::{ + AdmissionState, ArtifactOrigin, AuthoredArtifact, AuthoredArtifactId, AuthoredOperation, + FailureClass, OperationSettlement, RetrySchedule, SigningState, WorkClaim, WorkFailure, + WorkPhase, + }, + journal::OperationInstanceId, +}; + +const AUTHOR: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; +const CREATED_AT: u64 = 1_800_000_100; + +fn operation_id() -> OperationInstanceId { + OperationInstanceId::new([1; 16]).expect("operation ID") +} + +fn artifact_id(value: u8) -> AuthoredArtifactId { + AuthoredArtifactId::new([value; 16]).expect("artifact ID") +} + +fn plan() -> AuthoredEventPlan { + AuthoredEventPlan::from_generic( + GenericEventDraft::new( + "radroots.social.geochat.v1", + 20_000, + CREATED_AT, + Vec::new(), + "durable authored plan", + AUTHOR, + ) + .expect("draft"), + ) + .expect("plan") +} + +fn signed(plan: &AuthoredEventPlan) -> SignedEvent { + let wire = Nip01EventWire { + id: plan.expected_event_id().to_hex(), + pubkey: plan.author().to_hex(), + created_at: plan.created_at(), + kind: plan.body().kind(), + tags: plan.body().tags().to_vec(), + content: plan.body().content().to_owned(), + sig: "dd".repeat(64), + extra: Default::default(), + }; + let raw = serde_json::to_string(&wire).expect("raw event"); + SignedEvent::from_wire_verified_id(wire, raw).expect("signed event") +} + +fn retry_failure(phase: WorkPhase, at: u64) -> WorkFailure { + WorkFailure::new( + "temporary_failure", + phase, + FailureClass::Retryable, + Some(at), + Some("temporary failure".to_owned()), + ) + .expect("failure") +} + +#[test] +fn claim_failure_and_retry_models_are_bounded_and_fenced() { + let revision = NonZeroU64::MIN; + let claim = + WorkClaim::new([2; 16], "worker-1", NonZeroU64::MIN, 10, 20, revision).expect("claim"); + assert!(claim.matches_fence(&[2; 16], NonZeroU64::MIN, revision, 10)); + assert!(!claim.matches_fence(&[2; 16], NonZeroU64::MIN, revision, 20)); + assert_eq!( + WorkClaim::new([0; 16], "worker", NonZeroU64::MIN, 10, 20, revision), + Err(Error::InvalidWorkClaim) + ); + + let failure = retry_failure(WorkPhase::Signing, 30); + let retry = RetrySchedule::new(NonZeroU32::MIN, 30, failure.clone()).expect("retry"); + assert_eq!(retry.attempt().get(), 1); + assert_eq!(retry.failure(), &failure); + assert_eq!( + WorkFailure::new( + "INVALID", + WorkPhase::Signing, + FailureClass::Terminal, + None, + None, + ), + Err(Error::InvalidWorkFailure) + ); + assert_eq!( + RetrySchedule::new(NonZeroU32::MIN, 31, retry_failure(WorkPhase::Signing, 30),), + Err(Error::InvalidRetrySchedule) + ); +} + +#[test] +fn planned_artifacts_enforce_exact_signing_and_admission_transitions() { + let plan = plan(); + let mut artifact = AuthoredArtifact::planned(artifact_id(2), operation_id(), 0, &plan, 10) + .expect("planned artifact"); + let claim = WorkClaim::new( + [3; 16], + "signer", + NonZeroU64::MIN, + 10, + 20, + artifact.revision(), + ) + .expect("claim"); + artifact + .set_signing_claim(claim, 11) + .expect("claim signing"); + artifact.record_signed(signed(&plan), 12).expect("sign"); + assert_eq!(artifact.signing_state(), SigningState::Signed); + assert!(artifact.signed().is_some()); + assert!(artifact.signing_claim().is_none()); + artifact + .record_admission(AdmissionState::Duplicate, None, None, 13) + .expect("admit duplicate"); + assert!(artifact.admission_state().is_admitted()); + + let other = AuthoredEventPlan::from_generic( + GenericEventDraft::new( + "radroots.social.geochat.v1", + 20_000, + CREATED_AT, + Vec::new(), + "different", + AUTHOR, + ) + .expect("other draft"), + ) + .expect("other plan"); + let mut mismatched = AuthoredArtifact::planned(artifact_id(3), operation_id(), 1, &plan, 10) + .expect("planned artifact"); + assert_eq!( + mismatched.record_signed(signed(&other), 11), + Err(Error::InvalidAuthoredArtifact) + ); + assert_eq!(mismatched.signing_state(), SigningState::Planned); + assert!(mismatched.signed().is_none()); +} + +#[test] +fn imported_artifacts_are_non_resignable_and_settlement_preserves_order() { + let plan = plan(); + let imported = + AuthoredArtifact::imported_signed(artifact_id(2), operation_id(), 0, signed(&plan), 10) + .expect("imported"); + assert_eq!(imported.origin(), ArtifactOrigin::ImportedSigned); + assert!(!imported.origin().is_resignable()); + + let mut planned = + AuthoredArtifact::planned(artifact_id(3), operation_id(), 1, &plan, 10).expect("planned"); + planned.cancel_signing(11).expect("cancel"); + let operation = + AuthoredOperation::new(operation_id(), vec![artifact_id(2), artifact_id(3)], 10) + .expect("operation"); + let settlement = + OperationSettlement::evaluate(&operation, &[imported, planned]).expect("settlement"); + assert_eq!(settlement.artifacts(), 2); + assert_eq!(settlement.signed(), 1); + assert_eq!(settlement.pending(), 1); + assert_eq!(settlement.cancelled(), 1); + assert!(!settlement.is_settled()); + + assert_eq!( + AuthoredOperation::new(operation_id(), vec![artifact_id(2), artifact_id(2)], 10,), + Err(Error::InvalidAuthoredOperation) + ); +} + +#[test] +fn durable_models_round_trip_and_reject_forged_state() { + let plan = plan(); + let mut artifact = + AuthoredArtifact::planned(artifact_id(2), operation_id(), 0, &plan, 10).expect("planned"); + let failure = retry_failure(WorkPhase::Signing, 20); + artifact + .record_signing_failure( + failure.clone(), + Some(RetrySchedule::new(NonZeroU32::MIN, 20, failure).expect("retry")), + 11, + ) + .expect("retryable signing"); + let json = serde_json::to_string(&artifact).expect("artifact json"); + assert_eq!( + serde_json::from_str::<AuthoredArtifact>(&json).expect("artifact round trip"), + artifact + ); + + let mut forged: serde_json::Value = serde_json::from_str(&json).expect("artifact value"); + forged["signing_state"] = serde_json::json!("signed"); + assert!(serde_json::from_value::<AuthoredArtifact>(forged).is_err()); + + let exact = artifact.plan().expect("durable plan"); + let mut corrupt = exact.wire_json().to_vec(); + corrupt[0] ^= 1; + assert_eq!( + radroots_storage::authored::DurableAuthoredPlan::reconstruct(corrupt), + Err(Error::InvalidAuthoredArtifact) + ); + + let imported = + AuthoredArtifact::imported_signed(artifact_id(4), operation_id(), 0, signed(&plan), 10) + .expect("imported"); + let mut forged_digest = serde_json::to_value(imported).expect("imported value"); + forged_digest["signed"]["raw_json_sha256"] = + serde_json::to_value([0_u8; 32]).expect("digest value"); + assert!(serde_json::from_value::<AuthoredArtifact>(forged_digest).is_err()); + + let operation = + AuthoredOperation::new(operation_id(), vec![artifact_id(2), artifact_id(3)], 10) + .expect("operation"); + let operation_json = serde_json::to_string(&operation).expect("operation json"); + assert_eq!( + serde_json::from_str::<AuthoredOperation>(&operation_json).expect("operation round trip"), + operation + ); + let mut forged_operation: serde_json::Value = + serde_json::from_str(&operation_json).expect("operation value"); + forged_operation["artifact_ids"][1] = forged_operation["artifact_ids"][0].clone(); + assert!(serde_json::from_value::<AuthoredOperation>(forged_operation).is_err()); +}