1use 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
11pub const MAX_BACKUP_NAME_V1_BYTES: usize = 64;
13
14#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
16pub struct BackupNameV1(String);
17
18impl BackupNameV1 {
19 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 #[must_use]
28 pub fn as_str(&self) -> &str {
29 &self.0
30 }
31
32 #[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47pub enum BackupNameV1Error {
48 Empty,
50 TooLong,
52 InvalidFirstByte,
54 InvalidByte {
56 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
96pub const MAX_ARCHIVE_NAME_V1_BYTES: usize = MAX_BACKUP_NAME_V1_BYTES;
98
99#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
104pub struct ArchiveNameV1(BackupNameV1);
105
106impl ArchiveNameV1 {
107 pub fn new(value: impl Into<String>) -> Result<Self, ArchiveNameV1Error> {
109 BackupNameV1::new(value)
110 .map(Self)
111 .map_err(ArchiveNameV1Error)
112 }
113
114 #[must_use]
116 pub fn as_str(&self) -> &str {
117 self.0.as_str()
118 }
119
120 #[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#[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#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
154pub enum ArchiveRestoreStopV1 {
155 LastArchived,
157 AtApplicationSequence(CommitSequence),
159}
160
161#[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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
192pub enum OfflineMaintenanceOperationKind {
193 CreateBackup,
195 RestoreBackup,
197 RetireBackup,
199 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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
217pub enum OfflineMaintenanceReplacementConfirmation {
218 NotProvided,
220 AllowReplaceNonemptyTarget,
222}
223
224#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
226pub enum ContractMigrationOperationKind {
227 Check,
229 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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
244pub enum ContractMigrationApplyConfirmation {
245 NotProvided,
247 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#[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#[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#[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 #[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 #[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 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 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 #[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 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);