1use crate::hex::hex_encode_bytes;
6use serde::{Deserialize, Serialize};
7use std::fmt;
8use thiserror::Error;
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 {
84 (
85 $(#[$meta:meta])*
86 $name:ident,
87 error = $error:ty,
88 validate = $validate:expr
89 $(, schema($($schema:tt)+))?
90 $(,)?
91 ) => {
92 $(#[$meta])*
93 #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
94 #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
95 #[cfg_attr(
96 feature = "openapi",
97 schema(value_type = String $(, $($schema)+)?)
98 )]
99 pub struct $name(String);
100
101 impl $name {
102 pub fn parse(value: impl AsRef<str>) -> Result<Self, $error> {
104 let value = value.as_ref();
105 ($validate)(value)?;
106 Ok(Self(value.to_owned()))
107 }
108
109 pub fn as_str(&self) -> &str {
111 &self.0
112 }
113 }
114
115 impl TryFrom<&str> for $name {
116 type Error = $error;
117
118 fn try_from(value: &str) -> Result<Self, Self::Error> {
119 Self::parse(value)
120 }
121 }
122
123 impl TryFrom<String> for $name {
124 type Error = $error;
125
126 fn try_from(value: String) -> Result<Self, Self::Error> {
127 Self::parse(value)
128 }
129 }
130
131 impl std::str::FromStr for $name {
132 type Err = $error;
133
134 fn from_str(value: &str) -> Result<Self, Self::Err> {
135 Self::parse(value)
136 }
137 }
138
139 impl AsRef<str> for $name {
140 fn as_ref(&self) -> &str {
141 self.as_str()
142 }
143 }
144
145 impl std::borrow::Borrow<str> for $name {
146 fn borrow(&self) -> &str {
147 self.as_str()
148 }
149 }
150
151 impl std::fmt::Display for $name {
152 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153 f.write_str(&self.0)
154 }
155 }
156
157 impl serde::Serialize for $name {
158 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
159 where
160 S: serde::Serializer,
161 {
162 serializer.serialize_str(&self.0)
163 }
164 }
165
166 impl<'de> serde::Deserialize<'de> for $name {
167 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
168 where
169 D: serde::Deserializer<'de>,
170 {
171 let value = <String as serde::Deserialize>::deserialize(deserializer)?;
172 Self::parse(value).map_err(serde::de::Error::custom)
173 }
174 }
175 };
176 (
177 $(#[$meta:meta])*
178 $name:ident,
179 prefix = $prefix:literal
180 $(, schema($($schema:tt)+))?
181 $(,)?
182 ) => {
183 string_id! {
184 $(#[$meta])*
185 $name,
186 error = GeneratedIdValidationError,
187 validate = |value: &str| validate_generated_id($prefix, value)
188 $(, schema($($schema)+))?
189 }
190
191 impl $name {
192 pub fn generate() -> Self {
194 Self(generated_id($prefix))
195 }
196 }
197 };
198}
199
200macro_rules! numeric_id {
207 (
208 $(#[$meta:meta])*
209 $name:ident,
210 public_ordinal,
211 schema_description = $schema_description:literal
212 ) => {
213 $(#[$meta])*
214 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
215 pub struct $name(pub u64);
216
217 impl $name {
218 pub fn parse(value: u64) -> Result<Self, $crate::PublicOrdinalRangeError> {
225 if value > $crate::MAX_PUBLIC_INTEGER {
226 return Err($crate::PublicOrdinalRangeError);
227 }
228 Ok(Self(value))
229 }
230 }
231
232 #[cfg(feature = "openapi")]
233 impl utoipa::PartialSchema for $name {
234 fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
235 utoipa::openapi::schema::Object::builder()
236 .schema_type(utoipa::openapi::schema::Type::Integer)
237 .format(Some(utoipa::openapi::SchemaFormat::KnownFormat(
238 utoipa::openapi::KnownFormat::Int64,
239 )))
240 .minimum(Some(0u64))
241 .maximum(Some($crate::MAX_PUBLIC_INTEGER))
242 .description(Some($schema_description))
243 .into()
244 }
245 }
246
247 #[cfg(feature = "openapi")]
248 impl utoipa::ToSchema for $name {}
249
250 impl<'de> serde::Deserialize<'de> for $name {
251 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
252 where
253 D: serde::Deserializer<'de>,
254 {
255 let value = <u64 as serde::Deserialize>::deserialize(deserializer)?;
256 Self::parse(value).map_err(serde::de::Error::custom)
257 }
258 }
259
260 impl From<u64> for $name {
261 fn from(value: u64) -> Self {
262 Self(value)
263 }
264 }
265
266 impl fmt::Display for $name {
267 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
268 write!(f, "{}", self.0)
269 }
270 }
271 };
272 (
273 $(#[$meta:meta])*
274 $name:ident
275 ) => {
276 $(#[$meta])*
277 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
278 #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
279 #[cfg_attr(feature = "openapi", schema(value_type = u64))]
280 pub struct $name(pub u64);
281
282 impl From<u64> for $name {
283 fn from(value: u64) -> Self {
284 Self(value)
285 }
286 }
287
288 impl fmt::Display for $name {
289 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
290 write!(f, "{}", self.0)
291 }
292 }
293 };
294}
295
296pub(crate) use numeric_id;
297pub(crate) use string_id;
298pub(crate) use validation_error;
299
300pub fn generated_id(prefix: &'static str) -> String {
313 format!("{prefix}_{}", hex_encode_bytes(&random_128()))
314}
315
316fn generated_position_suffix() -> String {
317 let suffix = hex_encode_bytes(&random_128());
318 suffix[..16].to_owned()
319}
320
321fn random_128() -> [u8; 16] {
326 let mut bytes = [0_u8; 16];
327 getrandom::fill(&mut bytes).expect("the system random generator must be available");
328 bytes
329}
330
331fn validate_generated_id(
332 prefix: &'static str,
333 value: &str,
334) -> Result<(), GeneratedIdValidationError> {
335 let expected_prefix = format!("{prefix}_");
336 let Some(body) = value.strip_prefix(&expected_prefix) else {
337 return Err(generated_id_error(
338 value,
339 format!("must start with `{expected_prefix}`"),
340 ));
341 };
342 if body.len() != SERVER_GENERATED_ID_BODY_LEN {
343 return Err(generated_id_error(
344 value,
345 format!("body must be {SERVER_GENERATED_ID_BODY_LEN} lowercase hex characters"),
346 ));
347 }
348 if !body.bytes().all(is_lower_hex_byte) {
349 return Err(generated_id_error(
350 value,
351 "body must contain only lowercase hex characters".to_owned(),
352 ));
353 }
354 Ok(())
355}
356
357fn validate_namespace_id(value: &str) -> Result<(), NamespaceIdValidationError> {
358 validate_id_grammar(value).map_err(|reason| namespace_id_error(value, reason))?;
359 if value.starts_with("loonfs-") {
362 return Err(namespace_id_error(
363 value,
364 "the `loonfs-` prefix is reserved for LoonFS system namespaces",
365 ));
366 }
367 Ok(())
368}
369
370fn validate_commit_id(value: &str) -> Result<(), CommitIdValidationError> {
371 validate_id_grammar(value).map_err(|reason| commit_id_error(value, reason))
372}
373
374pub const MAX_NAME_KEY_BYTES: usize = 768;
379pub const MAX_ID_BYTES: usize = 128;
381
382fn validate_name_key(value: &str) -> Result<(), NameKeyValidationError> {
383 if value.is_empty() {
384 return Err(name_key_error(value, "must not be empty"));
385 }
386 if value.contains('/') {
387 return Err(name_key_error(value, "must not contain `/`"));
388 }
389 if matches!(value, "." | "..") {
390 return Err(name_key_error(value, "must not be `.` or `..`"));
391 }
392 if value.chars().any(|character| character.is_control()) {
393 return Err(name_key_error(value, "must not contain control characters"));
394 }
395 if value.len() > MAX_NAME_KEY_BYTES {
396 return Err(name_key_error(
398 "",
399 format!("exceeds the maximum name key length of {MAX_NAME_KEY_BYTES} bytes"),
400 ));
401 }
402 Ok(())
403}
404
405fn validate_position_suffix_id(
406 value: &str,
407 position_label: (&str, &str),
408) -> Result<(), GeneratedIdValidationError> {
409 let Some((position, suffix)) = value.split_once('-') else {
410 return Err(generated_id_error(
411 value,
412 format!(
413 "must be `<20 digit {}>-<16 lowercase hex>`",
414 position_label.0
415 ),
416 ));
417 };
418 if position.len() != 20 || !position.bytes().all(|byte| byte.is_ascii_digit()) {
419 return Err(generated_id_error(
420 value,
421 format!("{} prefix must be 20 decimal digits", position_label.1),
422 ));
423 }
424 if suffix.len() != 16 || !suffix.bytes().all(is_lower_hex_byte) {
425 return Err(generated_id_error(
426 value,
427 "suffix must be 16 lowercase hex characters".to_owned(),
428 ));
429 }
430 Ok(())
431}
432
433fn is_lower_hex_byte(byte: u8) -> bool {
434 byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)
435}
436
437fn validate_id_grammar(value: &str) -> Result<(), String> {
438 if value.is_empty() {
439 return Err("must not be empty".to_owned());
440 }
441 if value.len() > MAX_ID_BYTES {
442 return Err(format!("must be {MAX_ID_BYTES} bytes or fewer"));
443 }
444 if value.trim() != value {
445 return Err("must not have leading or trailing whitespace".to_owned());
446 }
447 if matches!(value, "." | "..") {
448 return Err("must not be `.` or `..`".to_owned());
449 }
450
451 let mut chars = value.chars();
452 let first = chars
453 .next()
454 .expect("empty id returned before char validation");
455 if !first.is_ascii_lowercase() && !first.is_ascii_digit() {
456 return Err("must start with a lowercase ASCII letter or digit".to_owned());
457 }
458 if !chars.all(is_allowed_id_tail_char) {
459 return Err(
460 "must contain only lowercase ASCII letters, digits, `.`, `_`, or `-`".to_owned(),
461 );
462 }
463
464 Ok(())
465}
466
467fn is_allowed_id_tail_char(ch: char) -> bool {
468 ch.is_ascii_lowercase() || ch.is_ascii_digit() || matches!(ch, '.' | '_' | '-')
469}
470
471fn namespace_id_error(value: &str, reason: impl Into<String>) -> NamespaceIdValidationError {
472 NamespaceIdValidationError {
473 value: value.to_owned(),
474 reason: reason.into(),
475 }
476}
477
478fn commit_id_error(value: &str, reason: impl Into<String>) -> CommitIdValidationError {
479 CommitIdValidationError {
480 value: value.to_owned(),
481 reason: reason.into(),
482 }
483}
484
485fn generated_id_error(value: &str, reason: String) -> GeneratedIdValidationError {
486 GeneratedIdValidationError {
487 value: value.to_owned(),
488 reason,
489 }
490}
491
492fn name_key_error(value: &str, reason: impl Into<String>) -> NameKeyValidationError {
493 NameKeyValidationError {
494 value: value.to_owned(),
495 reason: reason.into(),
496 }
497}
498
499string_id! {
504 NamespaceId,
511 error = NamespaceIdValidationError,
512 validate = validate_namespace_id,
513 schema(
514 pattern = r"^[a-z0-9][a-z0-9._-]{0,127}$",
518 example = "demo"
519 )
520}
521
522string_id! {
523 ContentStoreId,
527 prefix = "cs"
528}
529
530string_id! {
531 CommitId,
538 error = CommitIdValidationError,
539 validate = validate_commit_id,
540 schema(
541 pattern = r"^[a-z0-9][a-z0-9._-]{0,127}$",
542 example = "c_f3a9c2d4b6e8417a90c5d2f8e1b7a6c0"
543 )
544}
545
546impl CommitId {
547 pub fn generate() -> Self {
549 Self(generated_id("c"))
550 }
551}
552
553string_id! {
554 CheckpointId,
558 prefix = "chk",
559 schema(
560 pattern = r"^chk_[0-9a-f]{32}$",
561 example = "chk_00000000000000000000000000000002"
562 )
563}
564
565string_id! {
566 UploadId,
568 prefix = "upl",
569 schema(
570 pattern = r"^upl_[0-9a-f]{32}$",
571 example = "upl_4d8f2c91a7b34e0f9c6d1a2b3e5f708c"
572 )
573}
574
575string_id! {
576 ContentId,
584 error = GeneratedIdValidationError,
585 validate = |value: &str| validate_generated_id("con", value),
586 schema(
587 pattern = r"^con_[0-9a-f]{32}$",
588 example = "con_9f2a6c0e4b7d4a90b13f0d8c5e6a2b41"
589 )
590}
591
592impl ContentId {
593 pub fn generate() -> Self {
595 Self(format!(
596 "con_{}",
597 crate::hex::hex_encode_bytes(&random_128())
598 ))
599 }
600
601 pub fn shard_prefixes(&self) -> [&str; CONTENT_ID_SHARD_LEVELS] {
606 let first_start = CONTENT_ID_PREFIX_LEN;
607 let second_start = first_start + CONTENT_ID_SHARD_WIDTH;
608 [
609 &self.0[first_start..second_start],
610 &self.0[second_start..second_start + CONTENT_ID_SHARD_WIDTH],
611 ]
612 }
613}
614
615const CONTENT_ID_PREFIX_LEN: usize = "con_".len();
617const CONTENT_ID_SHARD_LEVELS: usize = 2;
619const CONTENT_ID_SHARD_WIDTH: usize = 2;
621
622string_id! {
623 MetadataTableId,
625 prefix = "tbl"
626}
627
628string_id! {
629 MetadataCompactionId,
635 prefix = "cmp"
636}
637
638string_id! {
639 IndexSegmentId,
641 prefix = "idx"
642}
643
644string_id! {
645 GrepManifestId,
647 prefix = "gmf"
648}
649
650string_id! {
651 ManifestObjectId,
653 error = GeneratedIdValidationError,
654 validate = |value| {
655 validate_position_suffix_id(value, ("manifest_id", "manifest id"))
656 }
657}
658
659impl ManifestObjectId {
660 pub fn generate(manifest_id: ManifestId) -> Self {
663 Self(format!(
664 "{:020}-{}",
665 manifest_id.0,
666 generated_position_suffix()
667 ))
668 }
669}
670
671pub fn manifest_object_id_manifest_id(object_id: &str) -> Option<ManifestId> {
673 validate_position_suffix_id(object_id, ("manifest_id", "manifest id")).ok()?;
674 let (position, _) = object_id.split_once('-')?;
675 position
676 .parse()
677 .ok()
678 .and_then(|value| ManifestId::parse(value).ok())
679}
680
681string_id! {
682 WalSegmentId,
684 error = GeneratedIdValidationError,
685 validate = |value| {
686 validate_position_suffix_id(value, ("start_seq", "position"))
687 }
688}
689
690impl WalSegmentId {
691 pub fn generate(start_seq: ChangeSeq) -> Self {
700 Self(format!(
701 "{:020}-{}",
702 start_seq.0,
703 generated_position_suffix()
704 ))
705 }
706}
707
708pub fn wal_segment_id_start_seq(segment_id: &str) -> Option<ChangeSeq> {
715 validate_position_suffix_id(segment_id, ("start_seq", "position")).ok()?;
716 let (position, _) = segment_id.split_once('-')?;
717 position
718 .parse()
719 .ok()
720 .and_then(|value| ChangeSeq::parse(value).ok())
721}
722
723string_id! {
724 NameKey,
729 error = NameKeyValidationError,
730 validate = validate_name_key,
731 schema(example = "report.txt")
732}
733
734impl NameKey {
735 pub fn for_display_name(display_name: &crate::DisplayName) -> Self {
737 Self(crate::name_key_for_display_name(display_name.as_str()))
738 }
739}
740
741pub const MAX_PUBLIC_INTEGER: u64 = 9_007_199_254_740_991;
750
751#[derive(Debug, Clone, Copy, PartialEq, Eq)]
753pub struct PublicOrdinalRangeError;
754
755impl fmt::Display for PublicOrdinalRangeError {
756 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
757 write!(f, "must be an integer from 0 through {MAX_PUBLIC_INTEGER}")
758 }
759}
760
761impl std::error::Error for PublicOrdinalRangeError {}
762
763pub fn next_public_ordinal(current: u64) -> Option<u64> {
765 current
766 .checked_add(1)
767 .filter(|next| *next <= MAX_PUBLIC_INTEGER)
768}
769
770numeric_id! {
771 InodeId
775}
776
777pub const ROOT_INODE_ID: InodeId = InodeId(1);
779
780pub const FIRST_ALLOCATABLE_INODE_ID: InodeId = InodeId(ROOT_INODE_ID.0 + 1);
782
783numeric_id! {
784 RevisionNo,
786 public_ordinal,
787 schema_description = "Revision number for a file's content. It increases whenever the content is replaced or restored."
788}
789
790numeric_id! {
791 ChangeSeq,
795 public_ordinal,
796 schema_description = "Sequence number assigned to a namespace commit. It determines the order in which commits become visible."
797}
798
799numeric_id! {
800 ManifestId,
805 public_ordinal,
806 schema_description = "Version number for a namespace manifest. It can increase when metadata changes, even if no namespace commit is written."
807}
808
809numeric_id! {
810 WriterEpoch,
812 public_ordinal,
813 schema_description = "Counter used to reject writes from an older writer."
814}
815
816#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
822#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
823#[serde(rename_all = "snake_case")]
824pub enum InodeKind {
825 File,
827 #[serde(rename = "dir")]
832 Directory,
833}
834
835impl fmt::Display for InodeKind {
836 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
837 match self {
838 Self::File => f.write_str("file"),
839 Self::Directory => f.write_str("dir"),
840 }
841 }
842}
843
844#[cfg(test)]
845mod tests {
846 use super::{
847 next_public_ordinal, ChangeSeq, CheckpointId, CommitId, ContentId, ContentStoreId, InodeId,
848 ManifestId, ManifestObjectId, MetadataTableId, NameKey, NamespaceId, RevisionNo, UploadId,
849 WalSegmentId, WriterEpoch, MAX_PUBLIC_INTEGER,
850 };
851 use crate::AttributeRevisionNo;
852 use std::collections::BTreeSet;
853
854 #[test]
855 fn public_ordinal_advancement_accepts_the_maximum_and_rejects_the_next_value() {
856 assert_eq!(
857 next_public_ordinal(MAX_PUBLIC_INTEGER - 1),
858 Some(MAX_PUBLIC_INTEGER)
859 );
860 assert_eq!(next_public_ordinal(MAX_PUBLIC_INTEGER), None);
861 }
862
863 #[test]
864 fn public_ordinal_inputs_must_fit_the_json_safe_integer_range() {
865 macro_rules! assert_range {
866 ($type:ty) => {{
867 let constructed = <$type>::parse(MAX_PUBLIC_INTEGER)
868 .expect("construct the maximum public ordinal");
869 assert_eq!(constructed.0, MAX_PUBLIC_INTEGER);
870
871 let construction_error = <$type>::parse(MAX_PUBLIC_INTEGER + 1)
872 .expect_err("reject a value above the public limit");
873 assert_eq!(
874 construction_error.to_string(),
875 "must be an integer from 0 through 9007199254740991"
876 );
877
878 let maximum = serde_json::from_str::<$type>(&MAX_PUBLIC_INTEGER.to_string())
879 .expect("deserialize the maximum public ordinal");
880 assert_eq!(maximum.0, MAX_PUBLIC_INTEGER);
881
882 let error = serde_json::from_str::<$type>(&(MAX_PUBLIC_INTEGER + 1).to_string())
883 .expect_err("ordinal above the public range");
884 assert!(
885 error
886 .to_string()
887 .contains("must be an integer from 0 through 9007199254740991"),
888 "unexpected range error: {error}"
889 );
890 }};
891 }
892
893 assert_range!(RevisionNo);
894 assert_range!(ChangeSeq);
895 assert_range!(AttributeRevisionNo);
896 assert_range!(ManifestId);
897 assert_range!(WriterEpoch);
898
899 assert_eq!(
900 serde_json::from_str::<InodeId>(&(MAX_PUBLIC_INTEGER + 1).to_string())
901 .expect("inode ids retain the full u64 range"),
902 InodeId(MAX_PUBLIC_INTEGER + 1)
903 );
904 }
905
906 #[test]
907 fn namespace_id_parse_accepts_allowed_grammar() {
908 let long_id = format!("a{}", "b".repeat(127));
909 for value in ["demo", "demo-1", "demo_1", "demo.v1", &long_id] {
910 let parsed = NamespaceId::parse(value).expect("valid namespace_id");
911 assert_eq!(parsed.as_str(), value);
912 }
913 }
914
915 #[test]
916 fn namespace_id_parse_rejects_invalid_values() {
917 let long_id = format!("a{}", "b".repeat(128));
918 for value in [
919 "", "/", "a/b", ".", "..", " demo", "demo ", "demo\n", "demo?", "demo#", "demo%",
920 "Demo", &long_id,
921 ] {
922 assert!(
923 NamespaceId::parse(value).is_err(),
924 "expected invalid namespace_id {value:?}"
925 );
926 }
927 }
928
929 #[test]
930 fn namespace_id_parse_rejects_reserved_system_prefix() {
931 assert!(NamespaceId::parse("loonfs-doctor-abc").is_err());
932 assert!(NamespaceId::parse("loonfs-").is_err());
933 assert_eq!(
935 NamespaceId::parse("my-loonfs-notes")
936 .expect("non-prefixed use is allowed")
937 .as_str(),
938 "my-loonfs-notes"
939 );
940 assert!(CommitId::parse("loonfs-retry-1").is_ok());
942 }
943
944 #[test]
945 fn commit_id_parse_uses_same_allowed_grammar() {
946 let parsed = CommitId::parse("c_demo-1").expect("valid commit_id");
947
948 assert_eq!(parsed.as_str(), "c_demo-1");
949 assert!(CommitId::parse("c/demo").is_err());
950 assert!(CommitId::parse("C_demo").is_err());
951 }
952
953 #[test]
954 fn identity_try_from_validates_values() {
955 assert_eq!(
956 NamespaceId::try_from("demo")
957 .expect("valid namespace id")
958 .as_str(),
959 "demo"
960 );
961 assert_eq!(
962 CommitId::try_from("commit-1")
963 .expect("valid commit id")
964 .as_str(),
965 "commit-1"
966 );
967 assert_eq!(
968 ContentStoreId::try_from("cs_00000000000000000000000000000001")
969 .expect("valid content store id")
970 .as_str(),
971 "cs_00000000000000000000000000000001"
972 );
973 assert_eq!(
974 CheckpointId::try_from("chk_00000000000000000000000000000001")
975 .expect("valid checkpoint id")
976 .as_str(),
977 "chk_00000000000000000000000000000001"
978 );
979 assert_eq!(
980 NameKey::try_from("report.txt".to_owned())
981 .expect("valid name key")
982 .as_str(),
983 "report.txt"
984 );
985 assert_eq!(
986 ManifestObjectId::try_from("00000000000000000042-0123456789abcdef")
987 .expect("valid manifest object id")
988 .as_str(),
989 "00000000000000000042-0123456789abcdef"
990 );
991
992 assert!(NamespaceId::try_from("invalid/name").is_err());
993 assert!(CommitId::try_from("invalid/name").is_err());
994 assert!(ContentStoreId::try_from("cs_0000000000000000000000000000000g").is_err());
995 assert!(CheckpointId::try_from("chk_0000000000000000000000000000000g").is_err());
996 assert!(NameKey::try_from("a/b").is_err());
997 assert!(ManifestObjectId::try_from("42-0123456789abcdef").is_err());
998 }
999
1000 #[test]
1001 fn identity_from_str_delegates_to_parse() {
1002 let namespace_id: NamespaceId = "demo".parse().expect("valid namespace id");
1003 assert_eq!(namespace_id.as_str(), "demo");
1004 assert!("invalid/name".parse::<NamespaceId>().is_err());
1005 }
1006
1007 #[test]
1008 fn identity_deserialize_validates_values() {
1009 let namespace_id: NamespaceId =
1010 serde_json::from_str(r#""demo""#).expect("valid namespace id json");
1011 assert_eq!(namespace_id.as_str(), "demo");
1012 let commit_id: CommitId =
1013 serde_json::from_str(r#""commit-1""#).expect("valid commit id json");
1014 assert_eq!(commit_id.as_str(), "commit-1");
1015 let content_store_id: ContentStoreId =
1016 serde_json::from_str(r#""cs_00000000000000000000000000000001""#)
1017 .expect("valid content store id json");
1018 assert_eq!(
1019 content_store_id.as_str(),
1020 "cs_00000000000000000000000000000001"
1021 );
1022 let checkpoint_id: CheckpointId =
1023 serde_json::from_str(r#""chk_00000000000000000000000000000001""#)
1024 .expect("valid checkpoint id json");
1025 assert_eq!(
1026 checkpoint_id.as_str(),
1027 "chk_00000000000000000000000000000001"
1028 );
1029
1030 let namespace_error = serde_json::from_str::<NamespaceId>(r#""invalid/name""#)
1031 .expect_err("invalid namespace id json");
1032 assert!(namespace_error.to_string().contains("namespace_id"));
1033 let commit_error = serde_json::from_str::<CommitId>(r#""invalid/name""#)
1034 .expect_err("invalid commit id json");
1035 assert!(commit_error.to_string().contains("commit_id"));
1036 let content_store_error =
1037 serde_json::from_str::<ContentStoreId>(r#""cs_0000000000000000000000000000000g""#)
1038 .expect_err("invalid content store id json");
1039 assert!(content_store_error.to_string().contains("generated id"));
1040 let checkpoint_error =
1041 serde_json::from_str::<CheckpointId>(r#""chk_0000000000000000000000000000000g""#)
1042 .expect_err("invalid checkpoint id json");
1043 assert!(checkpoint_error.to_string().contains("generated id"));
1044 }
1045
1046 #[test]
1047 fn generated_content_store_id_parse_requires_prefix_and_lower_hex_body() {
1048 let parsed = ContentStoreId::parse("cs_00000000000000000000000000000001")
1049 .expect("valid content store id");
1050
1051 assert_eq!(parsed.as_str(), "cs_00000000000000000000000000000001");
1052 let hyphenated_content_store_id = ["cs", "1"].join("-");
1053 for value in [
1054 hyphenated_content_store_id.as_str(),
1055 "upl_00000000000000000000000000000001",
1056 "content-stores/foo",
1057 "cs_",
1058 "cs_abcdef",
1059 "cs_0000000000000000000000000000000",
1060 "cs_000000000000000000000000000000001",
1061 "cs_ABCDEF00000000000000000000000000",
1062 "cs_0000000000000000000000000000000g",
1063 " cs_00000000000000000000000000000001",
1064 "cs_00000000000000000000000000000001 ",
1065 ] {
1066 assert!(
1067 ContentStoreId::parse(value).is_err(),
1068 "expected invalid content store id {value:?}"
1069 );
1070 }
1071 }
1072
1073 #[test]
1074 fn generated_upload_wal_segment_table_and_checkpoint_ids_reject_hyphenated_ids() {
1075 assert!(UploadId::parse("upl_00000000000000000000000000000001").is_ok());
1076 assert!(MetadataTableId::parse("tbl_00000000000000000000000000000001").is_ok());
1077 assert!(CheckpointId::parse("chk_00000000000000000000000000000001").is_ok());
1078 assert!(UploadId::parse(["upl", "123"].join("-")).is_err());
1079 assert!(WalSegmentId::parse("00000000000000000412-9f2a6c0e4b7d4a90").is_ok());
1080 assert!(ManifestObjectId::parse("00000000000000000412-9f2a6c0e4b7d4a90").is_ok());
1081 assert!(WalSegmentId::parse("412-9f2a6c0e4b7d4a90").is_err());
1082 assert!(WalSegmentId::parse("00000000000000000412-9F2A6C0E4B7D4A90").is_err());
1083 assert!(ManifestObjectId::parse("412-9f2a6c0e4b7d4a90").is_err());
1084 assert!(ManifestObjectId::parse("00000000000000000412-9F2A6C0E4B7D4A90").is_err());
1085 assert!(ManifestObjectId::parse("mf_9f2a6c0e4b7d4a90b13f0d8c5e6a2b41").is_err());
1086 assert!(WalSegmentId::parse("seg_9f2a6c0e4b7d4a90b13f0d8c5e6a2b41").is_err());
1087 assert!(MetadataTableId::parse(["tbl", "123"].join("-")).is_err());
1088 assert!(CheckpointId::parse(["chk", "123"].join("-")).is_err());
1089 }
1090
1091 #[test]
1092 fn generated_runtime_ids_use_lower_hex_bodies() {
1093 let upload_id = UploadId::generate();
1094 let wal_segment_id = WalSegmentId::generate(ChangeSeq(412));
1095 let manifest_object_id = ManifestObjectId::generate(ManifestId(413));
1096 let metadata_table_id = MetadataTableId::generate();
1097 let checkpoint_id = CheckpointId::generate();
1098
1099 assert_generated_id_shape(upload_id.as_str(), "upl");
1100 assert!(wal_segment_id.as_str().starts_with("00000000000000000412-"));
1101 assert!(manifest_object_id
1102 .as_str()
1103 .starts_with("00000000000000000413-"));
1104 assert_generated_id_shape(metadata_table_id.as_str(), "tbl");
1105 assert_generated_id_shape(checkpoint_id.as_str(), "chk");
1106 assert!(UploadId::parse(upload_id.as_str()).is_ok());
1107 assert!(WalSegmentId::parse(wal_segment_id.as_str()).is_ok());
1108 assert!(ManifestObjectId::parse(manifest_object_id.as_str()).is_ok());
1109 assert!(MetadataTableId::parse(metadata_table_id.as_str()).is_ok());
1110 assert!(CheckpointId::parse(checkpoint_id.as_str()).is_ok());
1111 }
1112
1113 #[test]
1114 fn generated_wal_segment_ids_are_not_reused_across_samples() {
1115 let mut ids = BTreeSet::new();
1118 for _ in 0..128 {
1119 let id = WalSegmentId::generate(ChangeSeq(412));
1120 assert!(
1121 ids.insert(id.clone()),
1122 "generated duplicate WAL segment id {id}"
1123 );
1124 }
1125 }
1126
1127 #[test]
1128 fn generated_manifest_object_ids_are_not_reused_across_samples() {
1129 let mut ids = BTreeSet::new();
1130 for _ in 0..128 {
1131 let id = ManifestObjectId::generate(ManifestId(412));
1132 assert!(
1133 ids.insert(id.clone()),
1134 "generated duplicate manifest object id {id}"
1135 );
1136 }
1137 }
1138
1139 #[test]
1140 fn wal_segment_id_start_seq_reads_position_prefix() {
1141 assert_eq!(
1142 super::wal_segment_id_start_seq("00000000000000000412-9f2a6c0e4b7d4a90"),
1143 Some(ChangeSeq(412))
1144 );
1145 assert_eq!(super::wal_segment_id_start_seq("not-a-segment-id"), None);
1146 assert_eq!(
1147 super::wal_segment_id_start_seq("00009007199254740992-9f2a6c0e4b7d4a90"),
1148 None
1149 );
1150 }
1151
1152 #[test]
1153 fn manifest_object_id_manifest_id_reads_position_prefix() {
1154 assert_eq!(
1155 super::manifest_object_id_manifest_id("00000000000000000412-9f2a6c0e4b7d4a90"),
1156 Some(ManifestId(412))
1157 );
1158 assert_eq!(
1159 super::manifest_object_id_manifest_id("not-a-manifest-object-id"),
1160 None
1161 );
1162 assert_eq!(
1163 super::manifest_object_id_manifest_id("00009007199254740992-9f2a6c0e4b7d4a90"),
1164 None
1165 );
1166 }
1167
1168 #[test]
1169 fn generated_content_ids_are_unique_and_shard_uniformly() {
1170 let mut ids = BTreeSet::new();
1171 let mut first_level_shards = BTreeSet::new();
1172 let mut leaf_shards = BTreeSet::new();
1173 for _ in 0..512 {
1174 let id = ContentId::generate();
1175 assert_generated_id_shape(id.as_str(), "con");
1176 let [first, second] = id.shard_prefixes();
1177 assert_eq!(first, &id.as_str()["con_".len().."con_".len() + 2]);
1178 assert_eq!(second, &id.as_str()["con_".len() + 2.."con_".len() + 4]);
1179 first_level_shards.insert(first.to_owned());
1180 leaf_shards.insert(format!("{first}/{second}"));
1181 assert!(
1182 ids.insert(id.clone()),
1183 "generated duplicate content id {id}"
1184 );
1185 }
1186 assert!(
1190 first_level_shards.len() > 128,
1191 "content id first-level shards are not spread: {} distinct",
1192 first_level_shards.len()
1193 );
1194 assert!(
1195 leaf_shards.len() > 480,
1196 "content id leaf shards are not spread: {} distinct",
1197 leaf_shards.len()
1198 );
1199 }
1200
1201 #[test]
1202 fn content_id_parse_requires_the_generated_id_shape() {
1203 assert!(ContentId::parse("con_0123456789abcdef0123456789abcdef").is_ok());
1204 for value in [
1205 "con_",
1206 "con_abcdef",
1207 "con_0123456789ABCDEF0123456789abcdef",
1208 "con_0123456789abcdef0123456789abcde",
1209 "upl_0123456789abcdef0123456789abcdef",
1210 "0123456789abcdef0123456789abcdef",
1211 ] {
1212 assert!(
1213 ContentId::parse(value).is_err(),
1214 "expected invalid content id {value:?}"
1215 );
1216 }
1217 }
1218
1219 fn assert_generated_id_shape(value: &str, prefix: &str) {
1220 let expected_prefix = format!("{prefix}_");
1221 let body = value
1222 .strip_prefix(&expected_prefix)
1223 .expect("generated id prefix");
1224 assert_eq!(body.len(), 32);
1225 assert!(
1226 body.bytes()
1227 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)),
1228 "generated id body must be lowercase hex: {value}"
1229 );
1230 }
1231
1232 #[test]
1233 fn name_key_parse_rejects_invalid_values() {
1234 assert_eq!(
1235 NameKey::parse("").expect_err("empty").reason(),
1236 "must not be empty"
1237 );
1238 assert_eq!(
1239 NameKey::parse("a/b").expect_err("slash").reason(),
1240 "must not contain `/`"
1241 );
1242 assert_eq!(
1243 NameKey::parse(".").expect_err("dot").reason(),
1244 "must not be `.` or `..`"
1245 );
1246 assert_eq!(
1247 NameKey::parse("a\u{0}b").expect_err("control").reason(),
1248 "must not contain control characters"
1249 );
1250 NameKey::parse("k".repeat(super::MAX_NAME_KEY_BYTES)).expect("cap is inclusive");
1251 assert_eq!(
1252 NameKey::parse("k".repeat(super::MAX_NAME_KEY_BYTES + 1))
1253 .expect_err("over cap")
1254 .reason(),
1255 "exceeds the maximum name key length of 768 bytes"
1256 );
1257 }
1258
1259 #[test]
1260 fn name_key_serializes_as_string_and_validates_deserialize() {
1261 let name_key = NameKey::parse("report.txt").expect("valid name key");
1262
1263 assert_eq!(
1264 serde_json::to_string(&name_key).expect("serialize name key"),
1265 "\"report.txt\""
1266 );
1267 assert_eq!(
1268 serde_json::from_str::<NameKey>("\"report.txt\"").expect("deserialize name key"),
1269 name_key
1270 );
1271 assert!(serde_json::from_str::<NameKey>("\"a/b\"").is_err());
1272 }
1273}