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, is_lower_hex_byte};
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            pub(crate) fn new(value: &str, reason: impl Into<String>) -> Self {
28                Self {
29                    value: value.to_owned(),
30                    reason: reason.into(),
31                }
32            }
33
34            /// Returns the rejected input, or an empty string when echoing it would be unsafe.
35            pub fn value(&self) -> &str {
36                &self.value
37            }
38
39            /// Returns the specific grammar rule the rejected input violated.
40            pub fn reason(&self) -> &str {
41                &self.reason
42            }
43        }
44    };
45}
46
47validation_error!(
48    NamespaceIdValidationError,
49    "invalid namespace_id {value:?}: {reason}"
50);
51validation_error!(
52    CommitIdValidationError,
53    "invalid commit_id {value:?}: {reason}"
54);
55validation_error!(
56    GeneratedIdValidationError,
57    "invalid generated id {value:?}: {reason}"
58);
59validation_error!(
60    SnapshotIdValidationError,
61    "invalid snapshot_id {value:?}: {reason}"
62);
63validation_error!(
64    NameKeyValidationError,
65    "invalid name_key {value:?}: {reason}"
66);
67validation_error!(
68    WriterIdValidationError,
69    "invalid writer_id {value:?}: {reason}"
70);
71validation_error!(
72    BindingGenerationValidationError,
73    "invalid binding_generation {value:?}: {reason}"
74);
75
76// ---------------------------------------------------------------------------
77// Id macros
78// ---------------------------------------------------------------------------
79
80/// Defines a validated string-id newtype.
81///
82/// Every string id gets the same surface: `parse` (the only fallible
83/// constructor), `as_str`, `TryFrom<&str>`/`TryFrom<String>`/`FromStr`
84/// (all delegating to `parse`), `AsRef<str>`, `Borrow<str>`, `Display`
85/// (the plain inner string), and serde as a plain string with validation
86/// on deserialize.
87///
88/// Two forms:
89/// - `string_id!(Name, error = ErrType, validate = validator)` uses a custom
90///   `fn(&str) -> Result<(), ErrType>` validator.
91/// - `string_id!(Name, prefix = "xyz")` validates the project-standard
92///   server-generated shape `xyz_<32 lowercase hex>` and adds a
93///   `generate()` constructor.
94///
95/// Either form may end with `schema(...)` metadata. Its optional `pattern`
96/// and `example` are added to the OpenAPI string schema when that feature is
97/// enabled.
98///
99/// Type-specific constructors that the macro cannot express (for example
100/// `CommitId::generate` or `NameKey::for_display_name`) live in a separate
101/// `impl` block next to the invocation.
102macro_rules! string_id {
103    (
104        $(#[$meta:meta])*
105        $name:ident,
106        error = $error:ty,
107        validate = $validate:expr
108        $(, schema($($schema:tt)+))?
109        $(,)?
110    ) => {
111        $(#[$meta])*
112        #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
113        #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
114        #[cfg_attr(
115            feature = "openapi",
116            schema(value_type = String $(, $($schema)+)?)
117        )]
118        pub struct $name(String);
119
120        impl $name {
121            /// Parses and validates the id from its serialized form.
122            pub fn parse(value: impl AsRef<str>) -> Result<Self, $error> {
123                let value = value.as_ref();
124                ($validate)(value)?;
125                Ok(Self(value.to_owned()))
126            }
127
128            /// Returns the serialized id.
129            pub fn as_str(&self) -> &str {
130                &self.0
131            }
132        }
133
134        impl TryFrom<&str> for $name {
135            type Error = $error;
136
137            fn try_from(value: &str) -> Result<Self, Self::Error> {
138                Self::parse(value)
139            }
140        }
141
142        impl TryFrom<String> for $name {
143            type Error = $error;
144
145            fn try_from(value: String) -> Result<Self, Self::Error> {
146                Self::parse(value)
147            }
148        }
149
150        impl std::str::FromStr for $name {
151            type Err = $error;
152
153            fn from_str(value: &str) -> Result<Self, Self::Err> {
154                Self::parse(value)
155            }
156        }
157
158        impl AsRef<str> for $name {
159            fn as_ref(&self) -> &str {
160                self.as_str()
161            }
162        }
163
164        impl std::borrow::Borrow<str> for $name {
165            fn borrow(&self) -> &str {
166                self.as_str()
167            }
168        }
169
170        impl std::fmt::Display for $name {
171            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172                f.write_str(&self.0)
173            }
174        }
175
176        impl serde::Serialize for $name {
177            fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
178            where
179                S: serde::Serializer,
180            {
181                serializer.serialize_str(&self.0)
182            }
183        }
184
185        impl<'de> serde::Deserialize<'de> for $name {
186            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
187            where
188                D: serde::Deserializer<'de>,
189            {
190                let value = <String as serde::Deserialize>::deserialize(deserializer)?;
191                Self::parse(value).map_err(serde::de::Error::custom)
192            }
193        }
194    };
195    (
196        $(#[$meta:meta])*
197        $name:ident,
198        prefix = $prefix:literal
199        $(, schema($($schema:tt)+))?
200        $(,)?
201    ) => {
202        string_id! {
203            $(#[$meta])*
204            $name,
205            error = GeneratedIdValidationError,
206            validate = |value: &str| validate_generated_id($prefix, value)
207            $(, schema($($schema)+))?
208        }
209
210        impl $name {
211            /// Generates a valid random id.
212            pub fn generate() -> Self {
213                Self(generated_id($prefix))
214            }
215        }
216    };
217}
218
219/// Defines a numeric (`u64`) id newtype.
220///
221/// Every numeric id gets `Copy`, ordering and hashing derives, `From<u64>`,
222/// `Display` as the plain inner number, and serde as a plain number. The
223/// inner field stays public: numeric ids are constructed positionally
224/// (`InodeId(7)`) and read via `.0`.
225macro_rules! numeric_id {
226    (
227        $(#[$meta:meta])*
228        $name:ident,
229        public_ordinal,
230        schema_description = $schema_description:literal
231    ) => {
232        $(#[$meta])*
233        #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
234        pub struct $name(pub u64);
235
236        impl $name {
237            /// Validates a numeric value before using it as an ordinal.
238            ///
239            /// Deserialization calls this method automatically. Code that
240            /// receives a raw integer through another interface must call it
241            /// explicitly. Direct tuple construction and `From<u64>` are for
242            /// values that have already been validated.
243            pub fn parse(value: u64) -> Result<Self, $crate::PublicOrdinalRangeError> {
244                if value > $crate::MAX_PUBLIC_INTEGER {
245                    return Err($crate::PublicOrdinalRangeError);
246                }
247                Ok(Self(value))
248            }
249
250            /// Returns the next ordinal, or an error at the public maximum.
251            pub fn successor(self) -> Result<Self, $crate::PublicOrdinalRangeError> {
252                $crate::next_public_ordinal(self.0)
253                    .map(Self)
254                    .ok_or($crate::PublicOrdinalRangeError)
255            }
256        }
257
258        #[cfg(feature = "openapi")]
259        impl utoipa::PartialSchema for $name {
260            fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
261                utoipa::openapi::schema::Object::builder()
262                    .schema_type(utoipa::openapi::schema::Type::Integer)
263                    .format(Some(utoipa::openapi::SchemaFormat::KnownFormat(
264                        utoipa::openapi::KnownFormat::Int64,
265                    )))
266                    .minimum(Some(0u64))
267                    .maximum(Some($crate::MAX_PUBLIC_INTEGER))
268                    .description(Some($schema_description))
269                    .into()
270            }
271        }
272
273        #[cfg(feature = "openapi")]
274        impl utoipa::ToSchema for $name {}
275
276        impl<'de> serde::Deserialize<'de> for $name {
277            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
278            where
279                D: serde::Deserializer<'de>,
280            {
281                let value = <u64 as serde::Deserialize>::deserialize(deserializer)?;
282                Self::parse(value).map_err(serde::de::Error::custom)
283            }
284        }
285
286        impl From<u64> for $name {
287            fn from(value: u64) -> Self {
288                Self(value)
289            }
290        }
291
292        impl fmt::Display for $name {
293            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
294                write!(f, "{}", self.0)
295            }
296        }
297    };
298    (
299        $(#[$meta:meta])*
300        $name:ident
301    ) => {
302        $(#[$meta])*
303        #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
304        pub struct $name(pub u64);
305
306        impl From<u64> for $name {
307            fn from(value: u64) -> Self {
308                Self(value)
309            }
310        }
311
312        impl fmt::Display for $name {
313            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
314                write!(f, "{}", self.0)
315            }
316        }
317    };
318}
319
320pub(crate) use numeric_id;
321pub(crate) use string_id;
322pub(crate) use validation_error;
323
324// ---------------------------------------------------------------------------
325// Shared validators and generators
326// ---------------------------------------------------------------------------
327
328/// Generates a project-standard opaque durable identifier.
329///
330/// Generated server-side IDs use an underscore prefix plus a 32-character
331/// lowercase hexadecimal body, such as `cs_<32hex>` or `chk_<32hex>`.
332///
333/// Ids in the id inventory generate through their newtype `generate()`
334/// constructors; this helper stays public for free-form generated labels
335/// (for example a server's per-request id) that have no validated id type.
336pub fn generated_id(prefix: &'static str) -> String {
337    format!("{prefix}_{}", hex_encode_bytes(&random_128()))
338}
339
340/// Draws 128 fresh random bits.
341///
342/// Generated ids hex-encode these bytes. Content ids use the same generator,
343/// which keeps their shard prefixes uniformly distributed.
344fn random_128() -> [u8; 16] {
345    let mut bytes = [0_u8; 16];
346    getrandom::fill(&mut bytes).expect("the system random generator must be available");
347    bytes
348}
349
350fn validate_generated_id(
351    prefix: &'static str,
352    value: &str,
353) -> Result<(), GeneratedIdValidationError> {
354    let expected_prefix = format!("{prefix}_");
355    let Some(body) = value.strip_prefix(&expected_prefix) else {
356        return Err(GeneratedIdValidationError::new(
357            value,
358            format!("must start with `{expected_prefix}`"),
359        ));
360    };
361    if body.len() != SERVER_GENERATED_ID_BODY_LEN {
362        return Err(GeneratedIdValidationError::new(
363            value,
364            format!("body must be {SERVER_GENERATED_ID_BODY_LEN} lowercase hex characters"),
365        ));
366    }
367    if !body.bytes().all(is_lower_hex_byte) {
368        return Err(GeneratedIdValidationError::new(
369            value,
370            "body must contain only lowercase hex characters".to_owned(),
371        ));
372    }
373    Ok(())
374}
375
376fn validate_namespace_id(value: &str) -> Result<(), NamespaceIdValidationError> {
377    validate_id_grammar(value).map_err(|reason| NamespaceIdValidationError::new(value, reason))?;
378    // System tooling (for example the object-store doctor probes) writes
379    // under namespace slots that must never collide with user namespaces.
380    if value.starts_with("loonfs-") {
381        return Err(NamespaceIdValidationError::new(
382            value,
383            "the `loonfs-` prefix is reserved for LoonFS system namespaces",
384        ));
385    }
386    Ok(())
387}
388
389fn validate_commit_id(value: &str) -> Result<(), CommitIdValidationError> {
390    validate_id_grammar(value).map_err(|reason| CommitIdValidationError::new(value, reason))
391}
392
393/// Maximum name-key length in UTF-8 bytes. Keys are derived from display
394/// names capped at [`crate::path::MAX_DISPLAY_NAME_BYTES`]; case folding
395/// expands at most threefold in bytes, so 768 admits every key derivable
396/// from a valid name while bounding row keys, filter keys, and cursors.
397pub const MAX_NAME_KEY_BYTES: usize = 768;
398/// Maximum validated namespace and commit id length in UTF-8 bytes.
399pub const MAX_ID_BYTES: usize = 128;
400
401fn validate_name_key(value: &str) -> Result<(), NameKeyValidationError> {
402    if value.is_empty() {
403        return Err(NameKeyValidationError::new(value, "must not be empty"));
404    }
405    if value.contains('/') {
406        return Err(NameKeyValidationError::new(value, "must not contain `/`"));
407    }
408    if matches!(value, "." | "..") {
409        return Err(NameKeyValidationError::new(
410            value,
411            "must not be `.` or `..`",
412        ));
413    }
414    if value.chars().any(|character| character.is_control()) {
415        return Err(NameKeyValidationError::new(
416            value,
417            "must not contain control characters",
418        ));
419    }
420    if value.len() > MAX_NAME_KEY_BYTES {
421        // An oversized or hostile name must not ride along in error payloads that serialize onto the wire.
422        return Err(NameKeyValidationError::new(
423            "",
424            format!("exceeds the maximum name key length of {MAX_NAME_KEY_BYTES} bytes"),
425        ));
426    }
427    Ok(())
428}
429
430fn validate_id_grammar(value: &str) -> Result<(), String> {
431    if value.is_empty() {
432        return Err("must not be empty".to_owned());
433    }
434    if value.len() > MAX_ID_BYTES {
435        return Err(format!("must be {MAX_ID_BYTES} bytes or fewer"));
436    }
437    if value.trim() != value {
438        return Err("must not have leading or trailing whitespace".to_owned());
439    }
440    if matches!(value, "." | "..") {
441        return Err("must not be `.` or `..`".to_owned());
442    }
443
444    let mut chars = value.chars();
445    let first = chars
446        .next()
447        .expect("empty id returned before char validation");
448    if !first.is_ascii_lowercase() && !first.is_ascii_digit() {
449        return Err("must start with a lowercase ASCII letter or digit".to_owned());
450    }
451    if !chars.all(is_allowed_id_tail_char) {
452        return Err(
453            "must contain only lowercase ASCII letters, digits, `.`, `_`, or `-`".to_owned(),
454        );
455    }
456
457    Ok(())
458}
459
460fn is_allowed_id_tail_char(ch: char) -> bool {
461    ch.is_ascii_lowercase() || ch.is_ascii_digit() || matches!(ch, '.' | '_' | '-')
462}
463
464// ---------------------------------------------------------------------------
465// String ids
466// ---------------------------------------------------------------------------
467
468string_id! {
469    /// Durable id for one namespace.
470    ///
471    /// A namespace is one filesystem history. This id is not a display name and
472    /// should not be reused after destruction. Its serialized form is 1 to 128
473    /// lowercase ASCII letters, digits, dots, underscores, or hyphens, starting
474    /// with a letter or digit; the `loonfs-` prefix is reserved for system use.
475    NamespaceId,
476    error = NamespaceIdValidationError,
477    validate = validate_namespace_id,
478    schema(
479        // The `loonfs-` reservation stays in the description: a lookahead
480        // would state it, but portable pattern dialects (RE2, most SDK
481        // generators) reject lookaheads, so the pattern is the grammar only.
482        pattern = r"^[a-z0-9][a-z0-9._-]{0,127}$",
483        example = "demo"
484    )
485}
486
487string_id! {
488    /// Stable writer label supplied by the embedding process.
489    WriterId,
490    error = WriterIdValidationError,
491    validate = |value: &str| {
492        if value.trim().is_empty() {
493            return Err(WriterIdValidationError::new(value, "must not be blank"));
494        }
495        Ok(())
496    }
497}
498
499string_id! {
500    /// Opaque token identifying one parent and name binding generation.
501    BindingGeneration,
502    error = BindingGenerationValidationError,
503    validate = |value: &str| {
504        if value.is_empty() {
505            return Err(BindingGenerationValidationError::new(value, "must not be empty"));
506        }
507        if !value.bytes().all(is_lower_hex_byte) {
508            return Err(BindingGenerationValidationError::new(
509                value,
510                "must contain only lowercase hex characters",
511            ));
512        }
513        Ok(())
514    },
515    schema(pattern = r"^[0-9a-f]+$")
516}
517
518string_id! {
519    /// Durable id for an immutable content store.
520    ///
521    /// Content stores own file bytes. Namespaces point at content stores.
522    ContentStoreId,
523    prefix = "cs"
524}
525
526string_id! {
527    /// Client-supplied idempotency key for one logical commit.
528    ///
529    /// Reuse the same `CommitId` when retrying the same request. The accepted
530    /// grammar is 1 to 128 lowercase ASCII letters, digits, dots, underscores,
531    /// or hyphens, starting with a letter or digit. [`CommitId::generate`] returns
532    /// `c_<32 lowercase hex>`, but callers may supply any value in that grammar.
533    CommitId,
534    error = CommitIdValidationError,
535    validate = validate_commit_id,
536    schema(
537        pattern = r"^[a-z0-9][a-z0-9._-]{0,127}$",
538        example = "c_f3a9c2d4b6e8417a90c5d2f8e1b7a6c0"
539    )
540}
541
542impl CommitId {
543    /// Generates a valid random commit id.
544    pub fn generate() -> Self {
545        Self(generated_id("c"))
546    }
547}
548
549string_id! {
550    /// Durable checkpoint identifier.
551    ///
552    /// The manifest number determines which namespace manifest it pins.
553    CheckpointId,
554    error = GeneratedIdValidationError,
555    validate = validate_checkpoint_id,
556    schema(
557        pattern = r"^pin_[0-9]{20}-[0-9a-f]{16}$",
558        example = "pin_00000000000000000001-0000000000000002"
559    )
560}
561
562string_id! {
563    /// Id of a snapshot.
564    ///
565    /// A snapshot is backed by a checkpoint record and uses that record's
566    /// id, `pin_{manifest_no:020}-{16 lowercase hex}`.
567    SnapshotId,
568    error = SnapshotIdValidationError,
569    validate = |value: &str| validate_checkpoint_id(value)
570        .map_err(|error| SnapshotIdValidationError::new(&error.value, error.reason)),
571    schema(
572        pattern = r"^pin_[0-9]{20}-[0-9a-f]{16}$",
573        example = "pin_00000000000000000001-0000000000000002"
574    )
575}
576
577impl From<SnapshotId> for CheckpointId {
578    fn from(snapshot_id: SnapshotId) -> Self {
579        Self(snapshot_id.0)
580    }
581}
582
583impl From<CheckpointId> for SnapshotId {
584    fn from(checkpoint_id: CheckpointId) -> Self {
585        Self(checkpoint_id.0)
586    }
587}
588
589impl CheckpointId {
590    /// Generates a new pin for the given manifest number.
591    pub fn generate(manifest_no: ManifestNo) -> Self {
592        let entropy = generated_id("pin");
593        Self::parse(format!("pin_{:020}-{}", manifest_no.0, &entropy[4..20]))
594            .expect("the pinned manifest number should be valid")
595    }
596
597    /// Returns the manifest number encoded in this id.
598    pub fn manifest_no(&self) -> ManifestNo {
599        ManifestNo(
600            self.0[4..24]
601                .parse()
602                .expect("a pin id should contain a manifest number"),
603        )
604    }
605}
606
607fn validate_checkpoint_id(value: &str) -> Result<(), GeneratedIdValidationError> {
608    let valid = value
609        .strip_prefix("pin_")
610        .and_then(|body| body.split_once('-'))
611        .is_some_and(|(number, entropy)| {
612            number.len() == 20
613                && number.bytes().all(|byte| byte.is_ascii_digit())
614                && number
615                    .parse::<u64>()
616                    .ok()
617                    .and_then(|number| ManifestNo::parse(number).ok())
618                    .is_some_and(|number| number.0 > 0)
619                && entropy.len() == 16
620                && entropy.bytes().all(is_lower_hex_byte)
621        });
622    if !valid {
623        return Err(GeneratedIdValidationError::new(value, "must be `pin_` followed by a twenty-digit positive manifest number, `-`, and sixteen lowercase hex characters".to_owned()));
624    }
625    Ok(())
626}
627
628string_id! {
629    /// Durable id for one upload session.
630    UploadId,
631    prefix = "upl",
632    schema(
633        pattern = r"^upl_[0-9a-f]{32}$",
634        example = "upl_4d8f2c91a7b34e0f9c6d1a2b3e5f708c"
635    )
636}
637
638string_id! {
639    /// Durable identity of one immutable content object.
640    ///
641    /// The body is 128 fully random bits, with no time component: content
642    /// object keys shard on the id's leading characters, and a clock-derived
643    /// prefix would put every upload in one window into one shard. The id
644    /// names *which object*, never what it contains — integrity evidence
645    /// rides [`crate::ContentRef`] beside it.
646    ContentId,
647    prefix = "con",
648    schema(
649        pattern = r"^con_[0-9a-f]{32}$",
650        example = "con_9f2a6c0e4b7d4a90b13f0d8c5e6a2b41"
651    )
652}
653
654impl ContentId {
655    /// Returns the two-character components for both content-key shard levels.
656    ///
657    /// Every valid id has a 32-character lowercase hex body, so this never
658    /// panics.
659    pub fn shard_prefixes(&self) -> [&str; CONTENT_ID_SHARD_LEVELS] {
660        let first_start = CONTENT_ID_PREFIX_LEN;
661        let second_start = first_start + CONTENT_ID_SHARD_WIDTH;
662        [
663            &self.0[first_start..second_start],
664            &self.0[second_start..second_start + CONTENT_ID_SHARD_WIDTH],
665        ]
666    }
667}
668
669/// Byte length of the `con_` marker that precedes a content id's hex body.
670const CONTENT_ID_PREFIX_LEN: usize = "con_".len();
671/// Number of directory levels used to shard content objects.
672const CONTENT_ID_SHARD_LEVELS: usize = 2;
673/// Number of content-id body characters in each shard directory name.
674const CONTENT_ID_SHARD_WIDTH: usize = 2;
675
676string_id! {
677    /// Durable id for one metadata segment.
678    MetadataSegmentId,
679    prefix = "seg"
680}
681
682string_id! {
683    /// Identifies one streaming metadata compaction job for log correlation only.
684    MetadataCompactionId,
685    prefix = "cmp"
686}
687
688string_id! {
689    /// Durable id for one derived-index segment file.
690    IndexSegmentId,
691    prefix = "idx"
692}
693
694string_id! {
695    /// Name-policy-derived directory entry key.
696    ///
697    /// Use this for exact name preconditions. Keep user-facing spelling in
698    /// `DisplayName`.
699    NameKey,
700    error = NameKeyValidationError,
701    validate = validate_name_key,
702    schema(example = "report.txt")
703}
704
705impl NameKey {
706    /// Computes the lookup key for a display name.
707    pub fn for_display_name(display_name: &crate::DisplayName) -> Self {
708        Self(crate::name_key_for_display_name(display_name.as_str()))
709    }
710}
711
712// ---------------------------------------------------------------------------
713// Numeric ids
714// ---------------------------------------------------------------------------
715
716/// Maximum value for an ordinal exposed through the API.
717///
718/// This is `2^53 - 1`, the largest integer JSON clients can represent
719/// without losing precision.
720pub const MAX_PUBLIC_INTEGER: u64 = 9_007_199_254_740_991;
721
722/// Returned when an ordinal exceeds [`MAX_PUBLIC_INTEGER`].
723#[derive(Debug, Clone, Copy, PartialEq, Eq)]
724pub struct PublicOrdinalRangeError;
725
726impl fmt::Display for PublicOrdinalRangeError {
727    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
728        write!(f, "must be an integer from 0 through {MAX_PUBLIC_INTEGER}")
729    }
730}
731
732impl std::error::Error for PublicOrdinalRangeError {}
733
734/// Returns the next ordinal, or `None` if the value is already at the limit.
735pub fn next_public_ordinal(current: u64) -> Option<u64> {
736    current
737        .checked_add(1)
738        .filter(|next| *next <= MAX_PUBLIC_INTEGER)
739}
740
741numeric_id! {
742    /// Numeric identity of a file or directory within a namespace.
743    ///
744    /// Inodes are stable across renames.
745    InodeId
746}
747
748#[cfg(feature = "openapi")]
749#[allow(
750    deprecated,
751    reason = "the published schema uses the requested singular example field"
752)]
753impl utoipa::PartialSchema for InodeId {
754    fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
755        utoipa::openapi::schema::Object::builder()
756            .schema_type(utoipa::openapi::schema::Type::String)
757            .pattern(Some(crate::public_inode_id::PATTERN))
758            .example(Some(serde_json::json!(crate::public_inode_id::EXAMPLE)))
759            .description(Some(crate::public_inode_id::DESCRIPTION))
760            .into()
761    }
762}
763
764#[cfg(feature = "openapi")]
765impl utoipa::ToSchema for InodeId {}
766
767/// Inode 1 is always the root directory of a namespace.
768pub const ROOT_INODE_ID: InodeId = InodeId(1);
769
770/// First inode id available after the root inode.
771pub const FIRST_ALLOCATABLE_INODE_ID: InodeId = InodeId(ROOT_INODE_ID.0 + 1);
772
773numeric_id! {
774    /// Revision number for a file's content.
775    RevisionNo,
776    public_ordinal,
777    schema_description = "Revision number for a file's content. It increases whenever the content is replaced or restored."
778}
779
780numeric_id! {
781    /// Sequence number assigned to a namespace commit.
782    ///
783    /// This number determines the order in which commits become visible.
784    ChangeSeq,
785    public_ordinal,
786    schema_description = "Sequence number assigned to a namespace commit. It determines the order in which commits become visible."
787}
788
789numeric_id! {
790    /// Contiguous WAL object number within one namespace.
791    WalNo,
792    public_ordinal,
793    schema_description = "Contiguous WAL object number within one namespace."
794}
795
796numeric_id! {
797    /// Monotonic manifest counter for one namespace.
798    ///
799    /// The manifest number can increase when metadata changes, even if no
800    /// namespace commit is written.
801    ManifestNo,
802    public_ordinal,
803    schema_description = "Monotonic manifest counter for one namespace. It can increase when metadata changes, even if no namespace commit is written."
804}
805
806numeric_id! {
807    /// Monotonic run counter allocated by the manifest that names the run.
808    ///
809    /// A run is the set of segments one producer wrote together. The
810    /// namespace manifest and the grep manifest each keep their own counter,
811    /// so a run number means nothing outside the manifest that allocated it.
812    RunNo,
813    public_ordinal,
814    schema_description = "Monotonic run counter allocated by the manifest that names the run. A run is the set of segments one producer wrote together."
815}
816
817numeric_id! {
818    /// Counter used to reject writes from an older writer.
819    WriterEpoch,
820    public_ordinal,
821    schema_description = "Counter used to reject writes from an older writer."
822}
823
824// ---------------------------------------------------------------------------
825// Filesystem item kind
826// ---------------------------------------------------------------------------
827
828/// Filesystem item kind.
829#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
830#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
831#[serde(rename_all = "snake_case")]
832pub enum InodeKind {
833    /// File with revision history.
834    File,
835    /// Directory with child bindings.
836    ///
837    /// The wire value is pinned to `"dir"`; only the Rust name spells the
838    /// word out.
839    #[serde(rename = "dir")]
840    Directory,
841}
842
843impl InodeKind {
844    /// Returns the serialized value.
845    pub const fn as_str(self) -> &'static str {
846        match self {
847            Self::File => "file",
848            Self::Directory => "dir",
849        }
850    }
851}
852
853impl fmt::Display for InodeKind {
854    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
855        f.write_str(self.as_str())
856    }
857}
858
859#[cfg(test)]
860mod tests {
861    use super::{
862        next_public_ordinal, BindingGeneration, ChangeSeq, CheckpointId, CommitId, ContentId,
863        ContentStoreId, InodeId, ManifestNo, MetadataSegmentId, NameKey, NamespaceId, RevisionNo,
864        RunNo, SnapshotId, UploadId, WalNo, WriterEpoch, WriterId, MAX_PUBLIC_INTEGER,
865    };
866    use crate::AttributeRevisionNo;
867    use std::collections::BTreeSet;
868
869    #[test]
870    fn public_ordinal_advancement_accepts_the_maximum_and_rejects_the_next_value() {
871        assert_eq!(
872            next_public_ordinal(MAX_PUBLIC_INTEGER - 1),
873            Some(MAX_PUBLIC_INTEGER)
874        );
875        assert_eq!(next_public_ordinal(MAX_PUBLIC_INTEGER), None);
876    }
877
878    #[test]
879    fn public_ordinal_inputs_must_fit_the_json_safe_integer_range() {
880        macro_rules! assert_range {
881            ($type:ty) => {{
882                let constructed = <$type>::parse(MAX_PUBLIC_INTEGER)
883                    .expect("construct the maximum public ordinal");
884                assert_eq!(constructed.0, MAX_PUBLIC_INTEGER);
885
886                let construction_error = <$type>::parse(MAX_PUBLIC_INTEGER + 1)
887                    .expect_err("reject a value above the public limit");
888                assert_eq!(
889                    construction_error.to_string(),
890                    "must be an integer from 0 through 9007199254740991"
891                );
892
893                let maximum = serde_json::from_str::<$type>(&MAX_PUBLIC_INTEGER.to_string())
894                    .expect("deserialize the maximum public ordinal");
895                assert_eq!(maximum.0, MAX_PUBLIC_INTEGER);
896
897                let error = serde_json::from_str::<$type>(&(MAX_PUBLIC_INTEGER + 1).to_string())
898                    .expect_err("ordinal above the public range");
899                assert!(
900                    error
901                        .to_string()
902                        .contains("must be an integer from 0 through 9007199254740991"),
903                    "unexpected range error: {error}"
904                );
905            }};
906        }
907
908        assert_range!(RevisionNo);
909        assert_range!(ChangeSeq);
910        assert_range!(AttributeRevisionNo);
911        assert_range!(ManifestNo);
912        assert_range!(WalNo);
913        assert_range!(RunNo);
914        assert_range!(WriterEpoch);
915
916        assert_eq!(
917            serde_json::from_str::<InodeId>(&(MAX_PUBLIC_INTEGER + 1).to_string())
918                .expect("inode ids retain the full u64 range"),
919            InodeId(MAX_PUBLIC_INTEGER + 1)
920        );
921    }
922
923    #[test]
924    fn namespace_id_parse_accepts_allowed_grammar() {
925        let long_id = format!("a{}", "b".repeat(127));
926        for value in ["demo", "demo-1", "demo_1", "demo.v1", &long_id] {
927            let parsed = NamespaceId::parse(value).expect("valid namespace_id");
928            assert_eq!(parsed.as_str(), value);
929        }
930    }
931
932    #[test]
933    fn namespace_id_parse_rejects_invalid_values() {
934        let long_id = format!("a{}", "b".repeat(128));
935        for value in [
936            "", "/", "a/b", ".", "..", " demo", "demo ", "demo\n", "demo?", "demo#", "demo%",
937            "Demo", &long_id,
938        ] {
939            assert!(
940                NamespaceId::parse(value).is_err(),
941                "expected invalid namespace_id {value:?}"
942            );
943        }
944    }
945
946    #[test]
947    fn namespace_id_parse_rejects_reserved_system_prefix() {
948        assert!(NamespaceId::parse("loonfs-doctor-abc").is_err());
949        assert!(NamespaceId::parse("loonfs-").is_err());
950        // The reservation is a prefix rule, not a substring rule.
951        assert_eq!(
952            NamespaceId::parse("my-loonfs-notes")
953                .expect("non-prefixed use is allowed")
954                .as_str(),
955            "my-loonfs-notes"
956        );
957        // Commit ids share the base grammar but not the reservation.
958        assert!(CommitId::parse("loonfs-retry-1").is_ok());
959    }
960
961    #[test]
962    fn writer_id_rejects_blank_text() {
963        for value in ["", " ", "\n", " \t "] {
964            assert!(WriterId::parse(value).is_err(), "accepted {value:?}");
965        }
966    }
967
968    #[test]
969    fn binding_generation_requires_nonempty_lowercase_hex() {
970        for value in ["", "abcg", "ABC", "01-23"] {
971            assert!(
972                BindingGeneration::parse(value).is_err(),
973                "accepted {value:?}"
974            );
975        }
976        for value in ["0", "0123456789abcdef"] {
977            assert_eq!(
978                BindingGeneration::parse(value)
979                    .expect("valid binding generation")
980                    .as_str(),
981                value
982            );
983        }
984    }
985
986    #[test]
987    fn identity_try_from_validates_values() {
988        assert_eq!(
989            NamespaceId::try_from("demo")
990                .expect("valid namespace id")
991                .as_str(),
992            "demo"
993        );
994        assert_eq!(
995            CommitId::try_from("commit-1")
996                .expect("valid commit id")
997                .as_str(),
998            "commit-1"
999        );
1000        assert_eq!(
1001            ContentStoreId::try_from("cs_00000000000000000000000000000001")
1002                .expect("valid content store id")
1003                .as_str(),
1004            "cs_00000000000000000000000000000001"
1005        );
1006        assert_eq!(
1007            CheckpointId::try_from("pin_00000000000000000001-0000000000000001")
1008                .expect("valid checkpoint id")
1009                .as_str(),
1010            "pin_00000000000000000001-0000000000000001"
1011        );
1012        assert_eq!(
1013            NameKey::try_from("report.txt".to_owned())
1014                .expect("valid name key")
1015                .as_str(),
1016            "report.txt"
1017        );
1018
1019        assert!(NamespaceId::try_from("invalid/name").is_err());
1020        assert!(CommitId::try_from("invalid/name").is_err());
1021        assert!(ContentStoreId::try_from("cs_0000000000000000000000000000000g").is_err());
1022        assert!(CheckpointId::try_from("chk_0000000000000000000000000000000g").is_err());
1023        assert!(NameKey::try_from("a/b").is_err());
1024    }
1025
1026    #[test]
1027    fn identity_deserialize_validates_values() {
1028        let namespace_id: NamespaceId =
1029            serde_json::from_str(r#""demo""#).expect("valid namespace id json");
1030        assert_eq!(namespace_id.as_str(), "demo");
1031        let commit_id: CommitId =
1032            serde_json::from_str(r#""commit-1""#).expect("valid commit id json");
1033        assert_eq!(commit_id.as_str(), "commit-1");
1034        let content_store_id: ContentStoreId =
1035            serde_json::from_str(r#""cs_00000000000000000000000000000001""#)
1036                .expect("valid content store id json");
1037        assert_eq!(
1038            content_store_id.as_str(),
1039            "cs_00000000000000000000000000000001"
1040        );
1041        let checkpoint_id: CheckpointId =
1042            serde_json::from_str(r#""pin_00000000000000000001-0000000000000001""#)
1043                .expect("valid checkpoint id json");
1044        assert_eq!(
1045            checkpoint_id.as_str(),
1046            "pin_00000000000000000001-0000000000000001"
1047        );
1048
1049        let namespace_error = serde_json::from_str::<NamespaceId>(r#""invalid/name""#)
1050            .expect_err("invalid namespace id json");
1051        assert!(namespace_error.to_string().contains("namespace_id"));
1052        let commit_error = serde_json::from_str::<CommitId>(r#""invalid/name""#)
1053            .expect_err("invalid commit id json");
1054        assert!(commit_error.to_string().contains("commit_id"));
1055        let content_store_error =
1056            serde_json::from_str::<ContentStoreId>(r#""cs_0000000000000000000000000000000g""#)
1057                .expect_err("invalid content store id json");
1058        assert!(content_store_error.to_string().contains("generated id"));
1059        let checkpoint_error =
1060            serde_json::from_str::<CheckpointId>(r#""chk_0000000000000000000000000000000g""#)
1061                .expect_err("invalid checkpoint id json");
1062        assert!(checkpoint_error.to_string().contains("generated id"));
1063    }
1064
1065    #[test]
1066    fn generated_content_store_id_parse_requires_prefix_and_lower_hex_body() {
1067        let parsed = ContentStoreId::parse("cs_00000000000000000000000000000001")
1068            .expect("valid content store id");
1069
1070        assert_eq!(parsed.as_str(), "cs_00000000000000000000000000000001");
1071        let hyphenated_content_store_id = ["cs", "1"].join("-");
1072        for value in [
1073            hyphenated_content_store_id.as_str(),
1074            "upl_00000000000000000000000000000001",
1075            "content-stores/foo",
1076            "cs_",
1077            "cs_abcdef",
1078            "cs_0000000000000000000000000000000",
1079            "cs_000000000000000000000000000000001",
1080            "cs_ABCDEF00000000000000000000000000",
1081            "cs_0000000000000000000000000000000g",
1082            " cs_00000000000000000000000000000001",
1083            "cs_00000000000000000000000000000001 ",
1084        ] {
1085            assert!(
1086                ContentStoreId::parse(value).is_err(),
1087                "expected invalid content store id {value:?}"
1088            );
1089        }
1090    }
1091
1092    #[test]
1093    fn generated_upload_wal_metadata_segment_and_checkpoint_ids_reject_hyphenated_ids() {
1094        assert!(UploadId::parse("upl_00000000000000000000000000000001").is_ok());
1095        assert!(MetadataSegmentId::parse("seg_00000000000000000000000000000001").is_ok());
1096        assert!(CheckpointId::parse("pin_00000000000000000001-0000000000000001").is_ok());
1097        assert!(UploadId::parse(["upl", "123"].join("-")).is_err());
1098        // The two positional families are told apart by their prefix, never
1099        // by context.
1100        assert!(MetadataSegmentId::parse(["seg", "123"].join("-")).is_err());
1101        assert!(CheckpointId::parse(["chk", "123"].join("-")).is_err());
1102    }
1103
1104    #[test]
1105    fn generated_runtime_ids_use_lower_hex_bodies() {
1106        let upload_id = UploadId::generate();
1107        let metadata_segment_id = MetadataSegmentId::generate();
1108        let checkpoint_id = CheckpointId::generate(ManifestNo(1));
1109
1110        assert_generated_id_shape(upload_id.as_str(), "upl");
1111        assert_generated_id_shape(metadata_segment_id.as_str(), "seg");
1112        assert_eq!(checkpoint_id.manifest_no(), ManifestNo(1));
1113        assert!(UploadId::parse(upload_id.as_str()).is_ok());
1114        assert!(MetadataSegmentId::parse(metadata_segment_id.as_str()).is_ok());
1115        assert!(CheckpointId::parse(checkpoint_id.as_str()).is_ok());
1116    }
1117
1118    #[test]
1119    fn checkpoint_ids_order_and_validate_their_manifest_numbers() {
1120        let first = CheckpointId::parse("pin_00000000000000000009-ffffffffffffffff").expect("pin");
1121        let second = CheckpointId::parse("pin_00000000000000000010-0000000000000000").expect("pin");
1122        assert!(first < second);
1123        assert_eq!(second.manifest_no(), ManifestNo(10));
1124        let snapshot_id = SnapshotId::from(second.clone());
1125        let decoded: SnapshotId = serde_json::from_str(
1126            &serde_json::to_string(&snapshot_id).expect("serialize snapshot id"),
1127        )
1128        .expect("decode snapshot id");
1129        assert_eq!(CheckpointId::from(decoded), second);
1130        for invalid in [
1131            "pin_00000000000000000000-0000000000000000".to_owned(),
1132            format!("pin_{:020}-0000000000000000", MAX_PUBLIC_INTEGER + 1),
1133            "pin_00000000000000000001-000000000000000G".to_owned(),
1134            "pin_1-0000000000000000".to_owned(),
1135            "pin_00000000000000000001-00000000000000000".to_owned(),
1136        ] {
1137            assert!(CheckpointId::parse(&invalid).is_err());
1138            assert!(SnapshotId::parse(&invalid).is_err());
1139        }
1140    }
1141
1142    #[test]
1143    fn generated_content_ids_are_unique_and_shard_uniformly() {
1144        let mut ids = BTreeSet::new();
1145        let mut first_level_shards = BTreeSet::new();
1146        let mut leaf_shards = BTreeSet::new();
1147        for _ in 0..512 {
1148            let id = ContentId::generate();
1149            assert_generated_id_shape(id.as_str(), "con");
1150            let [first, second] = id.shard_prefixes();
1151            assert_eq!(first, &id.as_str()["con_".len().."con_".len() + 2]);
1152            assert_eq!(second, &id.as_str()["con_".len() + 2.."con_".len() + 4]);
1153            first_level_shards.insert(first.to_owned());
1154            leaf_shards.insert(format!("{first}/{second}"));
1155            assert!(
1156                ids.insert(id.clone()),
1157                "generated duplicate content id {id}"
1158            );
1159        }
1160        // 512 draws over 256 first-level and 65,536 leaf shards: a generator
1161        // with a fixed or clock-derived prefix would collapse into a handful
1162        // of shards.
1163        assert!(
1164            first_level_shards.len() > 128,
1165            "content id first-level shards are not spread: {} distinct",
1166            first_level_shards.len()
1167        );
1168        assert!(
1169            leaf_shards.len() > 480,
1170            "content id leaf shards are not spread: {} distinct",
1171            leaf_shards.len()
1172        );
1173    }
1174
1175    #[test]
1176    fn content_id_parse_requires_the_generated_id_shape() {
1177        assert!(ContentId::parse("con_0123456789abcdef0123456789abcdef").is_ok());
1178        assert!(ContentId::parse("upl_0123456789abcdef0123456789abcdef").is_err());
1179    }
1180
1181    fn assert_generated_id_shape(value: &str, prefix: &str) {
1182        let expected_prefix = format!("{prefix}_");
1183        let body = value
1184            .strip_prefix(&expected_prefix)
1185            .expect("generated id prefix");
1186        assert_eq!(body.len(), 32);
1187        assert!(
1188            body.bytes()
1189                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)),
1190            "generated id body must be lowercase hex: {value}"
1191        );
1192    }
1193
1194    #[test]
1195    fn name_key_parse_rejects_invalid_values() {
1196        assert_eq!(
1197            NameKey::parse("").expect_err("empty").reason(),
1198            "must not be empty"
1199        );
1200        assert_eq!(
1201            NameKey::parse("a/b").expect_err("slash").reason(),
1202            "must not contain `/`"
1203        );
1204        assert_eq!(
1205            NameKey::parse(".").expect_err("dot").reason(),
1206            "must not be `.` or `..`"
1207        );
1208        assert_eq!(
1209            NameKey::parse("a\u{0}b").expect_err("control").reason(),
1210            "must not contain control characters"
1211        );
1212        NameKey::parse("k".repeat(super::MAX_NAME_KEY_BYTES)).expect("cap is inclusive");
1213        assert_eq!(
1214            NameKey::parse("k".repeat(super::MAX_NAME_KEY_BYTES + 1))
1215                .expect_err("over cap")
1216                .reason(),
1217            "exceeds the maximum name key length of 768 bytes"
1218        );
1219    }
1220
1221    #[test]
1222    fn name_key_serializes_as_string_and_validates_deserialize() {
1223        let name_key = NameKey::parse("report.txt").expect("valid name key");
1224
1225        assert_eq!(
1226            serde_json::to_string(&name_key).expect("serialize name key"),
1227            "\"report.txt\""
1228        );
1229        assert_eq!(
1230            serde_json::from_str::<NameKey>("\"report.txt\"").expect("deserialize name key"),
1231            name_key
1232        );
1233        assert!(serde_json::from_str::<NameKey>("\"a/b\"").is_err());
1234    }
1235}