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 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
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/// Either form may end with `schema(...)` metadata. Its optional `pattern`
77/// and `example` are added to the OpenAPI string schema when that feature is
78/// enabled.
79///
80/// Type-specific constructors that the macro cannot express (for example
81/// `CommitId::generate` or `NameKey::for_display_name`) live in a separate
82/// `impl` block next to the invocation.
83macro_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            /// Parses and validates the id from its serialized form.
103            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            /// Returns the serialized id.
110            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            /// Generates a valid random id.
193            pub fn generate() -> Self {
194                Self(generated_id($prefix))
195            }
196        }
197    };
198}
199
200/// Defines a numeric (`u64`) id newtype.
201///
202/// Every numeric id gets `Copy`, ordering and hashing derives, `From<u64>`,
203/// `Display` as the plain inner number, and serde as a plain number. The
204/// inner field stays public: numeric ids are constructed positionally
205/// (`InodeId(7)`) and read via `.0`.
206macro_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            /// Validates a numeric value before using it as an ordinal.
219            ///
220            /// Deserialization calls this method automatically. Code that
221            /// receives a raw integer through another interface must call it
222            /// explicitly. Direct tuple construction and `From<u64>` are for
223            /// values that have already been validated.
224            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
300// ---------------------------------------------------------------------------
301// Shared validators and generators
302// ---------------------------------------------------------------------------
303
304/// Generates a project-standard opaque durable identifier.
305///
306/// Generated server-side IDs use an underscore prefix plus a 32-character
307/// lowercase hexadecimal body, such as `cs_<32hex>` or `chk_<32hex>`.
308///
309/// Ids in the id inventory generate through their newtype `generate()`
310/// constructors; this helper stays public for free-form generated labels
311/// (for example a server's per-request id) that have no validated id type.
312pub 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
321/// Draws 128 fresh random bits.
322///
323/// Generated ids hex-encode these bytes. Content ids use the same generator,
324/// which keeps their shard prefixes uniformly distributed.
325fn 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    // System tooling (for example the object-store doctor probes) writes
360    // under namespace slots that must never collide with user namespaces.
361    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
374/// Maximum name-key length in UTF-8 bytes. Keys are derived from display
375/// names capped at [`crate::path::MAX_DISPLAY_NAME_BYTES`]; case folding
376/// expands at most threefold in bytes, so 768 admits every key derivable
377/// from a valid name while bounding row keys, filter keys, and cursors.
378pub const MAX_NAME_KEY_BYTES: usize = 768;
379/// Maximum validated namespace and commit id length in UTF-8 bytes.
380pub 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        // An oversized or hostile name must not ride along in error payloads that serialize onto the wire.
397        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
499// ---------------------------------------------------------------------------
500// String ids
501// ---------------------------------------------------------------------------
502
503string_id! {
504    /// Durable id for one namespace.
505    ///
506    /// A namespace is one filesystem history. This id is not a display name and
507    /// should not be reused after destruction. Its serialized form is 1 to 128
508    /// lowercase ASCII letters, digits, dots, underscores, or hyphens, starting
509    /// with a letter or digit; the `loonfs-` prefix is reserved for system use.
510    NamespaceId,
511    error = NamespaceIdValidationError,
512    validate = validate_namespace_id,
513    schema(
514        // The `loonfs-` reservation stays in the description: a lookahead
515        // would state it, but portable pattern dialects (RE2, most SDK
516        // generators) reject lookaheads, so the pattern is the grammar only.
517        pattern = r"^[a-z0-9][a-z0-9._-]{0,127}$",
518        example = "demo"
519    )
520}
521
522string_id! {
523    /// Durable id for an immutable content store.
524    ///
525    /// Content stores own file bytes. Namespaces point at content stores.
526    ContentStoreId,
527    prefix = "cs"
528}
529
530string_id! {
531    /// Client-supplied idempotency key for one logical commit.
532    ///
533    /// Reuse the same `CommitId` when retrying the same request. The accepted
534    /// grammar is 1 to 128 lowercase ASCII letters, digits, dots, underscores,
535    /// or hyphens, starting with a letter or digit. [`CommitId::generate`] returns
536    /// `c_<32 lowercase hex>`, but callers may supply any value in that grammar.
537    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    /// Generates a valid random commit id.
548    pub fn generate() -> Self {
549        Self(generated_id("c"))
550    }
551}
552
553string_id! {
554    /// Durable checkpoint identifier.
555    ///
556    /// A checkpoint is a durable bookmark to a namespace manifest version.
557    CheckpointId,
558    prefix = "chk",
559    schema(
560        pattern = r"^chk_[0-9a-f]{32}$",
561        example = "chk_00000000000000000000000000000002"
562    )
563}
564
565string_id! {
566    /// Durable id for one upload session.
567    UploadId,
568    prefix = "upl",
569    schema(
570        pattern = r"^upl_[0-9a-f]{32}$",
571        example = "upl_4d8f2c91a7b34e0f9c6d1a2b3e5f708c"
572    )
573}
574
575string_id! {
576    /// Durable identity of one immutable content object.
577    ///
578    /// The body is 128 fully random bits, with no time component: content
579    /// object keys shard on the id's leading characters, and a clock-derived
580    /// prefix would put every upload in one window into one shard. The id
581    /// names *which object*, never what it contains — integrity evidence
582    /// rides [`crate::ContentRef`] beside it.
583    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    /// Generates an id from 128 fresh random bits.
594    pub fn generate() -> Self {
595        Self(format!(
596            "con_{}",
597            crate::hex::hex_encode_bytes(&random_128())
598        ))
599    }
600
601    /// Returns the two-character components for both content-key shard levels.
602    ///
603    /// Every valid id has a 32-character lowercase hex body, so this never
604    /// panics.
605    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
615/// Byte length of the `con_` marker that precedes a content id's hex body.
616const CONTENT_ID_PREFIX_LEN: usize = "con_".len();
617/// Number of directory levels used to shard content objects.
618const CONTENT_ID_SHARD_LEVELS: usize = 2;
619/// Number of content-id body characters in each shard directory name.
620const CONTENT_ID_SHARD_WIDTH: usize = 2;
621
622string_id! {
623    /// Durable id for one metadata SST table file.
624    MetadataTableId,
625    prefix = "tbl"
626}
627
628string_id! {
629    /// Durable id for one streaming metadata compaction job.
630    ///
631    /// The job's staged output and its lease live under one prefix named by
632    /// this id, so a collector can tell one job's output from another's
633    /// without reading anything.
634    MetadataCompactionId,
635    prefix = "cmp"
636}
637
638string_id! {
639    /// Durable id for one derived-index segment file.
640    IndexSegmentId,
641    prefix = "idx"
642}
643
644string_id! {
645    /// Identifies one stored grep manifest.
646    GrepManifestId,
647    prefix = "gmf"
648}
649
650string_id! {
651    /// Durable object id for one namespace manifest candidate.
652    ManifestObjectId,
653    error = GeneratedIdValidationError,
654    validate = |value| {
655        validate_position_suffix_id(value, ("manifest_id", "manifest id"))
656    }
657}
658
659impl ManifestObjectId {
660    /// Manifest object ids order by logical manifest position and stay unique
661    /// under races.
662    pub fn generate(manifest_id: ManifestId) -> Self {
663        Self(format!(
664            "{:020}-{}",
665            manifest_id.0,
666            generated_position_suffix()
667        ))
668    }
669}
670
671/// Logical manifest id encoded in a manifest object id's 20-digit prefix.
672pub 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    /// Durable id for one WAL segment.
683    WalSegmentId,
684    error = GeneratedIdValidationError,
685    validate = |value| {
686        validate_position_suffix_id(value, ("start_seq", "position"))
687    }
688}
689
690impl WalSegmentId {
691    /// WAL segment ids order by history position and stay unique under races.
692    ///
693    /// The 20-digit prefix is the segment's `start_seq`, so listings sort by
694    /// position and reclamation can range-scan below a boundary cursor. The
695    /// 16-hex suffix keeps speculative writes unique: racing writers proposing
696    /// different segments for the same position never collide, and the head
697    /// compare-and-swap chooses among them. The name is an inspection and
698    /// reclamation hint only — recovery authority is the head and chain.
699    pub fn generate(start_seq: ChangeSeq) -> Self {
700        Self(format!(
701            "{:020}-{}",
702            start_seq.0,
703            generated_position_suffix()
704        ))
705    }
706}
707
708/// Start seq encoded in a WAL segment id's 20-digit position prefix.
709///
710/// Returns `None` when the value does not follow the generated id shape, so
711/// listings can skip foreign objects instead of failing. Like the name
712/// itself, the parsed position is an inspection and reclamation hint only —
713/// recovery authority is the head and chain.
714pub 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    /// Name-policy-derived directory entry key.
725    ///
726    /// Use this for exact name preconditions. Keep user-facing spelling in
727    /// `DisplayName`.
728    NameKey,
729    error = NameKeyValidationError,
730    validate = validate_name_key,
731    schema(example = "report.txt")
732}
733
734impl NameKey {
735    /// Computes the lookup key for a display name.
736    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
741// ---------------------------------------------------------------------------
742// Numeric ids
743// ---------------------------------------------------------------------------
744
745/// Maximum value for an ordinal exposed through the API.
746///
747/// This is `2^53 - 1`, the largest integer JSON clients can represent
748/// without losing precision.
749pub const MAX_PUBLIC_INTEGER: u64 = 9_007_199_254_740_991;
750
751/// Returned when an ordinal exceeds [`MAX_PUBLIC_INTEGER`].
752#[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
763/// Returns the next ordinal, or `None` if the value is already at the limit.
764pub 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    /// Numeric identity of a file or directory within a namespace.
772    ///
773    /// Inodes are stable across renames.
774    InodeId
775}
776
777/// Inode 1 is always the root directory of a namespace.
778pub const ROOT_INODE_ID: InodeId = InodeId(1);
779
780/// First inode id available after the root inode.
781pub const FIRST_ALLOCATABLE_INODE_ID: InodeId = InodeId(ROOT_INODE_ID.0 + 1);
782
783numeric_id! {
784    /// Revision number for a file's content.
785    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    /// Sequence number assigned to a namespace commit.
792    ///
793    /// This number determines the order in which commits become visible.
794    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    /// Version number for a namespace manifest.
801    ///
802    /// The manifest version can increase when metadata changes, even if no
803    /// namespace commit is written.
804    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    /// Counter used to reject writes from an older writer.
811    WriterEpoch,
812    public_ordinal,
813    schema_description = "Counter used to reject writes from an older writer."
814}
815
816// ---------------------------------------------------------------------------
817// Filesystem item kind
818// ---------------------------------------------------------------------------
819
820/// Filesystem item kind.
821#[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 with revision history.
826    File,
827    /// Directory with child bindings.
828    ///
829    /// The wire value is pinned to `"dir"`; only the Rust name spells the
830    /// word out.
831    #[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        // The reservation is a prefix rule, not a substring rule.
934        assert_eq!(
935            NamespaceId::parse("my-loonfs-notes")
936                .expect("non-prefixed use is allowed")
937                .as_str(),
938            "my-loonfs-notes"
939        );
940        // Commit ids share the base grammar but not the reservation.
941        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        // Same position, many proposers: the suffix keeps every proposal
1116        // distinct.
1117        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        // 512 draws over 256 first-level and 65,536 leaf shards: a generator
1187        // with a fixed or clock-derived prefix would collapse into a handful
1188        // of shards.
1189        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}