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            /// 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
405/// Splits a positional id into its position digits and its random suffix.
406///
407/// Positional ids carry a family prefix like every other generated id, then
408/// the 20-digit position that gives a listing its order, then a random
409/// suffix that keeps competing proposals for one position distinct.
410fn parse_position_suffix_id<'a>(
411    prefix: &str,
412    position_field: &str,
413    value: &'a str,
414) -> Result<(&'a str, &'a str), GeneratedIdValidationError> {
415    let Some((position, suffix)) = value
416        .strip_prefix(prefix)
417        .and_then(|rest| rest.strip_prefix('_'))
418        .and_then(|rest| rest.split_once('-'))
419    else {
420        return Err(generated_id_error(
421            value,
422            format!("must be `{prefix}_<20 digit {position_field}>-<16 lowercase hex>`"),
423        ));
424    };
425    if position.len() != 20 || !position.bytes().all(|byte| byte.is_ascii_digit()) {
426        return Err(generated_id_error(
427            value,
428            format!("`{position_field}` must be 20 decimal digits"),
429        ));
430    }
431    if suffix.len() != 16 || !suffix.bytes().all(is_lower_hex_byte) {
432        return Err(generated_id_error(
433            value,
434            "suffix must be 16 lowercase hex characters".to_owned(),
435        ));
436    }
437    Ok((position, suffix))
438}
439
440fn validate_id_grammar(value: &str) -> Result<(), String> {
441    if value.is_empty() {
442        return Err("must not be empty".to_owned());
443    }
444    if value.len() > MAX_ID_BYTES {
445        return Err(format!("must be {MAX_ID_BYTES} bytes or fewer"));
446    }
447    if value.trim() != value {
448        return Err("must not have leading or trailing whitespace".to_owned());
449    }
450    if matches!(value, "." | "..") {
451        return Err("must not be `.` or `..`".to_owned());
452    }
453
454    let mut chars = value.chars();
455    let first = chars
456        .next()
457        .expect("empty id returned before char validation");
458    if !first.is_ascii_lowercase() && !first.is_ascii_digit() {
459        return Err("must start with a lowercase ASCII letter or digit".to_owned());
460    }
461    if !chars.all(is_allowed_id_tail_char) {
462        return Err(
463            "must contain only lowercase ASCII letters, digits, `.`, `_`, or `-`".to_owned(),
464        );
465    }
466
467    Ok(())
468}
469
470fn is_allowed_id_tail_char(ch: char) -> bool {
471    ch.is_ascii_lowercase() || ch.is_ascii_digit() || matches!(ch, '.' | '_' | '-')
472}
473
474fn namespace_id_error(value: &str, reason: impl Into<String>) -> NamespaceIdValidationError {
475    NamespaceIdValidationError {
476        value: value.to_owned(),
477        reason: reason.into(),
478    }
479}
480
481fn commit_id_error(value: &str, reason: impl Into<String>) -> CommitIdValidationError {
482    CommitIdValidationError {
483        value: value.to_owned(),
484        reason: reason.into(),
485    }
486}
487
488fn generated_id_error(value: &str, reason: String) -> GeneratedIdValidationError {
489    GeneratedIdValidationError {
490        value: value.to_owned(),
491        reason,
492    }
493}
494
495fn name_key_error(value: &str, reason: impl Into<String>) -> NameKeyValidationError {
496    NameKeyValidationError {
497        value: value.to_owned(),
498        reason: reason.into(),
499    }
500}
501
502// ---------------------------------------------------------------------------
503// String ids
504// ---------------------------------------------------------------------------
505
506string_id! {
507    /// Durable id for one namespace.
508    ///
509    /// A namespace is one filesystem history. This id is not a display name and
510    /// should not be reused after destruction. Its serialized form is 1 to 128
511    /// lowercase ASCII letters, digits, dots, underscores, or hyphens, starting
512    /// with a letter or digit; the `loonfs-` prefix is reserved for system use.
513    NamespaceId,
514    error = NamespaceIdValidationError,
515    validate = validate_namespace_id,
516    schema(
517        // The `loonfs-` reservation stays in the description: a lookahead
518        // would state it, but portable pattern dialects (RE2, most SDK
519        // generators) reject lookaheads, so the pattern is the grammar only.
520        pattern = r"^[a-z0-9][a-z0-9._-]{0,127}$",
521        example = "demo"
522    )
523}
524
525string_id! {
526    /// Durable id for an immutable content store.
527    ///
528    /// Content stores own file bytes. Namespaces point at content stores.
529    ContentStoreId,
530    prefix = "cs"
531}
532
533string_id! {
534    /// Client-supplied idempotency key for one logical commit.
535    ///
536    /// Reuse the same `CommitId` when retrying the same request. The accepted
537    /// grammar is 1 to 128 lowercase ASCII letters, digits, dots, underscores,
538    /// or hyphens, starting with a letter or digit. [`CommitId::generate`] returns
539    /// `c_<32 lowercase hex>`, but callers may supply any value in that grammar.
540    CommitId,
541    error = CommitIdValidationError,
542    validate = validate_commit_id,
543    schema(
544        pattern = r"^[a-z0-9][a-z0-9._-]{0,127}$",
545        example = "c_f3a9c2d4b6e8417a90c5d2f8e1b7a6c0"
546    )
547}
548
549impl CommitId {
550    /// Generates a valid random commit id.
551    pub fn generate() -> Self {
552        Self(generated_id("c"))
553    }
554}
555
556string_id! {
557    /// Durable checkpoint identifier.
558    ///
559    /// A checkpoint is a durable bookmark to a namespace manifest version.
560    CheckpointId,
561    prefix = "chk",
562    schema(
563        pattern = r"^chk_[0-9a-f]{32}$",
564        example = "chk_00000000000000000000000000000002"
565    )
566}
567
568string_id! {
569    /// Durable id for one upload session.
570    UploadId,
571    prefix = "upl",
572    schema(
573        pattern = r"^upl_[0-9a-f]{32}$",
574        example = "upl_4d8f2c91a7b34e0f9c6d1a2b3e5f708c"
575    )
576}
577
578string_id! {
579    /// Durable identity of one immutable content object.
580    ///
581    /// The body is 128 fully random bits, with no time component: content
582    /// object keys shard on the id's leading characters, and a clock-derived
583    /// prefix would put every upload in one window into one shard. The id
584    /// names *which object*, never what it contains — integrity evidence
585    /// rides [`crate::ContentRef`] beside it.
586    ContentId,
587    prefix = "con",
588    schema(
589        pattern = r"^con_[0-9a-f]{32}$",
590        example = "con_9f2a6c0e4b7d4a90b13f0d8c5e6a2b41"
591    )
592}
593
594impl ContentId {
595    /// Returns the two-character components for both content-key shard levels.
596    ///
597    /// Every valid id has a 32-character lowercase hex body, so this never
598    /// panics.
599    pub fn shard_prefixes(&self) -> [&str; CONTENT_ID_SHARD_LEVELS] {
600        let first_start = CONTENT_ID_PREFIX_LEN;
601        let second_start = first_start + CONTENT_ID_SHARD_WIDTH;
602        [
603            &self.0[first_start..second_start],
604            &self.0[second_start..second_start + CONTENT_ID_SHARD_WIDTH],
605        ]
606    }
607}
608
609/// Byte length of the `con_` marker that precedes a content id's hex body.
610const CONTENT_ID_PREFIX_LEN: usize = "con_".len();
611/// Number of directory levels used to shard content objects.
612const CONTENT_ID_SHARD_LEVELS: usize = 2;
613/// Number of content-id body characters in each shard directory name.
614const CONTENT_ID_SHARD_WIDTH: usize = 2;
615
616string_id! {
617    /// Durable id for one metadata segment.
618    MetadataSegmentId,
619    prefix = "seg"
620}
621
622string_id! {
623    /// Durable id for one streaming metadata compaction job.
624    ///
625    /// The job's staged output and its lease live under one prefix named by
626    /// this id, so a collector can tell one job's output from another's
627    /// without reading anything.
628    MetadataCompactionId,
629    prefix = "cmp"
630}
631
632string_id! {
633    /// Durable id for one derived-index segment file.
634    IndexSegmentId,
635    prefix = "idx"
636}
637
638string_id! {
639    /// Durable object id for one stored grep manifest.
640    GrepManifestObjectId,
641    prefix = "gmf"
642}
643
644/// Family prefix carried by every namespace manifest object id.
645const MANIFEST_OBJECT_ID_PREFIX: &str = "man";
646/// Field a manifest object id's 20-digit position encodes.
647const MANIFEST_OBJECT_ID_POSITION_FIELD: &str = "manifest_no";
648
649string_id! {
650    /// Durable object id for one namespace manifest candidate.
651    ManifestObjectId,
652    error = GeneratedIdValidationError,
653    validate = |value: &str| {
654        parse_position_suffix_id(
655            MANIFEST_OBJECT_ID_PREFIX,
656            MANIFEST_OBJECT_ID_POSITION_FIELD,
657            value,
658        )
659        .map(|_| ())
660    }
661}
662
663impl ManifestObjectId {
664    /// Returns a unique object id that sorts by manifest number.
665    pub fn generate(manifest_no: ManifestNo) -> Self {
666        Self(format!(
667            "{MANIFEST_OBJECT_ID_PREFIX}_{:020}-{}",
668            manifest_no.0,
669            generated_position_suffix()
670        ))
671    }
672}
673
674/// Returns the manifest number in an object id's 20-digit position.
675pub fn manifest_object_id_manifest_no(object_id: &str) -> Option<ManifestNo> {
676    let (position, _) = parse_position_suffix_id(
677        MANIFEST_OBJECT_ID_PREFIX,
678        MANIFEST_OBJECT_ID_POSITION_FIELD,
679        object_id,
680    )
681    .ok()?;
682    position
683        .parse()
684        .ok()
685        .and_then(|value| ManifestNo::parse(value).ok())
686}
687
688/// Family prefix carried by every WAL segment id.
689const WAL_SEGMENT_ID_PREFIX: &str = "wal";
690/// Field a WAL segment id's 20-digit position encodes.
691const WAL_SEGMENT_ID_POSITION_FIELD: &str = "start_seq";
692
693string_id! {
694    /// Durable id for one WAL segment.
695    WalSegmentId,
696    error = GeneratedIdValidationError,
697    validate = |value: &str| {
698        parse_position_suffix_id(WAL_SEGMENT_ID_PREFIX, WAL_SEGMENT_ID_POSITION_FIELD, value)
699            .map(|_| ())
700    }
701}
702
703impl WalSegmentId {
704    /// WAL segment ids order by history position and stay unique under races.
705    ///
706    /// The 20 digits after `wal_` are the segment's `start_seq`, so listings
707    /// sort by position and reclamation can range-scan below a boundary
708    /// cursor. The 16-hex suffix keeps speculative writes unique: racing
709    /// writers proposing different segments for the same position never
710    /// collide, and the head compare-and-swap chooses among them. The name is
711    /// an inspection and reclamation hint only — recovery authority is the
712    /// head and chain.
713    pub fn generate(start_seq: ChangeSeq) -> Self {
714        Self(format!(
715            "{WAL_SEGMENT_ID_PREFIX}_{:020}-{}",
716            start_seq.0,
717            generated_position_suffix()
718        ))
719    }
720}
721
722/// Start seq encoded in a WAL segment id's 20-digit position.
723///
724/// Returns `None` when the value does not follow the generated id shape, so
725/// listings can skip foreign objects instead of failing. Like the name
726/// itself, the parsed position is an inspection and reclamation hint only —
727/// recovery authority is the head and chain.
728pub fn wal_segment_id_start_seq(segment_id: &str) -> Option<ChangeSeq> {
729    let (position, _) = parse_position_suffix_id(
730        WAL_SEGMENT_ID_PREFIX,
731        WAL_SEGMENT_ID_POSITION_FIELD,
732        segment_id,
733    )
734    .ok()?;
735    position
736        .parse()
737        .ok()
738        .and_then(|value| ChangeSeq::parse(value).ok())
739}
740
741string_id! {
742    /// Name-policy-derived directory entry key.
743    ///
744    /// Use this for exact name preconditions. Keep user-facing spelling in
745    /// `DisplayName`.
746    NameKey,
747    error = NameKeyValidationError,
748    validate = validate_name_key,
749    schema(example = "report.txt")
750}
751
752impl NameKey {
753    /// Computes the lookup key for a display name.
754    pub fn for_display_name(display_name: &crate::DisplayName) -> Self {
755        Self(crate::name_key_for_display_name(display_name.as_str()))
756    }
757}
758
759// ---------------------------------------------------------------------------
760// Numeric ids
761// ---------------------------------------------------------------------------
762
763/// Maximum value for an ordinal exposed through the API.
764///
765/// This is `2^53 - 1`, the largest integer JSON clients can represent
766/// without losing precision.
767pub const MAX_PUBLIC_INTEGER: u64 = 9_007_199_254_740_991;
768
769/// Returned when an ordinal exceeds [`MAX_PUBLIC_INTEGER`].
770#[derive(Debug, Clone, Copy, PartialEq, Eq)]
771pub struct PublicOrdinalRangeError;
772
773impl fmt::Display for PublicOrdinalRangeError {
774    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
775        write!(f, "must be an integer from 0 through {MAX_PUBLIC_INTEGER}")
776    }
777}
778
779impl std::error::Error for PublicOrdinalRangeError {}
780
781/// Returns the next ordinal, or `None` if the value is already at the limit.
782pub fn next_public_ordinal(current: u64) -> Option<u64> {
783    current
784        .checked_add(1)
785        .filter(|next| *next <= MAX_PUBLIC_INTEGER)
786}
787
788numeric_id! {
789    /// Numeric identity of a file or directory within a namespace.
790    ///
791    /// Inodes are stable across renames.
792    InodeId
793}
794
795/// Inode 1 is always the root directory of a namespace.
796pub const ROOT_INODE_ID: InodeId = InodeId(1);
797
798/// First inode id available after the root inode.
799pub const FIRST_ALLOCATABLE_INODE_ID: InodeId = InodeId(ROOT_INODE_ID.0 + 1);
800
801numeric_id! {
802    /// Revision number for a file's content.
803    RevisionNo,
804    public_ordinal,
805    schema_description = "Revision number for a file's content. It increases whenever the content is replaced or restored."
806}
807
808numeric_id! {
809    /// Sequence number assigned to a namespace commit.
810    ///
811    /// This number determines the order in which commits become visible.
812    ChangeSeq,
813    public_ordinal,
814    schema_description = "Sequence number assigned to a namespace commit. It determines the order in which commits become visible."
815}
816
817numeric_id! {
818    /// Monotonic manifest counter for one namespace.
819    ///
820    /// The manifest number can increase when metadata changes, even if no
821    /// namespace commit is written.
822    ManifestNo,
823    public_ordinal,
824    schema_description = "Monotonic manifest counter for one namespace. It can increase when metadata changes, even if no namespace commit is written."
825}
826
827numeric_id! {
828    /// Monotonic run counter allocated by the manifest that names the run.
829    ///
830    /// A run is the set of segments one producer wrote together. The
831    /// namespace manifest and the grep manifest each keep their own counter,
832    /// so a run number means nothing outside the manifest that allocated it.
833    RunNo,
834    public_ordinal,
835    schema_description = "Monotonic run counter allocated by the manifest that names the run. A run is the set of segments one producer wrote together."
836}
837
838impl RunNo {
839    /// Returns the next run number, or an error at the public maximum.
840    pub fn successor(self) -> Result<Self, PublicOrdinalRangeError> {
841        next_public_ordinal(self.0)
842            .map(Self)
843            .ok_or(PublicOrdinalRangeError)
844    }
845}
846
847numeric_id! {
848    /// Counter used to reject writes from an older writer.
849    WriterEpoch,
850    public_ordinal,
851    schema_description = "Counter used to reject writes from an older writer."
852}
853
854// ---------------------------------------------------------------------------
855// Filesystem item kind
856// ---------------------------------------------------------------------------
857
858/// Filesystem item kind.
859#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
860#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
861#[serde(rename_all = "snake_case")]
862pub enum InodeKind {
863    /// File with revision history.
864    File,
865    /// Directory with child bindings.
866    ///
867    /// The wire value is pinned to `"dir"`; only the Rust name spells the
868    /// word out.
869    #[serde(rename = "dir")]
870    Directory,
871}
872
873impl InodeKind {
874    /// Returns the serialized value.
875    pub const fn as_str(self) -> &'static str {
876        match self {
877            Self::File => "file",
878            Self::Directory => "dir",
879        }
880    }
881}
882
883impl fmt::Display for InodeKind {
884    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
885        f.write_str(self.as_str())
886    }
887}
888
889#[cfg(test)]
890mod tests {
891    use super::{
892        next_public_ordinal, ChangeSeq, CheckpointId, CommitId, ContentId, ContentStoreId, InodeId,
893        ManifestNo, ManifestObjectId, MetadataSegmentId, NameKey, NamespaceId, RevisionNo, RunNo,
894        UploadId, WalSegmentId, WriterEpoch, MAX_PUBLIC_INTEGER,
895    };
896    use crate::AttributeRevisionNo;
897    use std::collections::BTreeSet;
898
899    #[test]
900    fn public_ordinal_advancement_accepts_the_maximum_and_rejects_the_next_value() {
901        assert_eq!(
902            next_public_ordinal(MAX_PUBLIC_INTEGER - 1),
903            Some(MAX_PUBLIC_INTEGER)
904        );
905        assert_eq!(next_public_ordinal(MAX_PUBLIC_INTEGER), None);
906    }
907
908    #[test]
909    fn public_ordinal_inputs_must_fit_the_json_safe_integer_range() {
910        macro_rules! assert_range {
911            ($type:ty) => {{
912                let constructed = <$type>::parse(MAX_PUBLIC_INTEGER)
913                    .expect("construct the maximum public ordinal");
914                assert_eq!(constructed.0, MAX_PUBLIC_INTEGER);
915
916                let construction_error = <$type>::parse(MAX_PUBLIC_INTEGER + 1)
917                    .expect_err("reject a value above the public limit");
918                assert_eq!(
919                    construction_error.to_string(),
920                    "must be an integer from 0 through 9007199254740991"
921                );
922
923                let maximum = serde_json::from_str::<$type>(&MAX_PUBLIC_INTEGER.to_string())
924                    .expect("deserialize the maximum public ordinal");
925                assert_eq!(maximum.0, MAX_PUBLIC_INTEGER);
926
927                let error = serde_json::from_str::<$type>(&(MAX_PUBLIC_INTEGER + 1).to_string())
928                    .expect_err("ordinal above the public range");
929                assert!(
930                    error
931                        .to_string()
932                        .contains("must be an integer from 0 through 9007199254740991"),
933                    "unexpected range error: {error}"
934                );
935            }};
936        }
937
938        assert_range!(RevisionNo);
939        assert_range!(ChangeSeq);
940        assert_range!(AttributeRevisionNo);
941        assert_range!(ManifestNo);
942        assert_range!(RunNo);
943        assert_range!(WriterEpoch);
944
945        assert_eq!(
946            serde_json::from_str::<InodeId>(&(MAX_PUBLIC_INTEGER + 1).to_string())
947                .expect("inode ids retain the full u64 range"),
948            InodeId(MAX_PUBLIC_INTEGER + 1)
949        );
950    }
951
952    #[test]
953    fn namespace_id_parse_accepts_allowed_grammar() {
954        let long_id = format!("a{}", "b".repeat(127));
955        for value in ["demo", "demo-1", "demo_1", "demo.v1", &long_id] {
956            let parsed = NamespaceId::parse(value).expect("valid namespace_id");
957            assert_eq!(parsed.as_str(), value);
958        }
959    }
960
961    #[test]
962    fn namespace_id_parse_rejects_invalid_values() {
963        let long_id = format!("a{}", "b".repeat(128));
964        for value in [
965            "", "/", "a/b", ".", "..", " demo", "demo ", "demo\n", "demo?", "demo#", "demo%",
966            "Demo", &long_id,
967        ] {
968            assert!(
969                NamespaceId::parse(value).is_err(),
970                "expected invalid namespace_id {value:?}"
971            );
972        }
973    }
974
975    #[test]
976    fn namespace_id_parse_rejects_reserved_system_prefix() {
977        assert!(NamespaceId::parse("loonfs-doctor-abc").is_err());
978        assert!(NamespaceId::parse("loonfs-").is_err());
979        // The reservation is a prefix rule, not a substring rule.
980        assert_eq!(
981            NamespaceId::parse("my-loonfs-notes")
982                .expect("non-prefixed use is allowed")
983                .as_str(),
984            "my-loonfs-notes"
985        );
986        // Commit ids share the base grammar but not the reservation.
987        assert!(CommitId::parse("loonfs-retry-1").is_ok());
988    }
989
990    #[test]
991    fn identity_try_from_validates_values() {
992        assert_eq!(
993            NamespaceId::try_from("demo")
994                .expect("valid namespace id")
995                .as_str(),
996            "demo"
997        );
998        assert_eq!(
999            CommitId::try_from("commit-1")
1000                .expect("valid commit id")
1001                .as_str(),
1002            "commit-1"
1003        );
1004        assert_eq!(
1005            ContentStoreId::try_from("cs_00000000000000000000000000000001")
1006                .expect("valid content store id")
1007                .as_str(),
1008            "cs_00000000000000000000000000000001"
1009        );
1010        assert_eq!(
1011            CheckpointId::try_from("chk_00000000000000000000000000000001")
1012                .expect("valid checkpoint id")
1013                .as_str(),
1014            "chk_00000000000000000000000000000001"
1015        );
1016        assert_eq!(
1017            NameKey::try_from("report.txt".to_owned())
1018                .expect("valid name key")
1019                .as_str(),
1020            "report.txt"
1021        );
1022        assert_eq!(
1023            ManifestObjectId::try_from("man_00000000000000000042-0123456789abcdef")
1024                .expect("valid manifest object id")
1025                .as_str(),
1026            "man_00000000000000000042-0123456789abcdef"
1027        );
1028
1029        assert!(NamespaceId::try_from("invalid/name").is_err());
1030        assert!(CommitId::try_from("invalid/name").is_err());
1031        assert!(ContentStoreId::try_from("cs_0000000000000000000000000000000g").is_err());
1032        assert!(CheckpointId::try_from("chk_0000000000000000000000000000000g").is_err());
1033        assert!(NameKey::try_from("a/b").is_err());
1034        assert!(ManifestObjectId::try_from("42-0123456789abcdef").is_err());
1035    }
1036
1037    #[test]
1038    fn identity_deserialize_validates_values() {
1039        let namespace_id: NamespaceId =
1040            serde_json::from_str(r#""demo""#).expect("valid namespace id json");
1041        assert_eq!(namespace_id.as_str(), "demo");
1042        let commit_id: CommitId =
1043            serde_json::from_str(r#""commit-1""#).expect("valid commit id json");
1044        assert_eq!(commit_id.as_str(), "commit-1");
1045        let content_store_id: ContentStoreId =
1046            serde_json::from_str(r#""cs_00000000000000000000000000000001""#)
1047                .expect("valid content store id json");
1048        assert_eq!(
1049            content_store_id.as_str(),
1050            "cs_00000000000000000000000000000001"
1051        );
1052        let checkpoint_id: CheckpointId =
1053            serde_json::from_str(r#""chk_00000000000000000000000000000001""#)
1054                .expect("valid checkpoint id json");
1055        assert_eq!(
1056            checkpoint_id.as_str(),
1057            "chk_00000000000000000000000000000001"
1058        );
1059
1060        let namespace_error = serde_json::from_str::<NamespaceId>(r#""invalid/name""#)
1061            .expect_err("invalid namespace id json");
1062        assert!(namespace_error.to_string().contains("namespace_id"));
1063        let commit_error = serde_json::from_str::<CommitId>(r#""invalid/name""#)
1064            .expect_err("invalid commit id json");
1065        assert!(commit_error.to_string().contains("commit_id"));
1066        let content_store_error =
1067            serde_json::from_str::<ContentStoreId>(r#""cs_0000000000000000000000000000000g""#)
1068                .expect_err("invalid content store id json");
1069        assert!(content_store_error.to_string().contains("generated id"));
1070        let checkpoint_error =
1071            serde_json::from_str::<CheckpointId>(r#""chk_0000000000000000000000000000000g""#)
1072                .expect_err("invalid checkpoint id json");
1073        assert!(checkpoint_error.to_string().contains("generated id"));
1074    }
1075
1076    #[test]
1077    fn generated_content_store_id_parse_requires_prefix_and_lower_hex_body() {
1078        let parsed = ContentStoreId::parse("cs_00000000000000000000000000000001")
1079            .expect("valid content store id");
1080
1081        assert_eq!(parsed.as_str(), "cs_00000000000000000000000000000001");
1082        let hyphenated_content_store_id = ["cs", "1"].join("-");
1083        for value in [
1084            hyphenated_content_store_id.as_str(),
1085            "upl_00000000000000000000000000000001",
1086            "content-stores/foo",
1087            "cs_",
1088            "cs_abcdef",
1089            "cs_0000000000000000000000000000000",
1090            "cs_000000000000000000000000000000001",
1091            "cs_ABCDEF00000000000000000000000000",
1092            "cs_0000000000000000000000000000000g",
1093            " cs_00000000000000000000000000000001",
1094            "cs_00000000000000000000000000000001 ",
1095        ] {
1096            assert!(
1097                ContentStoreId::parse(value).is_err(),
1098                "expected invalid content store id {value:?}"
1099            );
1100        }
1101    }
1102
1103    #[test]
1104    fn generated_upload_wal_metadata_segment_and_checkpoint_ids_reject_hyphenated_ids() {
1105        assert!(UploadId::parse("upl_00000000000000000000000000000001").is_ok());
1106        assert!(MetadataSegmentId::parse("seg_00000000000000000000000000000001").is_ok());
1107        assert!(CheckpointId::parse("chk_00000000000000000000000000000001").is_ok());
1108        assert!(UploadId::parse(["upl", "123"].join("-")).is_err());
1109        assert!(WalSegmentId::parse("wal_00000000000000000412-9f2a6c0e4b7d4a90").is_ok());
1110        assert!(ManifestObjectId::parse("man_00000000000000000412-9f2a6c0e4b7d4a90").is_ok());
1111        assert!(WalSegmentId::parse("wal_412-9f2a6c0e4b7d4a90").is_err());
1112        assert!(WalSegmentId::parse("wal_00000000000000000412-9F2A6C0E4B7D4A90").is_err());
1113        assert!(ManifestObjectId::parse("man_412-9f2a6c0e4b7d4a90").is_err());
1114        assert!(ManifestObjectId::parse("man_00000000000000000412-9F2A6C0E4B7D4A90").is_err());
1115        assert!(ManifestObjectId::parse("mf_9f2a6c0e4b7d4a90b13f0d8c5e6a2b41").is_err());
1116        assert!(WalSegmentId::parse("seg_9f2a6c0e4b7d4a90b13f0d8c5e6a2b41").is_err());
1117        // The two positional families are told apart by their prefix, never
1118        // by context.
1119        assert!(WalSegmentId::parse("00000000000000000412-9f2a6c0e4b7d4a90").is_err());
1120        assert!(ManifestObjectId::parse("00000000000000000412-9f2a6c0e4b7d4a90").is_err());
1121        assert!(WalSegmentId::parse("man_00000000000000000412-9f2a6c0e4b7d4a90").is_err());
1122        assert!(ManifestObjectId::parse("wal_00000000000000000412-9f2a6c0e4b7d4a90").is_err());
1123        assert!(MetadataSegmentId::parse(["seg", "123"].join("-")).is_err());
1124        assert!(CheckpointId::parse(["chk", "123"].join("-")).is_err());
1125    }
1126
1127    #[test]
1128    fn generated_runtime_ids_use_lower_hex_bodies() {
1129        let upload_id = UploadId::generate();
1130        let wal_segment_id = WalSegmentId::generate(ChangeSeq(412));
1131        let manifest_object_id = ManifestObjectId::generate(ManifestNo(413));
1132        let metadata_segment_id = MetadataSegmentId::generate();
1133        let checkpoint_id = CheckpointId::generate();
1134
1135        assert_generated_id_shape(upload_id.as_str(), "upl");
1136        assert!(wal_segment_id
1137            .as_str()
1138            .starts_with("wal_00000000000000000412-"));
1139        assert!(manifest_object_id
1140            .as_str()
1141            .starts_with("man_00000000000000000413-"));
1142        assert_generated_id_shape(metadata_segment_id.as_str(), "seg");
1143        assert_generated_id_shape(checkpoint_id.as_str(), "chk");
1144        assert!(UploadId::parse(upload_id.as_str()).is_ok());
1145        assert!(WalSegmentId::parse(wal_segment_id.as_str()).is_ok());
1146        assert!(ManifestObjectId::parse(manifest_object_id.as_str()).is_ok());
1147        assert!(MetadataSegmentId::parse(metadata_segment_id.as_str()).is_ok());
1148        assert!(CheckpointId::parse(checkpoint_id.as_str()).is_ok());
1149    }
1150
1151    #[test]
1152    fn generated_positional_ids_are_not_reused_across_samples() {
1153        let mut wal_segment_ids = BTreeSet::new();
1154        let mut manifest_object_ids = BTreeSet::new();
1155        for _ in 0..128 {
1156            let wal_segment_id = WalSegmentId::generate(ChangeSeq(412));
1157            assert!(
1158                wal_segment_ids.insert(wal_segment_id.clone()),
1159                "generated duplicate WAL segment id {wal_segment_id}"
1160            );
1161            let manifest_object_id = ManifestObjectId::generate(ManifestNo(412));
1162            assert!(
1163                manifest_object_ids.insert(manifest_object_id.clone()),
1164                "generated duplicate manifest object id {manifest_object_id}"
1165            );
1166        }
1167    }
1168
1169    #[test]
1170    fn wal_segment_id_start_seq_reads_the_position_digits() {
1171        assert_eq!(
1172            super::wal_segment_id_start_seq("wal_00000000000000000412-9f2a6c0e4b7d4a90"),
1173            Some(ChangeSeq(412))
1174        );
1175        assert_eq!(super::wal_segment_id_start_seq("not-a-segment-id"), None);
1176        assert_eq!(
1177            super::wal_segment_id_start_seq("wal_00009007199254740992-9f2a6c0e4b7d4a90"),
1178            None
1179        );
1180    }
1181
1182    #[test]
1183    fn manifest_object_id_manifest_no_reads_the_position_digits() {
1184        assert_eq!(
1185            super::manifest_object_id_manifest_no("man_00000000000000000412-9f2a6c0e4b7d4a90"),
1186            Some(ManifestNo(412))
1187        );
1188        assert_eq!(
1189            super::manifest_object_id_manifest_no("not-a-manifest-object-id"),
1190            None
1191        );
1192        assert_eq!(
1193            super::manifest_object_id_manifest_no("man_00009007199254740992-9f2a6c0e4b7d4a90"),
1194            None
1195        );
1196    }
1197
1198    #[test]
1199    fn generated_content_ids_are_unique_and_shard_uniformly() {
1200        let mut ids = BTreeSet::new();
1201        let mut first_level_shards = BTreeSet::new();
1202        let mut leaf_shards = BTreeSet::new();
1203        for _ in 0..512 {
1204            let id = ContentId::generate();
1205            assert_generated_id_shape(id.as_str(), "con");
1206            let [first, second] = id.shard_prefixes();
1207            assert_eq!(first, &id.as_str()["con_".len().."con_".len() + 2]);
1208            assert_eq!(second, &id.as_str()["con_".len() + 2.."con_".len() + 4]);
1209            first_level_shards.insert(first.to_owned());
1210            leaf_shards.insert(format!("{first}/{second}"));
1211            assert!(
1212                ids.insert(id.clone()),
1213                "generated duplicate content id {id}"
1214            );
1215        }
1216        // 512 draws over 256 first-level and 65,536 leaf shards: a generator
1217        // with a fixed or clock-derived prefix would collapse into a handful
1218        // of shards.
1219        assert!(
1220            first_level_shards.len() > 128,
1221            "content id first-level shards are not spread: {} distinct",
1222            first_level_shards.len()
1223        );
1224        assert!(
1225            leaf_shards.len() > 480,
1226            "content id leaf shards are not spread: {} distinct",
1227            leaf_shards.len()
1228        );
1229    }
1230
1231    #[test]
1232    fn content_id_parse_requires_the_generated_id_shape() {
1233        assert!(ContentId::parse("con_0123456789abcdef0123456789abcdef").is_ok());
1234        assert!(ContentId::parse("upl_0123456789abcdef0123456789abcdef").is_err());
1235    }
1236
1237    fn assert_generated_id_shape(value: &str, prefix: &str) {
1238        let expected_prefix = format!("{prefix}_");
1239        let body = value
1240            .strip_prefix(&expected_prefix)
1241            .expect("generated id prefix");
1242        assert_eq!(body.len(), 32);
1243        assert!(
1244            body.bytes()
1245                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)),
1246            "generated id body must be lowercase hex: {value}"
1247        );
1248    }
1249
1250    #[test]
1251    fn name_key_parse_rejects_invalid_values() {
1252        assert_eq!(
1253            NameKey::parse("").expect_err("empty").reason(),
1254            "must not be empty"
1255        );
1256        assert_eq!(
1257            NameKey::parse("a/b").expect_err("slash").reason(),
1258            "must not contain `/`"
1259        );
1260        assert_eq!(
1261            NameKey::parse(".").expect_err("dot").reason(),
1262            "must not be `.` or `..`"
1263        );
1264        assert_eq!(
1265            NameKey::parse("a\u{0}b").expect_err("control").reason(),
1266            "must not contain control characters"
1267        );
1268        NameKey::parse("k".repeat(super::MAX_NAME_KEY_BYTES)).expect("cap is inclusive");
1269        assert_eq!(
1270            NameKey::parse("k".repeat(super::MAX_NAME_KEY_BYTES + 1))
1271                .expect_err("over cap")
1272                .reason(),
1273            "exceeds the maximum name key length of 768 bytes"
1274        );
1275    }
1276
1277    #[test]
1278    fn name_key_serializes_as_string_and_validates_deserialize() {
1279        let name_key = NameKey::parse("report.txt").expect("valid name key");
1280
1281        assert_eq!(
1282            serde_json::to_string(&name_key).expect("serialize name key"),
1283            "\"report.txt\""
1284        );
1285        assert_eq!(
1286            serde_json::from_str::<NameKey>("\"report.txt\"").expect("deserialize name key"),
1287            name_key
1288        );
1289        assert!(serde_json::from_str::<NameKey>("\"a/b\"").is_err());
1290    }
1291}