Skip to main content

loonfs_api/
actor.rs

1//! Types for identifying who made a commit.
2//!
3//! Use a stable ID such as `usr_8f3c`, rather than an email address or display
4//! name. Profile changes should not change the actor recorded in file history.
5
6use serde::{Deserialize, Serialize};
7use std::fmt;
8use thiserror::Error;
9
10const MAX_ACTOR_ID_BYTES: usize = 256;
11
12/// Identifies the user, service, or system responsible for a commit.
13///
14/// LoonFS stores this value as provided. It does not authenticate the actor or
15/// look up profile information.
16#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
17#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
18#[serde(deny_unknown_fields)]
19pub struct ActorRef {
20    /// The type of actor.
21    pub kind: ActorKind,
22    /// A stable identifier supplied by the application.
23    pub id: ActorId,
24}
25
26impl ActorRef {
27    /// Creates a user actor.
28    pub fn user(id: ActorId) -> Self {
29        Self {
30            kind: ActorKind::User,
31            id,
32        }
33    }
34
35    /// Creates a service actor.
36    pub fn service(id: ActorId) -> Self {
37        Self {
38            kind: ActorKind::Service,
39            id,
40        }
41    }
42
43    /// Creates a system actor.
44    pub fn system(id: ActorId) -> Self {
45        Self {
46            kind: ActorKind::System,
47            id,
48        }
49    }
50
51    /// Returns the actor used when LoonFS creates a namespace root.
52    pub fn loonfs_system() -> Self {
53        Self::system(ActorId::parse("loonfs").expect("`loonfs` should be a valid actor id"))
54    }
55}
56
57/// The type of actor responsible for a commit.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
59#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
60#[serde(rename_all = "snake_case")]
61pub enum ActorKind {
62    /// A user of the application.
63    User,
64    /// An application, integration, or background worker.
65    ///
66    /// Use [`ActorKind::User`] when a service acts on behalf of a known user.
67    Service,
68    /// System activity that changes filesystem data.
69    ///
70    /// Maintenance that does not create a commit has no actor.
71    System,
72}
73
74impl ActorKind {
75    /// Returns the value used in serialized actor references.
76    pub fn as_str(self) -> &'static str {
77        match self {
78            Self::User => "user",
79            Self::Service => "service",
80            Self::System => "system",
81        }
82    }
83}
84
85/// A validated actor identifier supplied by the application.
86///
87/// Actor IDs may use the syntax of the application's identity system. They
88/// must contain between 1 and 256 UTF-8 bytes, must not begin or end with
89/// whitespace, and must not contain control characters.
90#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
91pub struct ActorId(String);
92
93impl ActorId {
94    /// Parses and validates an actor ID.
95    pub fn parse(value: impl AsRef<str>) -> Result<Self, ActorIdValidationError> {
96        let value = value.as_ref();
97        validate_actor_id(value)?;
98        Ok(Self(value.to_owned()))
99    }
100
101    /// Returns the actor ID as a string.
102    pub fn as_str(&self) -> &str {
103        &self.0
104    }
105}
106
107impl TryFrom<&str> for ActorId {
108    type Error = ActorIdValidationError;
109
110    fn try_from(value: &str) -> Result<Self, Self::Error> {
111        Self::parse(value)
112    }
113}
114
115impl TryFrom<String> for ActorId {
116    type Error = ActorIdValidationError;
117
118    fn try_from(value: String) -> Result<Self, Self::Error> {
119        Self::parse(value)
120    }
121}
122
123impl std::str::FromStr for ActorId {
124    type Err = ActorIdValidationError;
125
126    fn from_str(value: &str) -> Result<Self, Self::Err> {
127        Self::parse(value)
128    }
129}
130
131impl AsRef<str> for ActorId {
132    fn as_ref(&self) -> &str {
133        self.as_str()
134    }
135}
136
137impl std::borrow::Borrow<str> for ActorId {
138    fn borrow(&self) -> &str {
139        self.as_str()
140    }
141}
142
143impl fmt::Display for ActorId {
144    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145        f.write_str(&self.0)
146    }
147}
148
149#[cfg(feature = "openapi")]
150impl utoipa::PartialSchema for ActorId {
151    #[allow(
152        deprecated,
153        reason = "the published schema uses the requested singular example field"
154    )]
155    fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
156        utoipa::openapi::schema::Object::builder()
157            .schema_type(utoipa::openapi::schema::Type::String)
158            .description(Some(
159                "Opaque hosting-platform actor id: non-empty, at most 256 UTF-8 bytes, without leading or trailing whitespace or control characters.",
160            ))
161            .example(Some(serde_json::json!("usr_8f3c")))
162            .into()
163    }
164}
165
166#[cfg(feature = "openapi")]
167impl utoipa::ToSchema for ActorId {}
168
169impl Serialize for ActorId {
170    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
171    where
172        S: serde::Serializer,
173    {
174        serializer.serialize_str(&self.0)
175    }
176}
177
178impl<'de> Deserialize<'de> for ActorId {
179    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
180    where
181        D: serde::Deserializer<'de>,
182    {
183        let value = String::deserialize(deserializer)?;
184        Self::parse(value).map_err(serde::de::Error::custom)
185    }
186}
187
188/// An error returned when an actor ID is invalid.
189#[derive(Debug, Clone, PartialEq, Eq, Error)]
190#[error("invalid actor_id {value:?}: {reason}")]
191pub struct ActorIdValidationError {
192    value: String,
193    reason: String,
194}
195
196impl ActorIdValidationError {
197    /// Returns the rejected input.
198    pub fn value(&self) -> &str {
199        &self.value
200    }
201
202    /// Returns the reason the input was rejected.
203    pub fn reason(&self) -> &str {
204        &self.reason
205    }
206}
207
208fn validate_actor_id(value: &str) -> Result<(), ActorIdValidationError> {
209    if value.is_empty() {
210        return Err(actor_id_error(value, "must not be empty"));
211    }
212    if value.len() > MAX_ACTOR_ID_BYTES {
213        return Err(actor_id_error(value, "must be 256 bytes or fewer"));
214    }
215    if value.trim() != value {
216        return Err(actor_id_error(
217            value,
218            "must not have leading or trailing whitespace",
219        ));
220    }
221    if value.chars().any(char::is_control) {
222        return Err(actor_id_error(value, "must not contain control characters"));
223    }
224    Ok(())
225}
226
227fn actor_id_error(value: &str, reason: &str) -> ActorIdValidationError {
228    ActorIdValidationError {
229        value: value.to_owned(),
230        reason: reason.to_owned(),
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::{ActorId, ActorKind, ActorRef};
237
238    #[test]
239    fn actor_kind_serializes_as_snake_case_strings() {
240        for (kind, json) in [
241            (ActorKind::User, r#""user""#),
242            (ActorKind::Service, r#""service""#),
243            (ActorKind::System, r#""system""#),
244        ] {
245            assert_eq!(serde_json::to_string(&kind).expect("serialize kind"), json);
246            assert_eq!(
247                serde_json::from_str::<ActorKind>(json).expect("deserialize kind"),
248                kind
249            );
250        }
251    }
252
253    #[test]
254    fn actor_ref_has_the_exact_wire_shape() {
255        let json = r#"{"kind":"user","id":"usr_8f3c"}"#;
256        let actor = ActorRef::user(ActorId::parse("usr_8f3c").expect("valid actor id"));
257
258        assert_eq!(
259            serde_json::to_string(&actor).expect("serialize actor"),
260            json
261        );
262        assert_eq!(
263            serde_json::from_str::<ActorRef>(json).expect("deserialize actor"),
264            actor
265        );
266    }
267
268    #[test]
269    fn actor_id_rejects_invalid_values_with_stable_reasons() {
270        let too_long = "x".repeat(257);
271        for (value, reason) in [
272            ("", "must not be empty"),
273            (&too_long, "must be 256 bytes or fewer"),
274            (" actor", "must not have leading or trailing whitespace"),
275            ("actor ", "must not have leading or trailing whitespace"),
276            ("actor\nid", "must not contain control characters"),
277            ("actor\0id", "must not contain control characters"),
278            ("actor\u{7f}id", "must not contain control characters"),
279        ] {
280            let error = ActorId::parse(value).expect_err("invalid actor id");
281            assert_eq!(error.value(), value);
282            assert_eq!(error.reason(), reason);
283        }
284    }
285
286    #[test]
287    fn actor_id_error_escapes_hostile_input() {
288        let error = ActorId::parse("actor\nid").expect_err("control character");
289
290        assert_eq!(
291            error.to_string(),
292            r#"invalid actor_id "actor\nid": must not contain control characters"#
293        );
294    }
295
296    #[test]
297    fn actor_id_accepts_external_syntax_and_round_trips() {
298        let exactly_256_bytes = "x".repeat(256);
299        for value in [
300            "auth0|64abc",
301            "AAD:uPn@Example",
302            "123e4567-e89b-12d3-a456-426614174000",
303            &exactly_256_bytes,
304        ] {
305            let parsed = ActorId::parse(value).expect("valid external actor id");
306            assert_eq!(parsed.as_str(), value);
307            assert_eq!(parsed.to_string(), value);
308            assert_eq!(ActorId::try_from(value).expect("try_from actor id"), parsed);
309            assert_eq!(value.parse::<ActorId>().expect("from_str actor id"), parsed);
310
311            let json = serde_json::to_string(&parsed).expect("serialize actor id");
312            assert_eq!(
313                serde_json::from_str::<ActorId>(&json).expect("deserialize actor id"),
314                parsed
315            );
316        }
317    }
318
319    #[test]
320    fn actor_id_utf8_limit_counts_bytes_not_characters() {
321        let exactly_256_bytes = "é".repeat(128);
322        let too_long = format!("{exactly_256_bytes}a");
323
324        ActorId::parse(&exactly_256_bytes).expect("256-byte unicode actor id");
325        assert_eq!(
326            ActorId::parse(&too_long)
327                .expect_err("257-byte unicode actor id")
328                .reason(),
329            "must be 256 bytes or fewer"
330        );
331    }
332
333    #[test]
334    fn actor_ref_rejects_unknown_kind_and_fields() {
335        assert!(serde_json::from_str::<ActorRef>(r#"{"kind":"robot","id":"x"}"#).is_err());
336        assert!(
337            serde_json::from_str::<ActorRef>(r#"{"kind":"user","id":"x","name":"Ada"}"#).is_err()
338        );
339    }
340
341    #[test]
342    fn actor_ref_convenience_constructors_select_the_kind() {
343        let id = ActorId::parse("actor").expect("valid actor id");
344
345        assert_eq!(ActorRef::user(id.clone()).kind, ActorKind::User);
346        assert_eq!(ActorRef::service(id.clone()).kind, ActorKind::Service);
347        assert_eq!(ActorRef::system(id).kind, ActorKind::System);
348        assert_eq!(
349            ActorRef::loonfs_system(),
350            ActorRef::system(ActorId::parse("loonfs").expect("valid bootstrap actor id"))
351        );
352    }
353
354    #[cfg(feature = "openapi")]
355    #[test]
356    fn actor_vocabulary_registers_openapi_schemas() {
357        #[derive(utoipa::OpenApi)]
358        #[openapi(components(schemas(ActorRef, ActorKind, ActorId)))]
359        struct ActorVocabularyOpenApi;
360
361        let document = serde_json::to_value(<ActorVocabularyOpenApi as utoipa::OpenApi>::openapi())
362            .expect("serialize actor vocabulary OpenAPI document");
363        let schemas = document
364            .pointer("/components/schemas")
365            .and_then(serde_json::Value::as_object)
366            .expect("actor vocabulary schemas");
367
368        for name in ["ActorRef", "ActorKind", "ActorId"] {
369            assert!(schemas.contains_key(name), "missing `{name}` schema");
370        }
371    }
372}