Skip to main content

loonfs_api/v0/
downloads.rs

1//! Download-grant shapes for the v0 HTTP API: what a client asks for when
2//! it wants a file's bytes straight from object storage, and the short-lived
3//! read capability it gets back.
4//!
5//! This is the read half of the direct transfer plane. The write half lives
6//! in [`super::uploads`], and the two share one access envelope
7//! ([`ObjectTransferAccess`]) because a client handles both the same way:
8//! send this method to this URL with these headers, before this instant.
9
10use super::ObjectTransferAccess;
11use crate::{AbsolutePath, ContentRef, InodeId, NamespaceId, RevisionNo};
12use serde::{Deserialize, Serialize};
13
14/// What a client names when it asks to read a file directly.
15///
16/// A path and, optionally, the revision it wants — the same two things the
17/// proxied content read takes, so a caller switching transports changes
18/// nothing about what it is asking for.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
21#[serde(deny_unknown_fields)]
22pub struct BeginDownloadRequest {
23    /// Absolute path of the file to read.
24    pub path: AbsolutePath,
25    /// Revision to read, or `None` for the path's current revision.
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub revision_no: Option<RevisionNo>,
28}
29
30impl BeginDownloadRequest {
31    /// Asks for the path's current revision.
32    pub fn for_path(path: AbsolutePath) -> Self {
33        Self {
34            path,
35            revision_no: None,
36        }
37    }
38
39    /// Asks for one prior revision of the path.
40    pub fn for_revision(path: AbsolutePath, revision_no: RevisionNo) -> Self {
41        Self {
42            path,
43            revision_no: Some(revision_no),
44        }
45    }
46}
47
48/// A short-lived capability to read one file's content object, plus
49/// everything the reader needs to check what arrives.
50///
51/// The raw object key is deliberately not here, exactly as it is not in a
52/// `direct_put` grant: a client learns a URL that expires, not an address it
53/// can revisit.
54///
55/// The grant names one immutable content object, so it does not go stale
56/// when the path moves on. A commit that replaces the file writes a new
57/// object and leaves this one alone; what the capability reads is what the
58/// requested revision held when the grant was issued, and the reference
59/// says which bytes those are.
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
62pub struct BeginDownloadResponse {
63    /// Namespace that was read.
64    pub namespace_id: NamespaceId,
65    /// Absolute path as rendered from stored display names.
66    pub path: AbsolutePath,
67    /// Revision the capability reads, resolved from the request.
68    pub revision_no: RevisionNo,
69    /// Identity, byte length, and checksum evidence for the object the
70    /// capability reads. A reader checks the bytes it receives against
71    /// `size_bytes` and recomputes `checksum.algorithm` over the complete
72    /// payload.
73    pub content_ref: ContentRef,
74    /// Short-lived read capability the client uses without learning the raw object key.
75    pub access: ObjectTransferAccess,
76}
77
78/// Empty request for an inode-addressed download.
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
80#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
81#[serde(deny_unknown_fields)]
82pub struct BeginDownloadByInodeRequest {}
83
84/// A short-lived capability to read one inode revision.
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
87pub struct BeginDownloadByInodeResponse {
88    /// Namespace that was read.
89    pub namespace_id: NamespaceId,
90    /// File inode being read.
91    #[serde(with = "crate::public_inode_id")]
92    #[cfg_attr(
93        feature = "openapi",
94        schema(schema_with = crate::public_inode_id::schema)
95    )]
96    pub inode_id: InodeId,
97    /// Revision being read.
98    pub revision_no: RevisionNo,
99    /// Content identity, size, and checksum.
100    pub content_ref: ContentRef,
101    /// Short-lived provider access without the raw object key.
102    pub access: ObjectTransferAccess,
103}
104
105#[cfg(test)]
106mod tests {
107    use super::{
108        BeginDownloadByInodeRequest, BeginDownloadByInodeResponse, BeginDownloadRequest,
109        BeginDownloadResponse,
110    };
111    use crate::v0::ObjectTransferAccess;
112    use crate::{AbsolutePath, ContentId, ContentRef, NamespaceId, RevisionNo};
113    use std::collections::BTreeMap;
114
115    fn absolute_path() -> AbsolutePath {
116        AbsolutePath::parse("/docs/report.txt").expect("absolute path")
117    }
118
119    /// A download request names a path, never an object. Identity belongs
120    /// to the server on the way out exactly as it does on the way in.
121    #[test]
122    fn a_download_request_names_only_a_path_and_a_revision() {
123        let request: BeginDownloadRequest =
124            serde_json::from_str(r#"{"path":"/docs/report.txt"}"#).expect("decode request");
125        assert_eq!(request.path, absolute_path());
126        assert_eq!(request.revision_no, None);
127
128        let pinned: BeginDownloadRequest =
129            serde_json::from_str(r#"{"path":"/docs/report.txt","revision_no":3}"#)
130                .expect("decode pinned request");
131        assert_eq!(pinned.revision_no, Some(RevisionNo(3)));
132
133        assert!(
134            serde_json::from_str::<BeginDownloadRequest>(
135                r#"{"path":"/docs/report.txt","content_id":"con_0123456789abcdef0123456789abcdef"}"#
136            )
137            .is_err(),
138            "a client must not be able to name the content object"
139        );
140    }
141
142    #[test]
143    fn a_download_grant_exposes_only_presigned_access() {
144        let response = BeginDownloadResponse {
145            namespace_id: NamespaceId::parse("demo").expect("namespace id"),
146            path: absolute_path(),
147            revision_no: RevisionNo(7),
148            content_ref: ContentRef::blob_v1(ContentId::generate(), b"hello"),
149            access: ObjectTransferAccess::PresignedUrl {
150                method: "GET".to_owned(),
151                url: "https://bucket.example/object?X-Amz-Signature=abc".to_owned(),
152                headers: BTreeMap::new(),
153                expires_at_ms: 1,
154            },
155        };
156
157        let json = serde_json::to_string(&response).expect("serialize response");
158        assert!(json.contains(r#""kind":"presigned_url""#));
159        assert!(!json.contains("object_key"));
160    }
161
162    #[test]
163    fn an_inode_download_request_is_strictly_empty_and_its_grant_is_path_free() {
164        let request: BeginDownloadByInodeRequest =
165            serde_json::from_str("{}").expect("decode empty request");
166        assert_eq!(request, BeginDownloadByInodeRequest {});
167        assert!(serde_json::from_str::<BeginDownloadByInodeRequest>(r#"{"path":"/old"}"#).is_err());
168
169        let response = BeginDownloadByInodeResponse {
170            namespace_id: NamespaceId::parse("demo").expect("namespace id"),
171            inode_id: crate::InodeId(42),
172            revision_no: RevisionNo(7),
173            content_ref: ContentRef::blob_v1(ContentId::generate(), b"hello"),
174            access: ObjectTransferAccess::PresignedUrl {
175                method: "GET".to_owned(),
176                url: "https://bucket.example/object?X-Amz-Signature=abc".to_owned(),
177                headers: BTreeMap::new(),
178                expires_at_ms: 1,
179            },
180        };
181        let json = serde_json::to_value(response).expect("serialize response");
182        assert_eq!(json["inode_id"], "ino_42");
183        assert!(json.get("path").is_none());
184    }
185}