Skip to main content

loonfs_api/
ids.rs

1//! Every identifier newtype in the workspace, generated by the
2//! `string_id!` and `numeric_id!` macros so all ids share one validated
3//! surface.
4
5use serde::{Deserialize, Serialize};
6use std::fmt;
7use thiserror::Error;
8use uuid::Uuid;
9
10const SERVER_GENERATED_ID_BODY_LEN: usize = 32;
11
12// ---------------------------------------------------------------------------
13// Validation errors
14// ---------------------------------------------------------------------------
15
16macro_rules! validation_error {
17    ($name:ident, $message:literal) => {
18        /// Describes why supplied text does not satisfy this identifier's validation contract.
19        #[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            /// Returns the rejected input, or an empty string when echoing it would be unsafe.
28            pub fn value(&self) -> &str {
29                &self.value
30            }
31
32            /// Returns the specific grammar rule the rejected input violated.
33            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
57// ---------------------------------------------------------------------------
58// Id macros
59// ---------------------------------------------------------------------------
60
61/// Defines a validated string-id newtype.
62///
63/// Every string id gets the same surface: `parse` (the only fallible
64/// constructor), `as_str`, `TryFrom<&str>`/`TryFrom<String>`/`FromStr`
65/// (all delegating to `parse`), `AsRef<str>`, `Borrow<str>`, `Display`
66/// (the plain inner string), and serde as a plain string with validation
67/// on deserialize.
68///
69/// Two forms:
70/// - `string_id!(Name, error = ErrType, validate = validator)` uses a custom
71///   `fn(&str) -> Result<(), ErrType>` validator.
72/// - `string_id!(Name, prefix = "xyz")` validates the project-standard
73///   server-generated shape `xyz_<32 lowercase hex>` and adds a
74///   `generate()` constructor.
75///
76/// Type-specific constructors that the macro cannot express (for example
77/// `CommitId::generate` or `NameKey::for_display_name`) live in a separate
78/// `impl` block next to the invocation.
79macro_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            /// Parses and validates the id from its serialized form.
94            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            /// Returns the serialized id.
101            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            /// Generates a valid random id.
181            pub fn generate() -> Self {
182                Self(generated_id($prefix))
183            }
184        }
185    };
186}
187
188/// Defines a numeric (`u64`) id newtype.
189///
190/// Every numeric id gets `Copy`, ordering and hashing derives, `From<u64>`,
191/// `Display` as the plain inner number, and serde as a plain number. The
192/// inner field stays public: numeric ids are constructed positionally
193/// (`InodeId(7)`) and read via `.0`.
194macro_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
221// ---------------------------------------------------------------------------
222// Shared validators and generators
223// ---------------------------------------------------------------------------
224
225/// Generates a project-standard opaque durable identifier.
226///
227/// Generated server-side IDs use an underscore prefix plus a 32-character
228/// lowercase UUID-simple body, such as `cs_<32hex>` or `chk_<32hex>`.
229///
230/// Ids in the id inventory generate through their newtype `generate()`
231/// constructors; this helper stays public for free-form generated labels
232/// (for example a server's per-request id) that have no validated id type.
233pub 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
242/// Draws 128 fresh random bits.
243///
244/// [`generated_id`] spends six of its bits on UUID version and variant tags.
245/// Content ids shard on their leading characters and must be uniform there,
246/// so they draw from the system generator directly instead.
247fn 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    // System tooling (for example the object-store doctor probes) writes
282    // under namespace slots that must never collide with user namespaces.
283    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
296/// Maximum name-key length in UTF-8 bytes. Keys are derived from display
297/// names capped at [`crate::path::MAX_DISPLAY_NAME_BYTES`]; case folding
298/// expands at most threefold in bytes, so 768 admits every key derivable
299/// from a valid name while bounding row keys, filter keys, and cursors.
300pub const MAX_NAME_KEY_BYTES: usize = 768;
301/// Maximum validated namespace and commit id length in UTF-8 bytes.
302pub 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        // An oversized or hostile name must not ride along in error payloads that serialize onto the wire.
319        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
421// ---------------------------------------------------------------------------
422// String ids
423// ---------------------------------------------------------------------------
424
425string_id! {
426    /// Durable id for one namespace.
427    ///
428    /// A namespace is one filesystem history. This id is not a display name and
429    /// should not be reused after destruction.
430    NamespaceId,
431    error = NamespaceIdValidationError,
432    validate = validate_namespace_id
433}
434
435string_id! {
436    /// Durable id for an immutable content store.
437    ///
438    /// Content stores own file bytes. Namespaces point at content stores.
439    ContentStoreId,
440    prefix = "cs"
441}
442
443string_id! {
444    /// Client-supplied idempotency key for one logical commit.
445    ///
446    /// Reuse the same `CommitId` when retrying the same request.
447    CommitId,
448    error = CommitIdValidationError,
449    validate = validate_commit_id
450}
451
452impl CommitId {
453    /// Generates a valid random commit id.
454    pub fn generate() -> Self {
455        Self(generated_id("c"))
456    }
457}
458
459string_id! {
460    /// Durable checkpoint identifier.
461    ///
462    /// A checkpoint is a durable bookmark to a namespace manifest version.
463    CheckpointId,
464    prefix = "chk"
465}
466
467string_id! {
468    /// Durable id for one upload session.
469    UploadId,
470    prefix = "upl"
471}
472
473string_id! {
474    /// Durable identity of one immutable content object.
475    ///
476    /// The body is 128 fully random bits, with no time component: content
477    /// object keys shard on the id's leading characters, and a clock-derived
478    /// prefix would put every upload in one window into one shard. The id
479    /// names *which object*, never what it contains — integrity evidence
480    /// rides [`crate::ContentRef`] beside it.
481    ContentId,
482    error = GeneratedIdValidationError,
483    validate = |value: &str| validate_generated_id("con", value)
484}
485
486impl ContentId {
487    /// Generates an id from 128 fresh random bits.
488    pub fn generate() -> Self {
489        Self(format!(
490            "con_{}",
491            crate::hex::hex_encode_bytes(&random_128())
492        ))
493    }
494
495    /// Returns the two-character shard prefix content keys are grouped by.
496    ///
497    /// Every valid id has a 32-character lowercase hex body, so this never
498    /// panics.
499    pub fn shard_prefix(&self) -> &str {
500        &self.0[CONTENT_ID_PREFIX_LEN..CONTENT_ID_PREFIX_LEN + CONTENT_ID_SHARD_LEN]
501    }
502}
503
504/// Byte length of the `con_` marker that precedes a content id's hex body.
505const CONTENT_ID_PREFIX_LEN: usize = "con_".len();
506/// Number of leading body characters that select a content object's shard.
507const CONTENT_ID_SHARD_LEN: usize = 2;
508
509string_id! {
510    /// Durable id for one metadata SST table file.
511    MetadataTableId,
512    prefix = "tbl"
513}
514
515string_id! {
516    /// Durable id for one derived-index segment file.
517    IndexSegmentId,
518    prefix = "idx"
519}
520
521string_id! {
522    /// Durable object id for one namespace manifest candidate.
523    ManifestObjectId,
524    error = GeneratedIdValidationError,
525    validate = |value| {
526        validate_position_suffix_id(value, ("manifest_id", "manifest id"))
527    }
528}
529
530impl ManifestObjectId {
531    /// Manifest object ids order by logical manifest position and stay unique
532    /// under races.
533    pub fn generate(manifest_id: ManifestId) -> Self {
534        Self(format!(
535            "{:020}-{}",
536            manifest_id.0,
537            generated_position_suffix()
538        ))
539    }
540}
541
542/// Logical manifest id encoded in a manifest object id's 20-digit prefix.
543pub 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    /// Durable id for one WAL segment.
551    WalSegmentId,
552    error = GeneratedIdValidationError,
553    validate = |value| {
554        validate_position_suffix_id(value, ("start_seq", "position"))
555    }
556}
557
558impl WalSegmentId {
559    /// WAL segment ids order by history position and stay unique under races.
560    ///
561    /// The 20-digit prefix is the segment's `start_seq`, so listings sort by
562    /// position and reclamation can range-scan below a boundary cursor. The
563    /// 16-hex suffix keeps speculative writes unique: racing writers proposing
564    /// different segments for the same position never collide, and the head
565    /// compare-and-swap chooses among them. The name is an inspection and
566    /// reclamation hint only — recovery authority is the head and chain.
567    pub fn generate(start_seq: ChangeSeq) -> Self {
568        Self(format!(
569            "{:020}-{}",
570            start_seq.0,
571            generated_position_suffix()
572        ))
573    }
574}
575
576/// Start seq encoded in a WAL segment id's 20-digit position prefix.
577///
578/// Returns `None` when the value does not follow the generated id shape, so
579/// listings can skip foreign objects instead of failing. Like the name
580/// itself, the parsed position is an inspection and reclamation hint only —
581/// recovery authority is the head and chain.
582pub 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    /// Name-policy-derived directory entry key.
590    ///
591    /// Use this for exact name preconditions. Keep user-facing spelling in
592    /// `DisplayName`.
593    NameKey,
594    error = NameKeyValidationError,
595    validate = validate_name_key
596}
597
598impl NameKey {
599    /// Computes the lookup key for a display name.
600    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
605// ---------------------------------------------------------------------------
606// Numeric ids
607// ---------------------------------------------------------------------------
608
609numeric_id! {
610    /// Numeric identity of a file or directory within a namespace.
611    ///
612    /// Inodes are stable across renames.
613    InodeId
614}
615
616/// Inode 1 is always the root directory of a namespace.
617pub const ROOT_INODE_ID: InodeId = InodeId(1);
618
619numeric_id! {
620    /// Monotonically increasing file revision counter within one file inode.
621    RevisionNo
622}
623
624numeric_id! {
625    /// Monotonically increasing namespace commit sequence number.
626    ///
627    /// This is the global visibility order for a namespace.
628    ChangeSeq
629}
630
631numeric_id! {
632    /// Monotonically increasing namespace manifest identity.
633    ///
634    /// This is the durable file-set version identity for namespace manifests.
635    /// Initial/fork manifests may be seeded from the current head sequence, but
636    /// later manifest ids can advance for checkpoint metadata, compaction, or
637    /// fork/index metadata without a new namespace commit.
638    ManifestId
639}
640
641numeric_id! {
642    /// Monotonically increasing writer epoch for namespace write fencing.
643    WriterEpoch
644}
645
646// ---------------------------------------------------------------------------
647// Filesystem item kind
648// ---------------------------------------------------------------------------
649
650/// Filesystem item kind.
651#[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 with revision history.
656    File,
657    /// Directory with child bindings.
658    ///
659    /// The wire value is pinned to `"dir"`; only the Rust name spells the
660    /// word out.
661    #[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        // The reservation is a prefix rule, not a substring rule.
710        assert_eq!(
711            NamespaceId::parse("my-loonfs-notes")
712                .expect("non-prefixed use is allowed")
713                .as_str(),
714            "my-loonfs-notes"
715        );
716        // Commit ids share the base grammar but not the reservation.
717        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        // Same position, many proposers: the suffix keeps every proposal
892        // distinct.
893        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        // 512 draws over 256 shards: a generator with a fixed or clock-derived
954        // prefix would collapse into a handful of shards.
955        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}