Skip to main content

loonfs_api/
public_inode_id.rs

1//! Converts inode IDs between internal numbers and public API strings.
2//!
3//! Public IDs use the form `ino_<number>`. They are scoped to a namespace and
4//! should be treated as identifiers rather than numbers.
5
6use crate::ids::validation_error;
7use crate::InodeId;
8use serde::{Deserialize, Deserializer, Serialize, Serializer};
9use thiserror::Error;
10
11/// Prefix used by every public inode ID.
12pub const PREFIX: &str = "ino_";
13
14/// OpenAPI pattern for public inode IDs.
15pub const PATTERN: &str = r"^ino_[1-9][0-9]*$";
16
17/// Example public inode ID used in OpenAPI.
18pub const EXAMPLE: &str = "ino_123";
19
20/// OpenAPI description for public inode IDs.
21pub const DESCRIPTION: &str = "Stable inode ID within a namespace";
22
23const INVALID_REASON: &str = "must use `ino_` followed by a nonzero u64 without leading zeroes";
24
25validation_error!(PublicInodeIdError, "invalid inode id {value:?}: {reason}");
26
27/// Converts an inode ID to its public API string.
28pub fn encode(id: InodeId) -> String {
29    format!("{PREFIX}{}", id.0)
30}
31
32/// Parses and validates a public inode ID.
33pub fn decode(value: &str) -> Result<InodeId, PublicInodeIdError> {
34    let Some(suffix) = value.strip_prefix(PREFIX) else {
35        return Err(invalid(value));
36    };
37    if suffix.is_empty()
38        || suffix.starts_with('0')
39        || !suffix.bytes().all(|byte| byte.is_ascii_digit())
40    {
41        return Err(invalid(value));
42    }
43
44    suffix
45        .parse::<u64>()
46        .map(InodeId)
47        .map_err(|_| invalid(value))
48}
49
50fn invalid(value: &str) -> PublicInodeIdError {
51    PublicInodeIdError {
52        value: value.to_owned(),
53        reason: INVALID_REASON.to_owned(),
54    }
55}
56
57/// Serializes an inode ID as a public API string.
58pub fn serialize<S>(id: &InodeId, serializer: S) -> Result<S::Ok, S::Error>
59where
60    S: Serializer,
61{
62    encode(*id).serialize(serializer)
63}
64
65/// Deserializes an inode ID from its public API string.
66pub fn deserialize<'de, D>(deserializer: D) -> Result<InodeId, D::Error>
67where
68    D: Deserializer<'de>,
69{
70    let value = String::deserialize(deserializer)?;
71    decode(&value).map_err(serde::de::Error::custom)
72}
73
74/// Serde support for optional public inode ID fields.
75pub mod option {
76    use super::{decode, encode};
77    use crate::InodeId;
78    use serde::{Deserialize, Deserializer, Serialize, Serializer};
79
80    /// Serializes an optional inode ID as a public API string.
81    pub fn serialize<S>(id: &Option<InodeId>, serializer: S) -> Result<S::Ok, S::Error>
82    where
83        S: Serializer,
84    {
85        id.map(encode).serialize(serializer)
86    }
87
88    /// Deserializes an optional inode ID from its public API string.
89    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<InodeId>, D::Error>
90    where
91        D: Deserializer<'de>,
92    {
93        Option::<String>::deserialize(deserializer)?
94            .map(|value| decode(&value))
95            .transpose()
96            .map_err(serde::de::Error::custom)
97    }
98}
99
100/// Builds the OpenAPI schema for a public inode ID.
101#[cfg(feature = "openapi")]
102#[allow(
103    deprecated,
104    reason = "the published schema uses the requested singular example field"
105)]
106pub fn schema() -> utoipa::openapi::schema::Object {
107    utoipa::openapi::schema::Object::builder()
108        .schema_type(utoipa::openapi::schema::Type::String)
109        .pattern(Some(PATTERN))
110        .example(Some(serde_json::json!(EXAMPLE)))
111        .description(Some(DESCRIPTION))
112        .build()
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    fn assert_rejected(value: &str) {
120        let error = decode(value).expect_err("invalid public inode id");
121
122        assert_eq!(error.value(), value);
123        assert_eq!(error.reason(), INVALID_REASON);
124        assert_eq!(
125            error.to_string(),
126            format!("invalid inode id {value:?}: {INVALID_REASON}")
127        );
128    }
129
130    #[test]
131    fn decode_accepts_public_inode_id_grammar() {
132        for (value, expected) in [
133            ("ino_1", InodeId(1)),
134            ("ino_42", InodeId(42)),
135            ("ino_18446744073709551615", InodeId(u64::MAX)),
136        ] {
137            assert_eq!(decode(value).expect("valid public inode id"), expected);
138            assert_eq!(encode(expected), value);
139        }
140    }
141
142    macro_rules! rejection_test {
143        ($name:ident, $value:literal) => {
144            #[test]
145            fn $name() {
146                assert_rejected($value);
147            }
148        };
149    }
150
151    rejection_test!(decode_rejects_zero, "ino_0");
152    rejection_test!(decode_rejects_leading_zero, "ino_01");
153    rejection_test!(decode_rejects_sign, "ino_-1");
154    rejection_test!(decode_rejects_uppercase_prefix, "INO_27");
155    rejection_test!(decode_rejects_mixed_case_prefix, "Ino_27");
156    rejection_test!(decode_rejects_missing_prefix, "27");
157    rejection_test!(decode_rejects_empty_suffix, "ino_");
158    rejection_test!(decode_rejects_suffix_trailing_text, "ino_27x");
159    rejection_test!(decode_rejects_leading_whitespace, " ino_27");
160    rejection_test!(decode_rejects_trailing_whitespace, "ino_27 ");
161    rejection_test!(decode_rejects_empty_string, "");
162    rejection_test!(decode_rejects_u64_overflow, "ino_18446744073709551616");
163
164    #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
165    struct RequiredField {
166        #[serde(with = "crate::public_inode_id")]
167        inode_id: InodeId,
168    }
169
170    #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
171    struct OptionalField {
172        #[serde(
173            default,
174            skip_serializing_if = "Option::is_none",
175            with = "crate::public_inode_id::option"
176        )]
177        inode_id: Option<InodeId>,
178    }
179
180    #[test]
181    fn serde_adapter_uses_only_the_string_form() {
182        let value = RequiredField {
183            inode_id: InodeId(42),
184        };
185
186        assert_eq!(
187            serde_json::to_string(&value).expect("serialize public inode id"),
188            r#"{"inode_id":"ino_42"}"#
189        );
190        assert_eq!(
191            serde_json::from_str::<RequiredField>(r#"{"inode_id":"ino_42"}"#)
192                .expect("deserialize public inode id"),
193            value
194        );
195        assert!(serde_json::from_str::<RequiredField>(r#"{"inode_id":42}"#).is_err());
196    }
197
198    #[test]
199    fn optional_serde_adapter_composes_with_skip_serializing_if() {
200        let present = OptionalField {
201            inode_id: Some(InodeId(42)),
202        };
203        let absent = OptionalField { inode_id: None };
204
205        assert_eq!(
206            serde_json::to_string(&present).expect("serialize present public inode id"),
207            r#"{"inode_id":"ino_42"}"#
208        );
209        assert_eq!(
210            serde_json::to_string(&absent).expect("serialize absent public inode id"),
211            "{}"
212        );
213        assert_eq!(
214            serde_json::from_str::<OptionalField>(r#"{"inode_id":"ino_42"}"#)
215                .expect("deserialize present public inode id"),
216            present
217        );
218        assert_eq!(
219            serde_json::from_str::<OptionalField>(r#"{"inode_id":null}"#)
220                .expect("deserialize null public inode id"),
221            absent
222        );
223        assert_eq!(
224            serde_json::from_str::<OptionalField>("{}").expect("deserialize missing inode id"),
225            absent
226        );
227        assert!(serde_json::from_str::<OptionalField>(r#"{"inode_id":42}"#).is_err());
228    }
229}