Skip to main content

riffdb_types/
maintenance.rs

1//! Checked identities and canonical input for offline maintenance.
2
3use std::error::Error;
4use std::fmt;
5
6use crate::{
7    CommitSequence, ContractBundleHash, ContractMigrationInputHash, MigrationBundleHash,
8    OfflineMaintenanceInputHash, hash_contract_migration_input, hash_offline_maintenance_input,
9};
10
11/// Maximum bytes in one public backup name.
12pub const MAX_BACKUP_NAME_V1_BYTES: usize = 64;
13
14/// A checked immutable backup name beneath the server-owned backup root.
15#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
16pub struct BackupNameV1(String);
17
18impl BackupNameV1 {
19    /// Validates the exact v1 backup-name grammar without normalization.
20    pub fn new(value: impl Into<String>) -> Result<Self, BackupNameV1Error> {
21        let value = value.into();
22        validate_backup_name_v1(&value)?;
23        Ok(Self(value))
24    }
25
26    /// Borrows the exact ASCII backup name.
27    #[must_use]
28    pub fn as_str(&self) -> &str {
29        &self.0
30    }
31
32    /// Borrows the exact ASCII identity bytes.
33    #[must_use]
34    pub fn as_bytes(&self) -> &[u8] {
35        self.0.as_bytes()
36    }
37}
38
39impl fmt::Display for BackupNameV1 {
40    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
41        self.0.fmt(formatter)
42    }
43}
44
45/// A safe backup-name validation failure.
46#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47pub enum BackupNameV1Error {
48    /// The name is empty.
49    Empty,
50    /// The name exceeds the v1 byte limit.
51    TooLong,
52    /// The first byte is not lowercase ASCII or a digit.
53    InvalidFirstByte,
54    /// A later byte is outside lowercase ASCII, digits, `-`, and `_`.
55    InvalidByte {
56        /// The zero-based byte offset.
57        index: usize,
58    },
59}
60
61impl fmt::Display for BackupNameV1Error {
62    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
63        formatter.write_str(match self {
64            Self::Empty => "backup name must not be empty",
65            Self::TooLong => "backup name exceeds 64 bytes",
66            Self::InvalidFirstByte => {
67                "backup name must start with a lowercase ASCII letter or digit"
68            }
69            Self::InvalidByte { .. } => "backup name contains an invalid byte",
70        })
71    }
72}
73
74impl Error for BackupNameV1Error {}
75
76fn validate_backup_name_v1(value: &str) -> Result<(), BackupNameV1Error> {
77    let bytes = value.as_bytes();
78    let Some(first) = bytes.first().copied() else {
79        return Err(BackupNameV1Error::Empty);
80    };
81    if bytes.len() > MAX_BACKUP_NAME_V1_BYTES {
82        return Err(BackupNameV1Error::TooLong);
83    }
84    if !first.is_ascii_lowercase() && !first.is_ascii_digit() {
85        return Err(BackupNameV1Error::InvalidFirstByte);
86    }
87    if let Some(index) = bytes.iter().enumerate().skip(1).find_map(|(index, byte)| {
88        (!byte.is_ascii_lowercase() && !byte.is_ascii_digit() && !matches!(byte, b'-' | b'_'))
89            .then_some(index)
90    }) {
91        return Err(BackupNameV1Error::InvalidByte { index });
92    }
93    Ok(())
94}
95
96/// Maximum bytes in one configured archive name.
97pub const MAX_ARCHIVE_NAME_V1_BYTES: usize = MAX_BACKUP_NAME_V1_BYTES;
98
99/// A checked operator-configured archive name, never a filesystem path or URI.
100///
101/// Its distinct type prevents a backup identity from silently selecting an
102/// archive. The accepted grammar is exactly the bounded backup-name grammar.
103#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
104pub struct ArchiveNameV1(BackupNameV1);
105
106impl ArchiveNameV1 {
107    /// Checks the exact ASCII grammar without normalization or path resolution.
108    pub fn new(value: impl Into<String>) -> Result<Self, ArchiveNameV1Error> {
109        BackupNameV1::new(value)
110            .map(Self)
111            .map_err(ArchiveNameV1Error)
112    }
113
114    /// Borrows the exact configured archive name.
115    #[must_use]
116    pub fn as_str(&self) -> &str {
117        self.0.as_str()
118    }
119
120    /// Borrows the canonical ASCII name bytes.
121    #[must_use]
122    pub fn as_bytes(&self) -> &[u8] {
123        self.0.as_bytes()
124    }
125}
126
127impl fmt::Display for ArchiveNameV1 {
128    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
129        self.0.fmt(formatter)
130    }
131}
132
133/// A bounded archive-name validation failure that retains no rejected input.
134#[derive(Clone, Copy, Debug, Eq, PartialEq)]
135pub struct ArchiveNameV1Error(BackupNameV1Error);
136
137impl fmt::Display for ArchiveNameV1Error {
138    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
139        formatter.write_str(match self.0 {
140            BackupNameV1Error::Empty => "archive name must not be empty",
141            BackupNameV1Error::TooLong => "archive name exceeds 64 bytes",
142            BackupNameV1Error::InvalidFirstByte => {
143                "archive name must start with a lowercase ASCII letter or digit"
144            }
145            BackupNameV1Error::InvalidByte { .. } => "archive name contains an invalid byte",
146        })
147    }
148}
149
150impl Error for ArchiveNameV1Error {}
151
152/// Exact application-sequence selection for an offline archive restore.
153#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
154pub enum ArchiveRestoreStopV1 {
155    /// Resolve once to a verified terminal archive selection before replay.
156    LastArchived,
157    /// Restore exactly this application sequence, without rounding to a frame.
158    AtApplicationSequence(CommitSequence),
159}
160
161/// Binds every archive restore input to a distinct canonical input domain.
162///
163/// Artifact selection is resolved and frozen separately in receipt V3. In
164/// particular, `LastArchived` never hashes a moving archive head into a request
165/// identity. A fresh transport request ID does not change this retry identity.
166#[must_use]
167pub fn archive_restore_input_hash(
168    backup_name: &BackupNameV1,
169    archive_name: &ArchiveNameV1,
170    stop: ArchiveRestoreStopV1,
171    confirmation: OfflineMaintenanceReplacementConfirmation,
172) -> OfflineMaintenanceInputHash {
173    let mut canonical = Vec::with_capacity(176);
174    canonical.extend_from_slice(b"riffdb.archive-restore-input/v1\0");
175    for name in [backup_name.as_bytes(), archive_name.as_bytes()] {
176        canonical.push(u8::try_from(name.len()).expect("checked names have at most 64 bytes"));
177        canonical.extend_from_slice(name);
178    }
179    canonical.push(confirmation.tag());
180    match stop {
181        ArchiveRestoreStopV1::LastArchived => canonical.push(0),
182        ArchiveRestoreStopV1::AtApplicationSequence(sequence) => {
183            canonical.push(1);
184            canonical.extend_from_slice(&sequence.get().to_be_bytes());
185        }
186    }
187    hash_offline_maintenance_input(&canonical)
188}
189
190/// The closed semantic kind of one offline maintenance operation.
191#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
192pub enum OfflineMaintenanceOperationKind {
193    /// Publish one immutable offline backup.
194    CreateBackup,
195    /// Restore one immutable backup through private staging.
196    RestoreBackup,
197    /// Permanently retire one previously published immutable backup.
198    RetireBackup,
199    /// Drive the one complete exact-end validation over the closed database
200    /// (ADR-0203). It has no backup name or any other semantic input.
201    StorageScrub,
202}
203
204impl OfflineMaintenanceOperationKind {
205    const fn tag(self) -> u8 {
206        match self {
207            Self::CreateBackup => 1,
208            Self::RestoreBackup => 2,
209            Self::RetireBackup => 3,
210            Self::StorageScrub => 4,
211        }
212    }
213}
214
215/// The caller's exact destructive-replacement confirmation.
216#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
217pub enum OfflineMaintenanceReplacementConfirmation {
218    /// No destructive-replacement confirmation was supplied.
219    NotProvided,
220    /// The caller supplied `ALLOW_REPLACE_NONEMPTY_TARGET`.
221    AllowReplaceNonemptyTarget,
222}
223
224/// The closed semantic kind of one public contract-migration operation.
225#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
226pub enum ContractMigrationOperationKind {
227    /// Run the complete read-only preflight without draining or staging.
228    Check,
229    /// Run the complete offline staged migration lifecycle.
230    Apply,
231}
232
233impl ContractMigrationOperationKind {
234    const fn tag(self) -> u8 {
235        match self {
236            Self::Check => 1,
237            Self::Apply => 2,
238        }
239    }
240}
241
242/// The caller's exact destructive migration confirmation.
243#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
244pub enum ContractMigrationApplyConfirmation {
245    /// No apply confirmation was supplied.
246    NotProvided,
247    /// The caller supplied `ALLOW_APPLY_CONTRACT_MIGRATION`.
248    AllowApplyContractMigration,
249}
250
251impl ContractMigrationApplyConfirmation {
252    const fn tag(self) -> u8 {
253        match self {
254            Self::NotProvided => 0,
255            Self::AllowApplyContractMigration => 1,
256        }
257    }
258}
259
260/// Computes the stable V1 semantic-input identity for one migration request.
261#[must_use]
262pub fn contract_migration_input_hash(
263    kind: ContractMigrationOperationKind,
264    parent: ContractBundleHash,
265    candidate: ContractBundleHash,
266    migration: MigrationBundleHash,
267    confirmation: ContractMigrationApplyConfirmation,
268) -> ContractMigrationInputHash {
269    let mut canonical = Vec::with_capacity(99);
270    canonical.push(1);
271    canonical.push(kind.tag());
272    canonical.extend_from_slice(parent.as_bytes());
273    canonical.extend_from_slice(candidate.as_bytes());
274    canonical.extend_from_slice(migration.as_bytes());
275    canonical.push(confirmation.tag());
276    hash_contract_migration_input(&canonical)
277}
278
279impl OfflineMaintenanceReplacementConfirmation {
280    const fn tag(self) -> u8 {
281        match self {
282            Self::NotProvided => 0,
283            Self::AllowReplaceNonemptyTarget => 1,
284        }
285    }
286}
287
288/// Computes the stable v1 semantic-input identity for one maintenance request.
289#[must_use]
290pub fn offline_maintenance_input_hash(
291    kind: OfflineMaintenanceOperationKind,
292    backup_name: &BackupNameV1,
293    confirmation: OfflineMaintenanceReplacementConfirmation,
294) -> OfflineMaintenanceInputHash {
295    let name = backup_name.as_bytes();
296    let mut canonical = Vec::with_capacity(4 + name.len());
297    canonical.push(1);
298    canonical.push(kind.tag());
299    canonical.push(u8::try_from(name.len()).expect("backup-name length is bounded to 64"));
300    canonical.extend_from_slice(name);
301    canonical.push(confirmation.tag());
302    hash_offline_maintenance_input(&canonical)
303}
304
305/// The storage scrub's fixed semantic-input identity (ADR-0203 Decision 9):
306/// domain-separated over the fixed scrub kind and its empty input. Every
307/// retry of one scrub operation, and every scrub, has this same identity.
308#[must_use]
309pub fn storage_scrub_input_hash() -> OfflineMaintenanceInputHash {
310    let mut canonical = Vec::with_capacity(33);
311    canonical.extend_from_slice(b"riffdb.storage-scrub-input/v1\0");
312    canonical.push(OfflineMaintenanceOperationKind::StorageScrub.tag());
313    hash_offline_maintenance_input(&canonical)
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    // req: REP-007
321    #[test]
322    fn archive_name_uses_the_exact_bounded_backup_grammar_without_becoming_a_path() {
323        for accepted in ["a", "0", "daily-archive", "archive_2026"] {
324            let name = ArchiveNameV1::new(accepted).expect("checked archive name");
325            assert_eq!(name.as_str(), accepted);
326            assert_eq!(name.as_bytes(), accepted.as_bytes());
327        }
328        for rejected in [
329            "",
330            "-archive",
331            "_archive",
332            ".maintenance",
333            "Upper",
334            "a.b",
335            "a/b",
336            "a\\b",
337            "c:archive",
338            "archive space",
339            "café",
340            "https://sink",
341            "a\0b",
342        ] {
343            assert!(ArchiveNameV1::new(rejected).is_err(), "{rejected:?}");
344        }
345        assert!(ArchiveNameV1::new("a".repeat(64)).is_ok());
346        assert!(ArchiveNameV1::new("a".repeat(65)).is_err());
347        let error = ArchiveNameV1::new("secret/sink").unwrap_err();
348        assert!(!error.to_string().contains("secret"));
349        assert!(!format!("{error:?}").contains("secret"));
350    }
351
352    // req: REP-007
353    #[test]
354    fn archive_restore_retry_identity_binds_every_field_and_is_distinct_from_plain_restore() {
355        use crate::CommitSequence;
356        use ArchiveRestoreStopV1::{AtApplicationSequence, LastArchived};
357        use OfflineMaintenanceReplacementConfirmation::{AllowReplaceNonemptyTarget, NotProvided};
358        let backup = BackupNameV1::new("before-upgrade").unwrap();
359        let archive = ArchiveNameV1::new("daily").unwrap();
360        let baseline = archive_restore_input_hash(&backup, &archive, LastArchived, NotProvided);
361        // Independent SHA-256 fixture for the ADR-0011 frame and exact input domain.
362        assert_eq!(
363            baseline.into_bytes(),
364            [
365                0x32, 0xfd, 0x15, 0x67, 0xf6, 0xdd, 0x2b, 0x4d, 0xc6, 0x4c, 0x78, 0xaa, 0x67, 0x54,
366                0xfe, 0x7d, 0x79, 0x46, 0xe8, 0xc7, 0xb2, 0xfb, 0xbe, 0x24, 0xad, 0x7d, 0xb5, 0xb3,
367                0xc7, 0x1b, 0x1f, 0x50,
368            ]
369        );
370        assert_eq!(
371            baseline,
372            archive_restore_input_hash(&backup, &archive, LastArchived, NotProvided)
373        );
374        let variants = [
375            archive_restore_input_hash(
376                &BackupNameV1::new("other").unwrap(),
377                &archive,
378                LastArchived,
379                NotProvided,
380            ),
381            archive_restore_input_hash(
382                &backup,
383                &ArchiveNameV1::new("other").unwrap(),
384                LastArchived,
385                NotProvided,
386            ),
387            archive_restore_input_hash(&backup, &archive, LastArchived, AllowReplaceNonemptyTarget),
388            archive_restore_input_hash(
389                &backup,
390                &archive,
391                AtApplicationSequence(CommitSequence::first()),
392                NotProvided,
393            ),
394            archive_restore_input_hash(
395                &backup,
396                &archive,
397                AtApplicationSequence(CommitSequence::new(u64::MAX).unwrap()),
398                NotProvided,
399            ),
400            offline_maintenance_input_hash(
401                OfflineMaintenanceOperationKind::RestoreBackup,
402                &backup,
403                NotProvided,
404            ),
405        ];
406        for (index, value) in variants.iter().enumerate() {
407            assert_ne!(*value, baseline);
408            assert!(!variants[..index].contains(value));
409        }
410        // Field boundaries are canonical even for names with ambiguous concatenation.
411        assert_ne!(
412            archive_restore_input_hash(
413                &BackupNameV1::new("a").unwrap(),
414                &ArchiveNameV1::new("bc").unwrap(),
415                LastArchived,
416                NotProvided
417            ),
418            archive_restore_input_hash(
419                &BackupNameV1::new("ab").unwrap(),
420                &ArchiveNameV1::new("c").unwrap(),
421                LastArchived,
422                NotProvided
423            ),
424        );
425    }
426
427    #[test]
428    fn backup_name_v1_accepts_only_the_exact_ascii_grammar() {
429        for accepted in ["a", "0", "before-upgrade", "backup_2026"] {
430            assert_eq!(
431                BackupNameV1::new(accepted).expect("accepted").as_str(),
432                accepted
433            );
434        }
435        for rejected in [
436            "",
437            "-backup",
438            "_backup",
439            ".maintenance",
440            "Upper",
441            "a.b",
442            "a/b",
443            "a\\b",
444            "c:backup",
445            "backup space",
446            "café",
447        ] {
448            assert!(BackupNameV1::new(rejected).is_err(), "{rejected:?}");
449        }
450        assert!(BackupNameV1::new("a".repeat(64)).is_ok());
451        assert_eq!(
452            BackupNameV1::new("a".repeat(65)),
453            Err(BackupNameV1Error::TooLong)
454        );
455    }
456
457    #[test]
458    fn semantic_input_hash_binds_kind_name_and_confirmation() {
459        let name = BackupNameV1::new("before-upgrade").expect("name");
460        let baseline = offline_maintenance_input_hash(
461            OfflineMaintenanceOperationKind::CreateBackup,
462            &name,
463            OfflineMaintenanceReplacementConfirmation::NotProvided,
464        );
465        assert_eq!(
466            baseline.into_bytes(),
467            [
468                0xb1, 0x5c, 0x8c, 0x4a, 0x4f, 0x1e, 0xbb, 0x53, 0x9a, 0xeb, 0x86, 0xef, 0x0e, 0x60,
469                0x30, 0x53, 0x03, 0xa1, 0xb8, 0xff, 0xd8, 0x6b, 0xf4, 0xef, 0xfc, 0xfa, 0xc7, 0xe5,
470                0x9c, 0xf9, 0xfc, 0x31,
471            ]
472        );
473        assert_eq!(
474            baseline,
475            offline_maintenance_input_hash(
476                OfflineMaintenanceOperationKind::CreateBackup,
477                &name,
478                OfflineMaintenanceReplacementConfirmation::NotProvided,
479            )
480        );
481        assert_ne!(
482            baseline,
483            offline_maintenance_input_hash(
484                OfflineMaintenanceOperationKind::RestoreBackup,
485                &name,
486                OfflineMaintenanceReplacementConfirmation::NotProvided,
487            )
488        );
489        assert_ne!(
490            baseline,
491            offline_maintenance_input_hash(
492                OfflineMaintenanceOperationKind::RetireBackup,
493                &name,
494                OfflineMaintenanceReplacementConfirmation::NotProvided,
495            )
496        );
497        assert_ne!(
498            baseline,
499            offline_maintenance_input_hash(
500                OfflineMaintenanceOperationKind::CreateBackup,
501                &name,
502                OfflineMaintenanceReplacementConfirmation::AllowReplaceNonemptyTarget,
503            )
504        );
505    }
506
507    #[test]
508    fn migration_input_hash_binds_every_semantic_identity() {
509        let parent = ContractBundleHash::from_bytes([1; 32]);
510        let candidate = ContractBundleHash::from_bytes([2; 32]);
511        let migration = MigrationBundleHash::from_bytes([3; 32]);
512        let baseline = contract_migration_input_hash(
513            ContractMigrationOperationKind::Check,
514            parent,
515            candidate,
516            migration,
517            ContractMigrationApplyConfirmation::NotProvided,
518        );
519        assert_eq!(
520            baseline,
521            contract_migration_input_hash(
522                ContractMigrationOperationKind::Check,
523                parent,
524                candidate,
525                migration,
526                ContractMigrationApplyConfirmation::NotProvided,
527            )
528        );
529        assert_ne!(
530            baseline,
531            contract_migration_input_hash(
532                ContractMigrationOperationKind::Apply,
533                parent,
534                candidate,
535                migration,
536                ContractMigrationApplyConfirmation::AllowApplyContractMigration,
537            )
538        );
539    }
540    // req: REC-008
541    #[test]
542    fn storage_scrub_input_identity_is_fixed_and_disjoint_from_every_backup_input() {
543        assert_eq!(storage_scrub_input_hash(), storage_scrub_input_hash());
544        // The identity is durable in every scrub receipt; pin its exact bytes.
545        let pinned: String = storage_scrub_input_hash()
546            .as_bytes()
547            .iter()
548            .map(|byte| format!("{byte:02x}"))
549            .collect();
550        assert_eq!(
551            pinned,
552            "9b2940429ed8e93442aae0ee1dbfb27019fe8ad2f3795899c7cc12d271ed4252"
553        );
554        let name = BackupNameV1::new("a").expect("checked name");
555        for kind in [
556            OfflineMaintenanceOperationKind::CreateBackup,
557            OfflineMaintenanceOperationKind::RestoreBackup,
558            OfflineMaintenanceOperationKind::RetireBackup,
559            OfflineMaintenanceOperationKind::StorageScrub,
560        ] {
561            for confirmation in [
562                OfflineMaintenanceReplacementConfirmation::NotProvided,
563                OfflineMaintenanceReplacementConfirmation::AllowReplaceNonemptyTarget,
564            ] {
565                assert_ne!(
566                    offline_maintenance_input_hash(kind, &name, confirmation),
567                    storage_scrub_input_hash()
568                );
569            }
570        }
571        assert_ne!(
572            archive_restore_input_hash(
573                &name,
574                &ArchiveNameV1::new("a").expect("checked archive"),
575                ArchiveRestoreStopV1::LastArchived,
576                OfflineMaintenanceReplacementConfirmation::NotProvided,
577            ),
578            storage_scrub_input_hash()
579        );
580    }
581}
582
583crate::inspect_owned_capacity_tuple_v1!(BackupNameV1, String);