1use serde::{Deserialize, Serialize};
6use std::fmt;
7use thiserror::Error;
8use uuid::Uuid;
9
10const SERVER_GENERATED_ID_BODY_LEN: usize = 32;
11
12macro_rules! validation_error {
17 ($name:ident, $message:literal) => {
18 #[derive(Debug, Clone, PartialEq, Eq, Error)]
20 #[error($message)]
21 pub struct $name {
22 value: String,
23 reason: String,
24 }
25
26 impl $name {
27 pub fn value(&self) -> &str {
29 &self.value
30 }
31
32 pub fn reason(&self) -> &str {
34 &self.reason
35 }
36 }
37 };
38}
39
40validation_error!(
41 NamespaceIdValidationError,
42 "invalid namespace_id {value:?}: {reason}"
43);
44validation_error!(
45 CommitIdValidationError,
46 "invalid commit_id {value:?}: {reason}"
47);
48validation_error!(
49 GeneratedIdValidationError,
50 "invalid generated id {value:?}: {reason}"
51);
52validation_error!(
53 NameKeyValidationError,
54 "invalid name_key {value:?}: {reason}"
55);
56
57macro_rules! string_id {
80 (
81 $(#[$meta:meta])*
82 $name:ident,
83 error = $error:ty,
84 validate = $validate:expr
85 ) => {
86 $(#[$meta])*
87 #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
88 #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
89 #[cfg_attr(feature = "openapi", schema(value_type = String))]
90 pub struct $name(String);
91
92 impl $name {
93 pub fn parse(value: impl AsRef<str>) -> Result<Self, $error> {
95 let value = value.as_ref();
96 ($validate)(value)?;
97 Ok(Self(value.to_owned()))
98 }
99
100 pub fn as_str(&self) -> &str {
102 &self.0
103 }
104 }
105
106 impl TryFrom<&str> for $name {
107 type Error = $error;
108
109 fn try_from(value: &str) -> Result<Self, Self::Error> {
110 Self::parse(value)
111 }
112 }
113
114 impl TryFrom<String> for $name {
115 type Error = $error;
116
117 fn try_from(value: String) -> Result<Self, Self::Error> {
118 Self::parse(value)
119 }
120 }
121
122 impl std::str::FromStr for $name {
123 type Err = $error;
124
125 fn from_str(value: &str) -> Result<Self, Self::Err> {
126 Self::parse(value)
127 }
128 }
129
130 impl AsRef<str> for $name {
131 fn as_ref(&self) -> &str {
132 self.as_str()
133 }
134 }
135
136 impl std::borrow::Borrow<str> for $name {
137 fn borrow(&self) -> &str {
138 self.as_str()
139 }
140 }
141
142 impl std::fmt::Display for $name {
143 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144 f.write_str(&self.0)
145 }
146 }
147
148 impl serde::Serialize for $name {
149 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
150 where
151 S: serde::Serializer,
152 {
153 serializer.serialize_str(&self.0)
154 }
155 }
156
157 impl<'de> serde::Deserialize<'de> for $name {
158 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
159 where
160 D: serde::Deserializer<'de>,
161 {
162 let value = <String as serde::Deserialize>::deserialize(deserializer)?;
163 Self::parse(value).map_err(serde::de::Error::custom)
164 }
165 }
166 };
167 (
168 $(#[$meta:meta])*
169 $name:ident,
170 prefix = $prefix:literal
171 ) => {
172 string_id! {
173 $(#[$meta])*
174 $name,
175 error = GeneratedIdValidationError,
176 validate = |value: &str| validate_generated_id($prefix, value)
177 }
178
179 impl $name {
180 pub fn generate() -> Self {
182 Self(generated_id($prefix))
183 }
184 }
185 };
186}
187
188macro_rules! numeric_id {
195 (
196 $(#[$meta:meta])*
197 $name:ident
198 ) => {
199 $(#[$meta])*
200 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
201 #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
202 #[cfg_attr(feature = "openapi", schema(value_type = u64))]
203 pub struct $name(pub u64);
204
205 impl From<u64> for $name {
206 fn from(value: u64) -> Self {
207 Self(value)
208 }
209 }
210
211 impl fmt::Display for $name {
212 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
213 write!(f, "{}", self.0)
214 }
215 }
216 };
217}
218
219pub(crate) use string_id;
220
221pub fn generated_id(prefix: &'static str) -> String {
234 format!("{prefix}_{}", Uuid::new_v4().simple())
235}
236
237fn generated_position_suffix() -> String {
238 let suffix = Uuid::new_v4().simple().to_string();
239 suffix[..16].to_owned()
240}
241
242fn random_128() -> [u8; 16] {
248 let mut bytes = [0_u8; 16];
249 getrandom::fill(&mut bytes).expect("the system random generator must be available");
250 bytes
251}
252
253fn validate_generated_id(
254 prefix: &'static str,
255 value: &str,
256) -> Result<(), GeneratedIdValidationError> {
257 let expected_prefix = format!("{prefix}_");
258 let Some(body) = value.strip_prefix(&expected_prefix) else {
259 return Err(generated_id_error(
260 value,
261 format!("must start with `{expected_prefix}`"),
262 ));
263 };
264 if body.len() != SERVER_GENERATED_ID_BODY_LEN {
265 return Err(generated_id_error(
266 value,
267 format!("body must be {SERVER_GENERATED_ID_BODY_LEN} lowercase hex characters"),
268 ));
269 }
270 if !body.bytes().all(is_lower_hex_byte) {
271 return Err(generated_id_error(
272 value,
273 "body must contain only lowercase hex characters".to_owned(),
274 ));
275 }
276 Ok(())
277}
278
279fn validate_namespace_id(value: &str) -> Result<(), NamespaceIdValidationError> {
280 validate_id_grammar(value).map_err(|reason| namespace_id_error(value, reason))?;
281 if value.starts_with("loonfs-") {
284 return Err(namespace_id_error(
285 value,
286 "the `loonfs-` prefix is reserved for LoonFS system namespaces",
287 ));
288 }
289 Ok(())
290}
291
292fn validate_commit_id(value: &str) -> Result<(), CommitIdValidationError> {
293 validate_id_grammar(value).map_err(|reason| commit_id_error(value, reason))
294}
295
296pub const MAX_NAME_KEY_BYTES: usize = 768;
301pub const MAX_ID_BYTES: usize = 128;
303
304fn validate_name_key(value: &str) -> Result<(), NameKeyValidationError> {
305 if value.is_empty() {
306 return Err(name_key_error(value, "must not be empty"));
307 }
308 if value.contains('/') {
309 return Err(name_key_error(value, "must not contain `/`"));
310 }
311 if matches!(value, "." | "..") {
312 return Err(name_key_error(value, "must not be `.` or `..`"));
313 }
314 if value.chars().any(|character| character.is_control()) {
315 return Err(name_key_error(value, "must not contain control characters"));
316 }
317 if value.len() > MAX_NAME_KEY_BYTES {
318 return Err(name_key_error(
320 "",
321 format!("exceeds the maximum name key length of {MAX_NAME_KEY_BYTES} bytes"),
322 ));
323 }
324 Ok(())
325}
326
327fn validate_position_suffix_id(
328 value: &str,
329 position_label: (&str, &str),
330) -> Result<(), GeneratedIdValidationError> {
331 let Some((position, suffix)) = value.split_once('-') else {
332 return Err(generated_id_error(
333 value,
334 format!(
335 "must be `<20 digit {}>-<16 lowercase hex>`",
336 position_label.0
337 ),
338 ));
339 };
340 if position.len() != 20 || !position.bytes().all(|byte| byte.is_ascii_digit()) {
341 return Err(generated_id_error(
342 value,
343 format!("{} prefix must be 20 decimal digits", position_label.1),
344 ));
345 }
346 if suffix.len() != 16 || !suffix.bytes().all(is_lower_hex_byte) {
347 return Err(generated_id_error(
348 value,
349 "suffix must be 16 lowercase hex characters".to_owned(),
350 ));
351 }
352 Ok(())
353}
354
355fn is_lower_hex_byte(byte: u8) -> bool {
356 byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)
357}
358
359fn validate_id_grammar(value: &str) -> Result<(), String> {
360 if value.is_empty() {
361 return Err("must not be empty".to_owned());
362 }
363 if value.len() > MAX_ID_BYTES {
364 return Err(format!("must be {MAX_ID_BYTES} bytes or fewer"));
365 }
366 if value.trim() != value {
367 return Err("must not have leading or trailing whitespace".to_owned());
368 }
369 if matches!(value, "." | "..") {
370 return Err("must not be `.` or `..`".to_owned());
371 }
372
373 let mut chars = value.chars();
374 let first = chars
375 .next()
376 .expect("empty id returned before char validation");
377 if !first.is_ascii_lowercase() && !first.is_ascii_digit() {
378 return Err("must start with a lowercase ASCII letter or digit".to_owned());
379 }
380 if !chars.all(is_allowed_id_tail_char) {
381 return Err(
382 "must contain only lowercase ASCII letters, digits, `.`, `_`, or `-`".to_owned(),
383 );
384 }
385
386 Ok(())
387}
388
389fn is_allowed_id_tail_char(ch: char) -> bool {
390 ch.is_ascii_lowercase() || ch.is_ascii_digit() || matches!(ch, '.' | '_' | '-')
391}
392
393fn namespace_id_error(value: &str, reason: impl Into<String>) -> NamespaceIdValidationError {
394 NamespaceIdValidationError {
395 value: value.to_owned(),
396 reason: reason.into(),
397 }
398}
399
400fn commit_id_error(value: &str, reason: impl Into<String>) -> CommitIdValidationError {
401 CommitIdValidationError {
402 value: value.to_owned(),
403 reason: reason.into(),
404 }
405}
406
407fn generated_id_error(value: &str, reason: String) -> GeneratedIdValidationError {
408 GeneratedIdValidationError {
409 value: value.to_owned(),
410 reason,
411 }
412}
413
414fn name_key_error(value: &str, reason: impl Into<String>) -> NameKeyValidationError {
415 NameKeyValidationError {
416 value: value.to_owned(),
417 reason: reason.into(),
418 }
419}
420
421string_id! {
426 NamespaceId,
431 error = NamespaceIdValidationError,
432 validate = validate_namespace_id
433}
434
435string_id! {
436 ContentStoreId,
440 prefix = "cs"
441}
442
443string_id! {
444 CommitId,
448 error = CommitIdValidationError,
449 validate = validate_commit_id
450}
451
452impl CommitId {
453 pub fn generate() -> Self {
455 Self(generated_id("c"))
456 }
457}
458
459string_id! {
460 CheckpointId,
464 prefix = "chk"
465}
466
467string_id! {
468 UploadId,
470 prefix = "upl"
471}
472
473string_id! {
474 ContentId,
482 error = GeneratedIdValidationError,
483 validate = |value: &str| validate_generated_id("con", value)
484}
485
486impl ContentId {
487 pub fn generate() -> Self {
489 Self(format!(
490 "con_{}",
491 crate::hex::hex_encode_bytes(&random_128())
492 ))
493 }
494
495 pub fn shard_prefix(&self) -> &str {
500 &self.0[CONTENT_ID_PREFIX_LEN..CONTENT_ID_PREFIX_LEN + CONTENT_ID_SHARD_LEN]
501 }
502}
503
504const CONTENT_ID_PREFIX_LEN: usize = "con_".len();
506const CONTENT_ID_SHARD_LEN: usize = 2;
508
509string_id! {
510 MetadataTableId,
512 prefix = "tbl"
513}
514
515string_id! {
516 IndexSegmentId,
518 prefix = "idx"
519}
520
521string_id! {
522 ManifestObjectId,
524 error = GeneratedIdValidationError,
525 validate = |value| {
526 validate_position_suffix_id(value, ("manifest_id", "manifest id"))
527 }
528}
529
530impl ManifestObjectId {
531 pub fn generate(manifest_id: ManifestId) -> Self {
534 Self(format!(
535 "{:020}-{}",
536 manifest_id.0,
537 generated_position_suffix()
538 ))
539 }
540}
541
542pub fn manifest_object_id_manifest_id(object_id: &str) -> Option<ManifestId> {
544 validate_position_suffix_id(object_id, ("manifest_id", "manifest id")).ok()?;
545 let (position, _) = object_id.split_once('-')?;
546 position.parse().ok().map(ManifestId)
547}
548
549string_id! {
550 WalSegmentId,
552 error = GeneratedIdValidationError,
553 validate = |value| {
554 validate_position_suffix_id(value, ("start_seq", "position"))
555 }
556}
557
558impl WalSegmentId {
559 pub fn generate(start_seq: ChangeSeq) -> Self {
568 Self(format!(
569 "{:020}-{}",
570 start_seq.0,
571 generated_position_suffix()
572 ))
573 }
574}
575
576pub fn wal_segment_id_start_seq(segment_id: &str) -> Option<ChangeSeq> {
583 validate_position_suffix_id(segment_id, ("start_seq", "position")).ok()?;
584 let (position, _) = segment_id.split_once('-')?;
585 position.parse().ok().map(ChangeSeq)
586}
587
588string_id! {
589 NameKey,
594 error = NameKeyValidationError,
595 validate = validate_name_key
596}
597
598impl NameKey {
599 pub fn for_display_name(display_name: &crate::DisplayName) -> Self {
601 Self(crate::name_key_for_display_name(display_name.as_str()))
602 }
603}
604
605numeric_id! {
610 InodeId
614}
615
616pub const ROOT_INODE_ID: InodeId = InodeId(1);
618
619numeric_id! {
620 RevisionNo
622}
623
624numeric_id! {
625 ChangeSeq
629}
630
631numeric_id! {
632 ManifestId
639}
640
641numeric_id! {
642 WriterEpoch
644}
645
646#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
652#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
653#[serde(rename_all = "snake_case")]
654pub enum InodeKind {
655 File,
657 #[serde(rename = "dir")]
662 Directory,
663}
664
665impl fmt::Display for InodeKind {
666 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
667 match self {
668 Self::File => f.write_str("file"),
669 Self::Directory => f.write_str("dir"),
670 }
671 }
672}
673
674#[cfg(test)]
675mod tests {
676 use super::{
677 ChangeSeq, CheckpointId, CommitId, ContentId, ContentStoreId, ManifestId, ManifestObjectId,
678 MetadataTableId, NameKey, NamespaceId, UploadId, WalSegmentId,
679 };
680 use std::collections::BTreeSet;
681
682 #[test]
683 fn namespace_id_parse_accepts_allowed_grammar() {
684 let long_id = format!("a{}", "b".repeat(127));
685 for value in ["demo", "demo-1", "demo_1", "demo.v1", &long_id] {
686 let parsed = NamespaceId::parse(value).expect("valid namespace_id");
687 assert_eq!(parsed.as_str(), value);
688 }
689 }
690
691 #[test]
692 fn namespace_id_parse_rejects_invalid_values() {
693 let long_id = format!("a{}", "b".repeat(128));
694 for value in [
695 "", "/", "a/b", ".", "..", " demo", "demo ", "demo\n", "demo?", "demo#", "demo%",
696 "Demo", &long_id,
697 ] {
698 assert!(
699 NamespaceId::parse(value).is_err(),
700 "expected invalid namespace_id {value:?}"
701 );
702 }
703 }
704
705 #[test]
706 fn namespace_id_parse_rejects_reserved_system_prefix() {
707 assert!(NamespaceId::parse("loonfs-doctor-abc").is_err());
708 assert!(NamespaceId::parse("loonfs-").is_err());
709 assert_eq!(
711 NamespaceId::parse("my-loonfs-notes")
712 .expect("non-prefixed use is allowed")
713 .as_str(),
714 "my-loonfs-notes"
715 );
716 assert!(CommitId::parse("loonfs-retry-1").is_ok());
718 }
719
720 #[test]
721 fn commit_id_parse_uses_same_allowed_grammar() {
722 let parsed = CommitId::parse("c_demo-1").expect("valid commit_id");
723
724 assert_eq!(parsed.as_str(), "c_demo-1");
725 assert!(CommitId::parse("c/demo").is_err());
726 assert!(CommitId::parse("C_demo").is_err());
727 }
728
729 #[test]
730 fn identity_try_from_validates_values() {
731 assert_eq!(
732 NamespaceId::try_from("demo")
733 .expect("valid namespace id")
734 .as_str(),
735 "demo"
736 );
737 assert_eq!(
738 CommitId::try_from("commit-1")
739 .expect("valid commit id")
740 .as_str(),
741 "commit-1"
742 );
743 assert_eq!(
744 ContentStoreId::try_from("cs_00000000000000000000000000000001")
745 .expect("valid content store id")
746 .as_str(),
747 "cs_00000000000000000000000000000001"
748 );
749 assert_eq!(
750 CheckpointId::try_from("chk_00000000000000000000000000000001")
751 .expect("valid checkpoint id")
752 .as_str(),
753 "chk_00000000000000000000000000000001"
754 );
755 assert_eq!(
756 NameKey::try_from("report.txt".to_owned())
757 .expect("valid name key")
758 .as_str(),
759 "report.txt"
760 );
761 assert_eq!(
762 ManifestObjectId::try_from("00000000000000000042-0123456789abcdef")
763 .expect("valid manifest object id")
764 .as_str(),
765 "00000000000000000042-0123456789abcdef"
766 );
767
768 assert!(NamespaceId::try_from("invalid/name").is_err());
769 assert!(CommitId::try_from("invalid/name").is_err());
770 assert!(ContentStoreId::try_from("cs_0000000000000000000000000000000g").is_err());
771 assert!(CheckpointId::try_from("chk_0000000000000000000000000000000g").is_err());
772 assert!(NameKey::try_from("a/b").is_err());
773 assert!(ManifestObjectId::try_from("42-0123456789abcdef").is_err());
774 }
775
776 #[test]
777 fn identity_from_str_delegates_to_parse() {
778 let namespace_id: NamespaceId = "demo".parse().expect("valid namespace id");
779 assert_eq!(namespace_id.as_str(), "demo");
780 assert!("invalid/name".parse::<NamespaceId>().is_err());
781 }
782
783 #[test]
784 fn identity_deserialize_validates_values() {
785 let namespace_id: NamespaceId =
786 serde_json::from_str(r#""demo""#).expect("valid namespace id json");
787 assert_eq!(namespace_id.as_str(), "demo");
788 let commit_id: CommitId =
789 serde_json::from_str(r#""commit-1""#).expect("valid commit id json");
790 assert_eq!(commit_id.as_str(), "commit-1");
791 let content_store_id: ContentStoreId =
792 serde_json::from_str(r#""cs_00000000000000000000000000000001""#)
793 .expect("valid content store id json");
794 assert_eq!(
795 content_store_id.as_str(),
796 "cs_00000000000000000000000000000001"
797 );
798 let checkpoint_id: CheckpointId =
799 serde_json::from_str(r#""chk_00000000000000000000000000000001""#)
800 .expect("valid checkpoint id json");
801 assert_eq!(
802 checkpoint_id.as_str(),
803 "chk_00000000000000000000000000000001"
804 );
805
806 let namespace_error = serde_json::from_str::<NamespaceId>(r#""invalid/name""#)
807 .expect_err("invalid namespace id json");
808 assert!(namespace_error.to_string().contains("namespace_id"));
809 let commit_error = serde_json::from_str::<CommitId>(r#""invalid/name""#)
810 .expect_err("invalid commit id json");
811 assert!(commit_error.to_string().contains("commit_id"));
812 let content_store_error =
813 serde_json::from_str::<ContentStoreId>(r#""cs_0000000000000000000000000000000g""#)
814 .expect_err("invalid content store id json");
815 assert!(content_store_error.to_string().contains("generated id"));
816 let checkpoint_error =
817 serde_json::from_str::<CheckpointId>(r#""chk_0000000000000000000000000000000g""#)
818 .expect_err("invalid checkpoint id json");
819 assert!(checkpoint_error.to_string().contains("generated id"));
820 }
821
822 #[test]
823 fn generated_content_store_id_parse_requires_prefix_and_lower_hex_body() {
824 let parsed = ContentStoreId::parse("cs_00000000000000000000000000000001")
825 .expect("valid content store id");
826
827 assert_eq!(parsed.as_str(), "cs_00000000000000000000000000000001");
828 let hyphenated_content_store_id = ["cs", "1"].join("-");
829 for value in [
830 hyphenated_content_store_id.as_str(),
831 "upl_00000000000000000000000000000001",
832 "content-stores/foo",
833 "cs_",
834 "cs_abcdef",
835 "cs_0000000000000000000000000000000",
836 "cs_000000000000000000000000000000001",
837 "cs_ABCDEF00000000000000000000000000",
838 "cs_0000000000000000000000000000000g",
839 " cs_00000000000000000000000000000001",
840 "cs_00000000000000000000000000000001 ",
841 ] {
842 assert!(
843 ContentStoreId::parse(value).is_err(),
844 "expected invalid content store id {value:?}"
845 );
846 }
847 }
848
849 #[test]
850 fn generated_upload_wal_segment_table_and_checkpoint_ids_reject_hyphenated_ids() {
851 assert!(UploadId::parse("upl_00000000000000000000000000000001").is_ok());
852 assert!(MetadataTableId::parse("tbl_00000000000000000000000000000001").is_ok());
853 assert!(CheckpointId::parse("chk_00000000000000000000000000000001").is_ok());
854 assert!(UploadId::parse(["upl", "123"].join("-")).is_err());
855 assert!(WalSegmentId::parse("00000000000000000412-9f2a6c0e4b7d4a90").is_ok());
856 assert!(ManifestObjectId::parse("00000000000000000412-9f2a6c0e4b7d4a90").is_ok());
857 assert!(WalSegmentId::parse("412-9f2a6c0e4b7d4a90").is_err());
858 assert!(WalSegmentId::parse("00000000000000000412-9F2A6C0E4B7D4A90").is_err());
859 assert!(ManifestObjectId::parse("412-9f2a6c0e4b7d4a90").is_err());
860 assert!(ManifestObjectId::parse("00000000000000000412-9F2A6C0E4B7D4A90").is_err());
861 assert!(ManifestObjectId::parse("mf_9f2a6c0e4b7d4a90b13f0d8c5e6a2b41").is_err());
862 assert!(WalSegmentId::parse("seg_9f2a6c0e4b7d4a90b13f0d8c5e6a2b41").is_err());
863 assert!(MetadataTableId::parse(["tbl", "123"].join("-")).is_err());
864 assert!(CheckpointId::parse(["chk", "123"].join("-")).is_err());
865 }
866
867 #[test]
868 fn generated_runtime_ids_use_lower_hex_uuid_bodies() {
869 let upload_id = UploadId::generate();
870 let wal_segment_id = WalSegmentId::generate(ChangeSeq(412));
871 let manifest_object_id = ManifestObjectId::generate(ManifestId(413));
872 let metadata_table_id = MetadataTableId::generate();
873 let checkpoint_id = CheckpointId::generate();
874
875 assert_generated_id_shape(upload_id.as_str(), "upl");
876 assert!(wal_segment_id.as_str().starts_with("00000000000000000412-"));
877 assert!(manifest_object_id
878 .as_str()
879 .starts_with("00000000000000000413-"));
880 assert_generated_id_shape(metadata_table_id.as_str(), "tbl");
881 assert_generated_id_shape(checkpoint_id.as_str(), "chk");
882 assert!(UploadId::parse(upload_id.as_str()).is_ok());
883 assert!(WalSegmentId::parse(wal_segment_id.as_str()).is_ok());
884 assert!(ManifestObjectId::parse(manifest_object_id.as_str()).is_ok());
885 assert!(MetadataTableId::parse(metadata_table_id.as_str()).is_ok());
886 assert!(CheckpointId::parse(checkpoint_id.as_str()).is_ok());
887 }
888
889 #[test]
890 fn generated_wal_segment_ids_are_not_reused_across_samples() {
891 let mut ids = BTreeSet::new();
894 for _ in 0..128 {
895 let id = WalSegmentId::generate(ChangeSeq(412));
896 assert!(
897 ids.insert(id.clone()),
898 "generated duplicate WAL segment id {id}"
899 );
900 }
901 }
902
903 #[test]
904 fn generated_manifest_object_ids_are_not_reused_across_samples() {
905 let mut ids = BTreeSet::new();
906 for _ in 0..128 {
907 let id = ManifestObjectId::generate(ManifestId(412));
908 assert!(
909 ids.insert(id.clone()),
910 "generated duplicate manifest object id {id}"
911 );
912 }
913 }
914
915 #[test]
916 fn wal_segment_id_start_seq_reads_position_prefix() {
917 assert_eq!(
918 super::wal_segment_id_start_seq("00000000000000000412-9f2a6c0e4b7d4a90"),
919 Some(ChangeSeq(412))
920 );
921 assert_eq!(super::wal_segment_id_start_seq("not-a-segment-id"), None);
922 }
923
924 #[test]
925 fn manifest_object_id_manifest_id_reads_position_prefix() {
926 assert_eq!(
927 super::manifest_object_id_manifest_id("00000000000000000412-9f2a6c0e4b7d4a90"),
928 Some(ManifestId(412))
929 );
930 assert_eq!(
931 super::manifest_object_id_manifest_id("not-a-manifest-object-id"),
932 None
933 );
934 }
935
936 #[test]
937 fn generated_content_ids_are_unique_and_shard_uniformly() {
938 let mut ids = BTreeSet::new();
939 let mut shards = BTreeSet::new();
940 for _ in 0..512 {
941 let id = ContentId::generate();
942 assert_generated_id_shape(id.as_str(), "con");
943 assert_eq!(
944 id.shard_prefix(),
945 &id.as_str()["con_".len().."con_".len() + 2]
946 );
947 shards.insert(id.shard_prefix().to_owned());
948 assert!(
949 ids.insert(id.clone()),
950 "generated duplicate content id {id}"
951 );
952 }
953 assert!(
956 shards.len() > 128,
957 "content id shard prefixes are not spread: {} distinct",
958 shards.len()
959 );
960 }
961
962 #[test]
963 fn content_id_parse_requires_the_generated_id_shape() {
964 assert!(ContentId::parse("con_0123456789abcdef0123456789abcdef").is_ok());
965 for value in [
966 "con_",
967 "con_abcdef",
968 "con_0123456789ABCDEF0123456789abcdef",
969 "con_0123456789abcdef0123456789abcde",
970 "upl_0123456789abcdef0123456789abcdef",
971 "0123456789abcdef0123456789abcdef",
972 ] {
973 assert!(
974 ContentId::parse(value).is_err(),
975 "expected invalid content id {value:?}"
976 );
977 }
978 }
979
980 fn assert_generated_id_shape(value: &str, prefix: &str) {
981 let expected_prefix = format!("{prefix}_");
982 let body = value
983 .strip_prefix(&expected_prefix)
984 .expect("generated id prefix");
985 assert_eq!(body.len(), 32);
986 assert!(
987 body.bytes()
988 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)),
989 "generated id body must be lowercase hex: {value}"
990 );
991 }
992
993 #[test]
994 fn name_key_parse_rejects_invalid_values() {
995 assert_eq!(
996 NameKey::parse("").expect_err("empty").reason(),
997 "must not be empty"
998 );
999 assert_eq!(
1000 NameKey::parse("a/b").expect_err("slash").reason(),
1001 "must not contain `/`"
1002 );
1003 assert_eq!(
1004 NameKey::parse(".").expect_err("dot").reason(),
1005 "must not be `.` or `..`"
1006 );
1007 assert_eq!(
1008 NameKey::parse("a\u{0}b").expect_err("control").reason(),
1009 "must not contain control characters"
1010 );
1011 NameKey::parse("k".repeat(super::MAX_NAME_KEY_BYTES)).expect("cap is inclusive");
1012 assert_eq!(
1013 NameKey::parse("k".repeat(super::MAX_NAME_KEY_BYTES + 1))
1014 .expect_err("over cap")
1015 .reason(),
1016 "exceeds the maximum name key length of 768 bytes"
1017 );
1018 }
1019
1020 #[test]
1021 fn name_key_serializes_as_string_and_validates_deserialize() {
1022 let name_key = NameKey::parse("report.txt").expect("valid name key");
1023
1024 assert_eq!(
1025 serde_json::to_string(&name_key).expect("serialize name key"),
1026 "\"report.txt\""
1027 );
1028 assert_eq!(
1029 serde_json::from_str::<NameKey>("\"report.txt\"").expect("deserialize name key"),
1030 name_key
1031 );
1032 assert!(serde_json::from_str::<NameKey>("\"a/b\"").is_err());
1033 }
1034}