Skip to main content

loonfs_api/v0/
reads.rs

1//! Authoritative read-result shapes for the v0 HTTP API: the stat/list
2//! entry, the directory-listing envelope, and the file-bytes read result.
3//! The mutating operation shapes live in [`super::operations`].
4
5use crate::{
6    AbsolutePath, ActorRef, AttributeRevisionNo, Attributes, ChangeSeq, ContentRef, DisplayName,
7    InodeId, InodeKind, NameKey, NamespaceId, RevisionNo,
8};
9use serde::{Deserialize, Serialize};
10
11/// Authoritative metadata for one visible path.
12///
13/// This is the result shape for stat/list style reads. The entry kind carries
14/// the file-only revision and content summary, so a directory cannot carry a
15/// partial file payload. Attributes are likewise projected as one value or
16/// omitted as one value, while serializing as prefixed sibling fields.
17/// The attribute revision is read independently — clients feed it to
18/// `expected_attributes_revision_no` on the next write without touching the
19/// values — so this is a prefixed-sibling projection rather than a value
20/// consumed as one nested unit.
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
23pub struct AuthoritativePathEntry {
24    /// Namespace that was read.
25    pub namespace_id: NamespaceId,
26    /// Absolute path as rendered from stored display names.
27    pub path: AbsolutePath,
28    /// Stable inode identity for this item.
29    #[serde(with = "crate::public_inode_id")]
30    #[cfg_attr(
31        feature = "openapi",
32        schema(schema_with = crate::public_inode_id::schema)
33    )]
34    pub inode_id: InodeId,
35    /// Actor that created this inode, as supplied by the application.
36    pub created_by: ActorRef,
37    /// Time the inode was created, in Unix milliseconds. Sequence numbers
38    /// determine order.
39    pub created_at_ms: u64,
40    /// File-or-directory classification and its kind-specific payload.
41    #[serde(flatten)]
42    pub kind: AuthoritativePathEntryKind,
43    /// Namespace head sequence this answer was read from.
44    pub head_seq: ChangeSeq,
45    /// Parent directory inode, or `None` for the root.
46    #[serde(
47        default,
48        skip_serializing_if = "Option::is_none",
49        with = "crate::public_inode_id::option"
50    )]
51    #[cfg_attr(
52        feature = "openapi",
53        schema(schema_with = crate::public_inode_id::optional_schema)
54    )]
55    pub parent_inode_id: Option<InodeId>,
56    /// Stored display name for this path component, absent for the nameless root.
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub display_name: Option<DisplayName>,
59    /// The inode's attribute projection, when requested.
60    #[serde(flatten, default, skip_serializing_if = "Option::is_none")]
61    pub attributes: Option<AttributesProjection>,
62}
63
64impl AuthoritativePathEntry {
65    /// Returns whether this entry is a file or directory.
66    pub const fn inode_kind(&self) -> InodeKind {
67        self.kind.inode_kind()
68    }
69
70    /// Returns the current revision number for a file entry.
71    pub const fn revision_no(&self) -> Option<RevisionNo> {
72        match &self.kind {
73            AuthoritativePathEntryKind::Directory {} => None,
74            AuthoritativePathEntryKind::File { revision_no, .. } => Some(*revision_no),
75        }
76    }
77
78    /// Returns the current byte length for a file entry.
79    pub const fn size_bytes(&self) -> Option<u64> {
80        match &self.kind {
81            AuthoritativePathEntryKind::Directory {} => None,
82            AuthoritativePathEntryKind::File { size_bytes, .. } => Some(*size_bytes),
83        }
84    }
85
86    /// Returns the current content reference for a file entry.
87    pub const fn content_ref(&self) -> Option<&ContentRef> {
88        match &self.kind {
89            AuthoritativePathEntryKind::Directory {} => None,
90            AuthoritativePathEntryKind::File { content_ref, .. } => Some(content_ref),
91        }
92    }
93
94    /// Returns the current revision's commit stamp for a file entry.
95    pub const fn committed_at_ms(&self) -> Option<u64> {
96        match &self.kind {
97            AuthoritativePathEntryKind::Directory {} => None,
98            AuthoritativePathEntryKind::File {
99                committed_at_ms, ..
100            } => Some(*committed_at_ms),
101        }
102    }
103}
104
105/// Kind-specific metadata for an authoritative path entry.
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
108#[serde(tag = "inode_kind", rename_all = "snake_case")]
109pub enum AuthoritativePathEntryKind {
110    /// A directory, which has no revision payload in v0.
111    ///
112    /// The entry tag reuses [`InodeKind`]'s wire vocabulary.
113    #[serde(rename = "dir")]
114    #[cfg_attr(feature = "openapi", schema(title = "AuthoritativePathEntryDirectory"))]
115    Directory {},
116    /// A file and its current revision summary.
117    #[cfg_attr(feature = "openapi", schema(title = "AuthoritativePathEntryFile"))]
118    File {
119        /// Current file revision number.
120        revision_no: RevisionNo,
121        /// Current file size in bytes.
122        ///
123        /// This remains explicit even though `content_ref` also carries the
124        /// length because callers sort directory listings by this field.
125        size_bytes: u64,
126        /// Current content reference.
127        content_ref: ContentRef,
128        /// Actor responsible for the current revision.
129        revision_actor: ActorRef,
130        /// Time of the current revision, in Unix milliseconds. Revision
131        /// sequences determine order.
132        committed_at_ms: u64,
133    },
134}
135
136impl AuthoritativePathEntryKind {
137    /// Returns the stable inode classification represented by this payload.
138    pub const fn inode_kind(&self) -> InodeKind {
139        match self {
140            Self::Directory {} => InodeKind::Directory,
141            Self::File { .. } => InodeKind::File,
142        }
143    }
144
145    /// Returns the actor responsible for the current file revision.
146    /// Directories return `None`.
147    pub const fn revision_actor(&self) -> Option<&ActorRef> {
148        match self {
149            Self::Directory {} => None,
150            Self::File { revision_actor, .. } => Some(revision_actor),
151        }
152    }
153}
154
155/// One inode's structurally complete attribute projection.
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
157#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
158pub struct AttributesProjection {
159    /// The attribute revision this projection represents.
160    pub attributes_revision_no: AttributeRevisionNo,
161    /// Actor responsible for the latest attribute update. This is `None` for
162    /// the initial empty state at revision 0.
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub attributes_updated_by: Option<ActorRef>,
165    /// Time of the latest attribute update, in Unix milliseconds. This is
166    /// `None` for the initial empty state at revision 0.
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub attributes_updated_at_ms: Option<u64>,
169    /// The complete attribute map at `attributes_revision_no`.
170    ///
171    /// An inode that has never had attributes written is at revision 0 with
172    /// an empty map.
173    pub attributes: Attributes,
174}
175
176/// One directory listing and the namespace head it was answered at.
177///
178/// The envelope names the listing target and head so an empty directory
179/// still tells the caller which state it observed, and so the response can
180/// grow without reshaping `entries`.
181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
182#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
183pub struct ListPathEntriesResponse {
184    /// Namespace that was read.
185    pub namespace_id: NamespaceId,
186    /// Absolute path of the listed directory.
187    pub path: AbsolutePath,
188    /// Namespace head sequence this listing was read from.
189    pub head_seq: ChangeSeq,
190    /// Directory entries for this page.
191    ///
192    /// Entries are returned in canonical name-key order. Higher-level display
193    /// surfaces may sort entries separately for presentation.
194    pub entries: Vec<AuthoritativePathEntry>,
195    /// Cursor for the next page, if more entries remain.
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub next_cursor: Option<String>,
198}
199
200/// File bytes plus the metadata entry they came from.
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
202#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
203pub struct AuthoritativeFileBytes {
204    /// Authoritative metadata for the file that was read.
205    pub entry: AuthoritativePathEntry,
206    /// Validated file bytes.
207    pub bytes: Vec<u8>,
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    fn entry(
215        path: &str,
216        parent_inode_id: Option<InodeId>,
217        display_name: Option<&str>,
218    ) -> AuthoritativePathEntry {
219        AuthoritativePathEntry {
220            namespace_id: NamespaceId::parse("demo").expect("namespace id"),
221            path: AbsolutePath::parse(path).expect("absolute path"),
222            inode_id: InodeId(if parent_inode_id.is_some() { 2 } else { 1 }),
223            created_by: ActorRef::loonfs_system(),
224            created_at_ms: 1_752_624_000_000,
225            kind: AuthoritativePathEntryKind::Directory {},
226            head_seq: ChangeSeq(3),
227            parent_inode_id,
228            display_name: display_name.map(|name| DisplayName::parse(name).expect("display name")),
229            attributes: None,
230        }
231    }
232
233    #[test]
234    fn authoritative_entry_paths_keep_the_plain_string_wire_shape() {
235        let named = entry("/docs", Some(InodeId(1)), Some("docs"));
236        assert_eq!(
237            serde_json::to_value(&named).expect("serialize named entry"),
238            serde_json::json!({
239                "namespace_id": "demo",
240                "path": "/docs",
241                "inode_id": "ino_2",
242                "created_by": { "kind": "system", "id": "loonfs" },
243                "created_at_ms": 1_752_624_000_000_u64,
244                "inode_kind": "dir",
245                "head_seq": 3,
246                "parent_inode_id": "ino_1",
247                "display_name": "docs"
248            })
249        );
250
251        let response = ListPathEntriesResponse {
252            namespace_id: NamespaceId::parse("demo").expect("namespace id"),
253            path: AbsolutePath::parse("/").expect("absolute path"),
254            head_seq: ChangeSeq(3),
255            entries: vec![named],
256            next_cursor: None,
257        };
258        assert_eq!(
259            serde_json::to_value(response).expect("serialize listing"),
260            serde_json::json!({
261                "namespace_id": "demo",
262                "path": "/",
263                "head_seq": 3,
264                "entries": [{
265                    "namespace_id": "demo",
266                    "path": "/docs",
267                    "inode_id": "ino_2",
268                    "created_by": { "kind": "system", "id": "loonfs" },
269                    "created_at_ms": 1_752_624_000_000_u64,
270                    "inode_kind": "dir",
271                    "head_seq": 3,
272                    "parent_inode_id": "ino_1",
273                    "display_name": "docs"
274                }]
275            })
276        );
277    }
278
279    #[test]
280    fn a_file_entry_serializes_its_required_payload_with_the_kind() {
281        let content_ref = ContentRef::blob_v1(crate::ContentId::generate(), b"hello");
282        let mut file = entry("/report.txt", Some(InodeId(1)), Some("report.txt"));
283        file.kind = AuthoritativePathEntryKind::File {
284            revision_no: RevisionNo(7),
285            size_bytes: 5,
286            content_ref: content_ref.clone(),
287            revision_actor: ActorRef::loonfs_system(),
288            committed_at_ms: 1_752_624_000_000,
289        };
290
291        assert_eq!(
292            serde_json::to_value(file).expect("serialize file entry"),
293            serde_json::json!({
294                "namespace_id": "demo",
295                "path": "/report.txt",
296                "inode_id": "ino_2",
297                "created_by": { "kind": "system", "id": "loonfs" },
298                "created_at_ms": 1_752_624_000_000_u64,
299                "inode_kind": "file",
300                "revision_no": 7,
301                "size_bytes": 5,
302                "content_ref": content_ref,
303                "revision_actor": { "kind": "system", "id": "loonfs" },
304                "committed_at_ms": 1_752_624_000_000_u64,
305                "head_seq": 3,
306                "parent_inode_id": "ino_1",
307                "display_name": "report.txt"
308            })
309        );
310    }
311
312    #[test]
313    fn nameless_root_omits_parent_inode_id_and_display_name() {
314        let root_json = serde_json::to_value(entry("/", None, None)).expect("serialize root");
315        assert!(root_json.get("parent_inode_id").is_none());
316        assert!(root_json.get("display_name").is_none());
317
318        let decoded: AuthoritativePathEntry =
319            serde_json::from_value(root_json).expect("decode root without optional fields");
320        assert_eq!(decoded.parent_inode_id, None);
321        assert_eq!(decoded.display_name, None);
322
323        let named_json = serde_json::to_value(entry("/docs", Some(InodeId(1)), Some("docs")))
324            .expect("serialize named entry");
325        assert_eq!(named_json["parent_inode_id"], "ino_1");
326        assert_eq!(named_json["display_name"], "docs");
327    }
328
329    #[test]
330    fn authoritative_entry_kinds_share_inode_kind_wire_values() {
331        let directory = AuthoritativePathEntryKind::Directory {};
332        assert_eq!(
333            serde_json::to_value(directory).expect("serialize directory entry kind")["inode_kind"],
334            serde_json::to_value(InodeKind::Directory).expect("serialize directory inode kind")
335        );
336
337        let content_ref = ContentRef::blob_v1(crate::ContentId::generate(), b"hello");
338        let file = AuthoritativePathEntryKind::File {
339            revision_no: RevisionNo(1),
340            size_bytes: 5,
341            content_ref,
342            revision_actor: ActorRef::loonfs_system(),
343            committed_at_ms: 1,
344        };
345        assert_eq!(
346            serde_json::to_value(file).expect("serialize file entry kind")["inode_kind"],
347            serde_json::to_value(InodeKind::File).expect("serialize file inode kind")
348        );
349    }
350
351    #[test]
352    fn requested_attributes_serialize_as_flat_prefixed_siblings() {
353        let mut projected = entry("/docs", Some(InodeId(1)), Some("docs"));
354        projected.attributes = Some(AttributesProjection {
355            attributes_revision_no: crate::AttributeRevisionNo(7),
356            attributes_updated_by: Some(ActorRef::loonfs_system()),
357            attributes_updated_at_ms: Some(1_752_624_000_000),
358            attributes: crate::Attributes::new(std::collections::BTreeMap::from([(
359                crate::AttributeKey::parse("owner").expect("attribute key"),
360                crate::AttributeValue::parse("finance").expect("attribute value"),
361            )]))
362            .expect("attributes"),
363        });
364
365        let projected_json = serde_json::to_value(&projected).expect("serialize projected entry");
366        assert_eq!(projected_json["attributes_revision_no"], 7);
367        assert_eq!(
368            projected_json["attributes"],
369            serde_json::json!({ "owner": "finance" })
370        );
371        assert_eq!(
372            projected_json["attributes_updated_by"],
373            serde_json::json!({ "kind": "system", "id": "loonfs" })
374        );
375        assert_eq!(
376            projected_json["attributes_updated_at_ms"],
377            1_752_624_000_000_u64
378        );
379
380        let decoded: AuthoritativePathEntry =
381            serde_json::from_value(projected_json).expect("decode projected entry");
382        let projection = decoded.attributes.expect("projected attributes");
383        assert_eq!(
384            projection.attributes_revision_no,
385            crate::AttributeRevisionNo(7)
386        );
387    }
388
389    #[test]
390    fn unrequested_attributes_omit_both_wire_keys() {
391        let unprojected = entry("/docs", Some(InodeId(1)), Some("docs"));
392        let unprojected_json =
393            serde_json::to_value(&unprojected).expect("serialize unprojected entry");
394        assert!(unprojected_json.get("attributes").is_none());
395        assert!(unprojected_json.get("attributes_revision_no").is_none());
396
397        let decoded: AuthoritativePathEntry =
398            serde_json::from_value(unprojected_json).expect("decode unprojected entry");
399        assert!(decoded.attributes.is_none());
400    }
401
402    #[test]
403    fn never_written_attributes_serialize_as_revision_zero_and_empty_map() {
404        let mut projected = entry("/docs", Some(InodeId(1)), Some("docs"));
405        projected.attributes = Some(AttributesProjection {
406            attributes_revision_no: crate::AttributeRevisionNo(0),
407            attributes_updated_by: None,
408            attributes_updated_at_ms: None,
409            attributes: crate::Attributes::default(),
410        });
411        let projected_json = serde_json::to_value(&projected).expect("serialize projected entry");
412        assert_eq!(projected_json["attributes_revision_no"], 0);
413        assert_eq!(projected_json["attributes"], serde_json::json!({}));
414        assert!(projected_json.get("attributes_updated_by").is_none());
415        assert!(projected_json.get("attributes_updated_at_ms").is_none());
416    }
417
418    #[test]
419    fn serialized_entries_never_nest_attributes_inside_attributes() {
420        let mut projected = entry("/docs", Some(InodeId(1)), Some("docs"));
421        projected.attributes = Some(AttributesProjection {
422            attributes_revision_no: crate::AttributeRevisionNo(1),
423            attributes_updated_by: None,
424            attributes_updated_at_ms: None,
425            attributes: crate::Attributes::new(std::collections::BTreeMap::from([(
426                crate::AttributeKey::parse("owner").expect("attribute key"),
427                crate::AttributeValue::parse("finance").expect("attribute value"),
428            )]))
429            .expect("attributes"),
430        });
431
432        let projected_json = serde_json::to_value(projected).expect("serialize projected entry");
433        assert!(projected_json.pointer("/attributes/attributes").is_none());
434    }
435
436    #[test]
437    fn trash_handle_copies_directly_into_an_undelete_operation() {
438        let trash = TrashEntry {
439            inode_id: InodeId(42),
440            deletion_seq: ChangeSeq(417),
441            deleted_at_ms: 1_752_625_000_000,
442            deleted_by: ActorRef::loonfs_system(),
443            parent_inode_id: Some(InodeId(7)),
444            name_key: Some(NameKey::parse("report.txt").expect("name key")),
445            display_name: Some(DisplayName::parse("Report.txt").expect("display name")),
446        };
447        let trash_json = serde_json::to_value(trash).expect("serialize trash entry");
448        assert_eq!(trash_json["inode_id"], serde_json::json!("ino_42"));
449        assert_eq!(trash_json["deletion_seq"], serde_json::json!(417));
450        assert!(trash_json.get("root_inode_id").is_none());
451        assert!(trash_json.get("deleted_at_seq").is_none());
452
453        let operation_json = serde_json::json!({
454            "kind": "undelete",
455            "inode_id": trash_json["inode_id"].clone(),
456            "deletion_seq": trash_json["deletion_seq"].clone()
457        });
458        let operation: crate::v0::FilesystemOperation =
459            serde_json::from_value(operation_json).expect("decode copied trash handle");
460        assert!(matches!(
461            operation,
462            crate::v0::FilesystemOperation::Undelete {
463                inode_id: InodeId(42),
464                deletion_seq: ChangeSeq(417),
465                path: None,
466            }
467        ));
468
469        assert!(
470            serde_json::from_value::<crate::v0::FilesystemOperation>(serde_json::json!({
471                "kind": "undelete",
472                "inode_id": 42,
473                "deleted_at_seq": 417
474            }))
475            .is_err(),
476            "the retired deletion handle must not decode"
477        );
478    }
479}
480
481/// One deletion that can still be restored.
482///
483/// `inode_id` and `deletion_seq` are sufficient to restore it. The original
484/// parent and name are included when they were recorded.
485#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
486#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
487pub struct TrashEntry {
488    /// Inode hidden by the deletion.
489    #[serde(with = "crate::public_inode_id")]
490    #[cfg_attr(
491        feature = "openapi",
492        schema(schema_with = crate::public_inode_id::schema)
493    )]
494    pub inode_id: InodeId,
495    /// Commit sequence that identifies this deletion.
496    pub deletion_seq: ChangeSeq,
497    /// Time of the deletion, in Unix milliseconds.
498    pub deleted_at_ms: u64,
499    /// Actor responsible for the deletion.
500    pub deleted_by: ActorRef,
501    /// Directory that held the deleted binding, when recorded.
502    #[serde(
503        default,
504        skip_serializing_if = "Option::is_none",
505        with = "crate::public_inode_id::option"
506    )]
507    #[cfg_attr(
508        feature = "openapi",
509        schema(schema_with = crate::public_inode_id::optional_schema)
510    )]
511    pub parent_inode_id: Option<InodeId>,
512    /// Canonical key of the deleted binding, when recorded.
513    #[serde(default, skip_serializing_if = "Option::is_none")]
514    pub name_key: Option<NameKey>,
515    /// User-facing spelling of the deleted binding, when recorded.
516    #[serde(default, skip_serializing_if = "Option::is_none")]
517    pub display_name: Option<DisplayName>,
518}
519
520/// One trash listing page: the namespace's recoverable deletions.
521#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
522#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
523pub struct ListTrashResponse {
524    /// Namespace that was read.
525    pub namespace_id: NamespaceId,
526    /// Head sequence this page was evaluated at.
527    pub head_seq: ChangeSeq,
528    /// Recoverable deletions, oldest deletion first.
529    pub entries: Vec<TrashEntry>,
530    /// Present when another page follows.
531    #[serde(default, skip_serializing_if = "Option::is_none")]
532    pub next_cursor: Option<String>,
533}