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