use std::error::Error;
use std::fmt;
use crate::{
CommitSequence, ContractBundleHash, ContractMigrationInputHash, MigrationBundleHash,
OfflineMaintenanceInputHash, hash_contract_migration_input, hash_offline_maintenance_input,
};
pub const MAX_BACKUP_NAME_V1_BYTES: usize = 64;
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct BackupNameV1(String);
impl BackupNameV1 {
pub fn new(value: impl Into<String>) -> Result<Self, BackupNameV1Error> {
let value = value.into();
validate_backup_name_v1(&value)?;
Ok(Self(value))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn as_bytes(&self) -> &[u8] {
self.0.as_bytes()
}
}
impl fmt::Display for BackupNameV1 {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(formatter)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum BackupNameV1Error {
Empty,
TooLong,
InvalidFirstByte,
InvalidByte {
index: usize,
},
}
impl fmt::Display for BackupNameV1Error {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::Empty => "backup name must not be empty",
Self::TooLong => "backup name exceeds 64 bytes",
Self::InvalidFirstByte => {
"backup name must start with a lowercase ASCII letter or digit"
}
Self::InvalidByte { .. } => "backup name contains an invalid byte",
})
}
}
impl Error for BackupNameV1Error {}
fn validate_backup_name_v1(value: &str) -> Result<(), BackupNameV1Error> {
let bytes = value.as_bytes();
let Some(first) = bytes.first().copied() else {
return Err(BackupNameV1Error::Empty);
};
if bytes.len() > MAX_BACKUP_NAME_V1_BYTES {
return Err(BackupNameV1Error::TooLong);
}
if !first.is_ascii_lowercase() && !first.is_ascii_digit() {
return Err(BackupNameV1Error::InvalidFirstByte);
}
if let Some(index) = bytes.iter().enumerate().skip(1).find_map(|(index, byte)| {
(!byte.is_ascii_lowercase() && !byte.is_ascii_digit() && !matches!(byte, b'-' | b'_'))
.then_some(index)
}) {
return Err(BackupNameV1Error::InvalidByte { index });
}
Ok(())
}
pub const MAX_ARCHIVE_NAME_V1_BYTES: usize = MAX_BACKUP_NAME_V1_BYTES;
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct ArchiveNameV1(BackupNameV1);
impl ArchiveNameV1 {
pub fn new(value: impl Into<String>) -> Result<Self, ArchiveNameV1Error> {
BackupNameV1::new(value)
.map(Self)
.map_err(ArchiveNameV1Error)
}
#[must_use]
pub fn as_str(&self) -> &str {
self.0.as_str()
}
#[must_use]
pub fn as_bytes(&self) -> &[u8] {
self.0.as_bytes()
}
}
impl fmt::Display for ArchiveNameV1 {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(formatter)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ArchiveNameV1Error(BackupNameV1Error);
impl fmt::Display for ArchiveNameV1Error {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self.0 {
BackupNameV1Error::Empty => "archive name must not be empty",
BackupNameV1Error::TooLong => "archive name exceeds 64 bytes",
BackupNameV1Error::InvalidFirstByte => {
"archive name must start with a lowercase ASCII letter or digit"
}
BackupNameV1Error::InvalidByte { .. } => "archive name contains an invalid byte",
})
}
}
impl Error for ArchiveNameV1Error {}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum ArchiveRestoreStopV1 {
LastArchived,
AtApplicationSequence(CommitSequence),
}
#[must_use]
pub fn archive_restore_input_hash(
backup_name: &BackupNameV1,
archive_name: &ArchiveNameV1,
stop: ArchiveRestoreStopV1,
confirmation: OfflineMaintenanceReplacementConfirmation,
) -> OfflineMaintenanceInputHash {
let mut canonical = Vec::with_capacity(176);
canonical.extend_from_slice(b"riffdb.archive-restore-input/v1\0");
for name in [backup_name.as_bytes(), archive_name.as_bytes()] {
canonical.push(u8::try_from(name.len()).expect("checked names have at most 64 bytes"));
canonical.extend_from_slice(name);
}
canonical.push(confirmation.tag());
match stop {
ArchiveRestoreStopV1::LastArchived => canonical.push(0),
ArchiveRestoreStopV1::AtApplicationSequence(sequence) => {
canonical.push(1);
canonical.extend_from_slice(&sequence.get().to_be_bytes());
}
}
hash_offline_maintenance_input(&canonical)
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum OfflineMaintenanceOperationKind {
CreateBackup,
RestoreBackup,
RetireBackup,
StorageScrub,
}
impl OfflineMaintenanceOperationKind {
const fn tag(self) -> u8 {
match self {
Self::CreateBackup => 1,
Self::RestoreBackup => 2,
Self::RetireBackup => 3,
Self::StorageScrub => 4,
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum OfflineMaintenanceReplacementConfirmation {
NotProvided,
AllowReplaceNonemptyTarget,
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum ContractMigrationOperationKind {
Check,
Apply,
}
impl ContractMigrationOperationKind {
const fn tag(self) -> u8 {
match self {
Self::Check => 1,
Self::Apply => 2,
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum ContractMigrationApplyConfirmation {
NotProvided,
AllowApplyContractMigration,
}
impl ContractMigrationApplyConfirmation {
const fn tag(self) -> u8 {
match self {
Self::NotProvided => 0,
Self::AllowApplyContractMigration => 1,
}
}
}
#[must_use]
pub fn contract_migration_input_hash(
kind: ContractMigrationOperationKind,
parent: ContractBundleHash,
candidate: ContractBundleHash,
migration: MigrationBundleHash,
confirmation: ContractMigrationApplyConfirmation,
) -> ContractMigrationInputHash {
let mut canonical = Vec::with_capacity(99);
canonical.push(1);
canonical.push(kind.tag());
canonical.extend_from_slice(parent.as_bytes());
canonical.extend_from_slice(candidate.as_bytes());
canonical.extend_from_slice(migration.as_bytes());
canonical.push(confirmation.tag());
hash_contract_migration_input(&canonical)
}
impl OfflineMaintenanceReplacementConfirmation {
const fn tag(self) -> u8 {
match self {
Self::NotProvided => 0,
Self::AllowReplaceNonemptyTarget => 1,
}
}
}
#[must_use]
pub fn offline_maintenance_input_hash(
kind: OfflineMaintenanceOperationKind,
backup_name: &BackupNameV1,
confirmation: OfflineMaintenanceReplacementConfirmation,
) -> OfflineMaintenanceInputHash {
let name = backup_name.as_bytes();
let mut canonical = Vec::with_capacity(4 + name.len());
canonical.push(1);
canonical.push(kind.tag());
canonical.push(u8::try_from(name.len()).expect("backup-name length is bounded to 64"));
canonical.extend_from_slice(name);
canonical.push(confirmation.tag());
hash_offline_maintenance_input(&canonical)
}
#[must_use]
pub fn storage_scrub_input_hash() -> OfflineMaintenanceInputHash {
let mut canonical = Vec::with_capacity(33);
canonical.extend_from_slice(b"riffdb.storage-scrub-input/v1\0");
canonical.push(OfflineMaintenanceOperationKind::StorageScrub.tag());
hash_offline_maintenance_input(&canonical)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn archive_name_uses_the_exact_bounded_backup_grammar_without_becoming_a_path() {
for accepted in ["a", "0", "daily-archive", "archive_2026"] {
let name = ArchiveNameV1::new(accepted).expect("checked archive name");
assert_eq!(name.as_str(), accepted);
assert_eq!(name.as_bytes(), accepted.as_bytes());
}
for rejected in [
"",
"-archive",
"_archive",
".maintenance",
"Upper",
"a.b",
"a/b",
"a\\b",
"c:archive",
"archive space",
"café",
"https://sink",
"a\0b",
] {
assert!(ArchiveNameV1::new(rejected).is_err(), "{rejected:?}");
}
assert!(ArchiveNameV1::new("a".repeat(64)).is_ok());
assert!(ArchiveNameV1::new("a".repeat(65)).is_err());
let error = ArchiveNameV1::new("secret/sink").unwrap_err();
assert!(!error.to_string().contains("secret"));
assert!(!format!("{error:?}").contains("secret"));
}
#[test]
fn archive_restore_retry_identity_binds_every_field_and_is_distinct_from_plain_restore() {
use crate::CommitSequence;
use ArchiveRestoreStopV1::{AtApplicationSequence, LastArchived};
use OfflineMaintenanceReplacementConfirmation::{AllowReplaceNonemptyTarget, NotProvided};
let backup = BackupNameV1::new("before-upgrade").unwrap();
let archive = ArchiveNameV1::new("daily").unwrap();
let baseline = archive_restore_input_hash(&backup, &archive, LastArchived, NotProvided);
assert_eq!(
baseline.into_bytes(),
[
0x32, 0xfd, 0x15, 0x67, 0xf6, 0xdd, 0x2b, 0x4d, 0xc6, 0x4c, 0x78, 0xaa, 0x67, 0x54,
0xfe, 0x7d, 0x79, 0x46, 0xe8, 0xc7, 0xb2, 0xfb, 0xbe, 0x24, 0xad, 0x7d, 0xb5, 0xb3,
0xc7, 0x1b, 0x1f, 0x50,
]
);
assert_eq!(
baseline,
archive_restore_input_hash(&backup, &archive, LastArchived, NotProvided)
);
let variants = [
archive_restore_input_hash(
&BackupNameV1::new("other").unwrap(),
&archive,
LastArchived,
NotProvided,
),
archive_restore_input_hash(
&backup,
&ArchiveNameV1::new("other").unwrap(),
LastArchived,
NotProvided,
),
archive_restore_input_hash(&backup, &archive, LastArchived, AllowReplaceNonemptyTarget),
archive_restore_input_hash(
&backup,
&archive,
AtApplicationSequence(CommitSequence::first()),
NotProvided,
),
archive_restore_input_hash(
&backup,
&archive,
AtApplicationSequence(CommitSequence::new(u64::MAX).unwrap()),
NotProvided,
),
offline_maintenance_input_hash(
OfflineMaintenanceOperationKind::RestoreBackup,
&backup,
NotProvided,
),
];
for (index, value) in variants.iter().enumerate() {
assert_ne!(*value, baseline);
assert!(!variants[..index].contains(value));
}
assert_ne!(
archive_restore_input_hash(
&BackupNameV1::new("a").unwrap(),
&ArchiveNameV1::new("bc").unwrap(),
LastArchived,
NotProvided
),
archive_restore_input_hash(
&BackupNameV1::new("ab").unwrap(),
&ArchiveNameV1::new("c").unwrap(),
LastArchived,
NotProvided
),
);
}
#[test]
fn backup_name_v1_accepts_only_the_exact_ascii_grammar() {
for accepted in ["a", "0", "before-upgrade", "backup_2026"] {
assert_eq!(
BackupNameV1::new(accepted).expect("accepted").as_str(),
accepted
);
}
for rejected in [
"",
"-backup",
"_backup",
".maintenance",
"Upper",
"a.b",
"a/b",
"a\\b",
"c:backup",
"backup space",
"café",
] {
assert!(BackupNameV1::new(rejected).is_err(), "{rejected:?}");
}
assert!(BackupNameV1::new("a".repeat(64)).is_ok());
assert_eq!(
BackupNameV1::new("a".repeat(65)),
Err(BackupNameV1Error::TooLong)
);
}
#[test]
fn semantic_input_hash_binds_kind_name_and_confirmation() {
let name = BackupNameV1::new("before-upgrade").expect("name");
let baseline = offline_maintenance_input_hash(
OfflineMaintenanceOperationKind::CreateBackup,
&name,
OfflineMaintenanceReplacementConfirmation::NotProvided,
);
assert_eq!(
baseline.into_bytes(),
[
0xb1, 0x5c, 0x8c, 0x4a, 0x4f, 0x1e, 0xbb, 0x53, 0x9a, 0xeb, 0x86, 0xef, 0x0e, 0x60,
0x30, 0x53, 0x03, 0xa1, 0xb8, 0xff, 0xd8, 0x6b, 0xf4, 0xef, 0xfc, 0xfa, 0xc7, 0xe5,
0x9c, 0xf9, 0xfc, 0x31,
]
);
assert_eq!(
baseline,
offline_maintenance_input_hash(
OfflineMaintenanceOperationKind::CreateBackup,
&name,
OfflineMaintenanceReplacementConfirmation::NotProvided,
)
);
assert_ne!(
baseline,
offline_maintenance_input_hash(
OfflineMaintenanceOperationKind::RestoreBackup,
&name,
OfflineMaintenanceReplacementConfirmation::NotProvided,
)
);
assert_ne!(
baseline,
offline_maintenance_input_hash(
OfflineMaintenanceOperationKind::RetireBackup,
&name,
OfflineMaintenanceReplacementConfirmation::NotProvided,
)
);
assert_ne!(
baseline,
offline_maintenance_input_hash(
OfflineMaintenanceOperationKind::CreateBackup,
&name,
OfflineMaintenanceReplacementConfirmation::AllowReplaceNonemptyTarget,
)
);
}
#[test]
fn migration_input_hash_binds_every_semantic_identity() {
let parent = ContractBundleHash::from_bytes([1; 32]);
let candidate = ContractBundleHash::from_bytes([2; 32]);
let migration = MigrationBundleHash::from_bytes([3; 32]);
let baseline = contract_migration_input_hash(
ContractMigrationOperationKind::Check,
parent,
candidate,
migration,
ContractMigrationApplyConfirmation::NotProvided,
);
assert_eq!(
baseline,
contract_migration_input_hash(
ContractMigrationOperationKind::Check,
parent,
candidate,
migration,
ContractMigrationApplyConfirmation::NotProvided,
)
);
assert_ne!(
baseline,
contract_migration_input_hash(
ContractMigrationOperationKind::Apply,
parent,
candidate,
migration,
ContractMigrationApplyConfirmation::AllowApplyContractMigration,
)
);
}
#[test]
fn storage_scrub_input_identity_is_fixed_and_disjoint_from_every_backup_input() {
assert_eq!(storage_scrub_input_hash(), storage_scrub_input_hash());
let pinned: String = storage_scrub_input_hash()
.as_bytes()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect();
assert_eq!(
pinned,
"9b2940429ed8e93442aae0ee1dbfb27019fe8ad2f3795899c7cc12d271ed4252"
);
let name = BackupNameV1::new("a").expect("checked name");
for kind in [
OfflineMaintenanceOperationKind::CreateBackup,
OfflineMaintenanceOperationKind::RestoreBackup,
OfflineMaintenanceOperationKind::RetireBackup,
OfflineMaintenanceOperationKind::StorageScrub,
] {
for confirmation in [
OfflineMaintenanceReplacementConfirmation::NotProvided,
OfflineMaintenanceReplacementConfirmation::AllowReplaceNonemptyTarget,
] {
assert_ne!(
offline_maintenance_input_hash(kind, &name, confirmation),
storage_scrub_input_hash()
);
}
}
assert_ne!(
archive_restore_input_hash(
&name,
&ArchiveNameV1::new("a").expect("checked archive"),
ArchiveRestoreStopV1::LastArchived,
OfflineMaintenanceReplacementConfirmation::NotProvided,
),
storage_scrub_input_hash()
);
}
}
crate::inspect_owned_capacity_tuple_v1!(BackupNameV1, String);