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 crate::ids::{string_id, validation_error};
7use serde::{Deserialize, Serialize};
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// This type also appears in request bodies, so it rejects unknown fields in
17// every context. Add new actor kinds instead of new fields. This is not
18// rustdoc because it describes storage behavior, not the public API.
19#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
20#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
21#[serde(deny_unknown_fields)]
22pub struct ActorRef {
23    /// The type of actor.
24    pub kind: ActorKind,
25    /// A stable identifier supplied by the application.
26    pub id: ActorId,
27}
28
29impl ActorRef {
30    /// Creates a user actor.
31    pub fn user(id: ActorId) -> Self {
32        Self {
33            kind: ActorKind::User,
34            id,
35        }
36    }
37
38    /// Creates a service actor.
39    pub fn service(id: ActorId) -> Self {
40        Self {
41            kind: ActorKind::Service,
42            id,
43        }
44    }
45
46    /// Creates a system actor.
47    pub fn system(id: ActorId) -> Self {
48        Self {
49            kind: ActorKind::System,
50            id,
51        }
52    }
53
54    /// Returns the actor used when LoonFS creates a namespace root.
55    pub fn loonfs_system() -> Self {
56        Self::system(ActorId::parse("loonfs").expect("`loonfs` should be a valid actor id"))
57    }
58}
59
60/// The type of actor responsible for a commit.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
62#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
63#[serde(rename_all = "snake_case")]
64pub enum ActorKind {
65    /// A user of the application.
66    User,
67    /// An application, integration, or background worker.
68    ///
69    /// Use [`ActorKind::User`] when a service acts on behalf of a known user.
70    Service,
71    /// System activity that changes filesystem data.
72    ///
73    /// Maintenance that does not create a commit has no actor.
74    System,
75}
76
77impl ActorKind {
78    /// Returns the value used in serialized actor references.
79    pub fn as_str(self) -> &'static str {
80        match self {
81            Self::User => "user",
82            Self::Service => "service",
83            Self::System => "system",
84        }
85    }
86}
87
88validation_error!(
89    ActorIdValidationError,
90    "invalid actor_id {value:?}: {reason}"
91);
92
93string_id! {
94    /// A validated actor identifier supplied by the application.
95    ///
96    /// Actor IDs may use the syntax of the application's identity system. They
97    /// must contain between 1 and 256 UTF-8 bytes, must not begin or end with
98    /// whitespace, and must not contain control characters.
99    ActorId,
100    error = ActorIdValidationError,
101    validate = validate_actor_id,
102    schema(
103        description = "Opaque hosting-platform actor id: non-empty, at most 256 UTF-8 bytes, without leading or trailing whitespace or control characters.",
104        example = "usr_8f3c"
105    )
106}
107
108fn validate_actor_id(value: &str) -> Result<(), ActorIdValidationError> {
109    if value.is_empty() {
110        return Err(actor_id_error(value, "must not be empty"));
111    }
112    if value.len() > MAX_ACTOR_ID_BYTES {
113        return Err(actor_id_error(
114            value,
115            &format!("must be {MAX_ACTOR_ID_BYTES} bytes or fewer"),
116        ));
117    }
118    if value.trim() != value {
119        return Err(actor_id_error(
120            value,
121            "must not have leading or trailing whitespace",
122        ));
123    }
124    if value.chars().any(char::is_control) {
125        return Err(actor_id_error(value, "must not contain control characters"));
126    }
127    Ok(())
128}
129
130fn actor_id_error(value: &str, reason: &str) -> ActorIdValidationError {
131    ActorIdValidationError {
132        value: value.to_owned(),
133        reason: reason.to_owned(),
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::{ActorId, ActorKind, ActorRef};
140
141    #[test]
142    fn actor_kind_serializes_as_snake_case_strings() {
143        for (kind, json) in [
144            (ActorKind::User, r#""user""#),
145            (ActorKind::Service, r#""service""#),
146            (ActorKind::System, r#""system""#),
147        ] {
148            assert_eq!(serde_json::to_string(&kind).expect("serialize kind"), json);
149            assert_eq!(
150                serde_json::from_str::<ActorKind>(json).expect("deserialize kind"),
151                kind
152            );
153        }
154    }
155
156    #[test]
157    fn actor_ref_has_the_exact_wire_shape() {
158        let json = r#"{"kind":"user","id":"usr_8f3c"}"#;
159        let actor = ActorRef::user(ActorId::parse("usr_8f3c").expect("valid actor id"));
160
161        assert_eq!(
162            serde_json::to_string(&actor).expect("serialize actor"),
163            json
164        );
165        assert_eq!(
166            serde_json::from_str::<ActorRef>(json).expect("deserialize actor"),
167            actor
168        );
169    }
170
171    #[test]
172    fn actor_id_rejects_invalid_values_with_stable_reasons() {
173        let too_long = "x".repeat(257);
174        for (value, reason) in [
175            ("", "must not be empty"),
176            (&too_long, "must be 256 bytes or fewer"),
177            (" actor", "must not have leading or trailing whitespace"),
178            ("actor ", "must not have leading or trailing whitespace"),
179            ("actor\nid", "must not contain control characters"),
180            ("actor\0id", "must not contain control characters"),
181            ("actor\u{7f}id", "must not contain control characters"),
182        ] {
183            let error = ActorId::parse(value).expect_err("invalid actor id");
184            assert_eq!(error.value(), value);
185            assert_eq!(error.reason(), reason);
186        }
187    }
188
189    #[test]
190    fn actor_id_error_escapes_hostile_input() {
191        let error = ActorId::parse("actor\nid").expect_err("control character");
192
193        assert_eq!(
194            error.to_string(),
195            r#"invalid actor_id "actor\nid": must not contain control characters"#
196        );
197    }
198
199    #[test]
200    fn actor_id_accepts_external_syntax_and_round_trips() {
201        let exactly_256_bytes = "x".repeat(256);
202        for value in [
203            "auth0|64abc",
204            "AAD:uPn@Example",
205            "123e4567-e89b-12d3-a456-426614174000",
206            &exactly_256_bytes,
207        ] {
208            let parsed = ActorId::parse(value).expect("valid external actor id");
209            assert_eq!(parsed.as_str(), value);
210            assert_eq!(parsed.to_string(), value);
211            assert_eq!(ActorId::try_from(value).expect("try_from actor id"), parsed);
212            assert_eq!(value.parse::<ActorId>().expect("from_str actor id"), parsed);
213
214            let json = serde_json::to_string(&parsed).expect("serialize actor id");
215            assert_eq!(
216                serde_json::from_str::<ActorId>(&json).expect("deserialize actor id"),
217                parsed
218            );
219        }
220    }
221
222    #[test]
223    fn actor_id_utf8_limit_counts_bytes_not_characters() {
224        let exactly_256_bytes = "é".repeat(128);
225        let too_long = format!("{exactly_256_bytes}a");
226
227        ActorId::parse(&exactly_256_bytes).expect("256-byte unicode actor id");
228        assert_eq!(
229            ActorId::parse(&too_long)
230                .expect_err("257-byte unicode actor id")
231                .reason(),
232            "must be 256 bytes or fewer"
233        );
234    }
235
236    #[test]
237    fn actor_ref_rejects_unknown_kind_and_fields() {
238        assert!(serde_json::from_str::<ActorRef>(r#"{"kind":"robot","id":"x"}"#).is_err());
239        assert!(
240            serde_json::from_str::<ActorRef>(r#"{"kind":"user","id":"x","name":"Ada"}"#).is_err()
241        );
242    }
243}