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    #[cfg_attr(feature = "openapi", schema(nullable = false))]
28    pub revision_no: Option<RevisionNo>,
29}
30
31impl BeginDownloadRequest {
32    /// Asks for the path's current revision.
33    pub fn for_path(path: AbsolutePath) -> Self {
34        Self {
35            path,
36            revision_no: None,
37        }
38    }
39
40    /// Asks for one prior revision of the path.
41    pub fn for_revision(path: AbsolutePath, revision_no: RevisionNo) -> Self {
42        Self {
43            path,
44            revision_no: Some(revision_no),
45        }
46    }
47}
48
49/// A short-lived capability to read one file's content object, plus
50/// everything the reader needs to check what arrives.
51///
52/// The raw object key is deliberately not here, exactly as it is not in a
53/// `direct_put` grant: a client learns a URL that expires, not an address it
54/// can revisit.
55///
56/// The grant names one immutable content object, so it does not go stale
57/// when the path moves on. A commit that replaces the file writes a new
58/// object and leaves this one alone; what the capability reads is what the
59/// requested revision held when the grant was issued, and the reference
60/// says which bytes those are.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
63pub struct BeginDownloadResponse {
64    /// Namespace that was read.
65    pub namespace_id: NamespaceId,
66    /// Absolute path as rendered from stored display names.
67    pub path: AbsolutePath,
68    /// Revision the capability reads, resolved from the request.
69    pub revision_no: RevisionNo,
70    /// Identity, byte length, and checksum evidence for the object the
71    /// capability reads. A reader checks the bytes it receives against
72    /// `size_bytes` and recomputes `checksum.algorithm` over the complete
73    /// payload.
74    pub content_ref: ContentRef,
75    /// Short-lived read capability the client uses without learning the raw object key.
76    pub access: ObjectTransferAccess,
77}
78
79/// Empty request for an inode-addressed download.
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
81#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
82#[serde(deny_unknown_fields)]
83pub struct BeginDownloadByInodeRequest {}
84
85/// A short-lived capability to read one inode revision.
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
88pub struct BeginDownloadByInodeResponse {
89    /// Namespace that was read.
90    pub namespace_id: NamespaceId,
91    /// File inode being read.
92    #[serde(with = "crate::public_inode_id")]
93    #[cfg_attr(
94        feature = "openapi",
95        schema(schema_with = crate::public_inode_id::schema)
96    )]
97    pub inode_id: InodeId,
98    /// Revision being read.
99    pub revision_no: RevisionNo,
100    /// Content identity, size, and checksum.
101    pub content_ref: ContentRef,
102    /// Short-lived provider access without the raw object key.
103    pub access: ObjectTransferAccess,
104}
105
106#[cfg(test)]
107mod tests {
108    use super::{
109        BeginDownloadByInodeRequest, BeginDownloadByInodeResponse, BeginDownloadRequest,
110        BeginDownloadResponse,
111    };
112    use crate::v0::ObjectTransferAccess;
113    use crate::{AbsolutePath, ContentId, ContentRef, NamespaceId, RevisionNo};
114    use std::collections::BTreeMap;
115
116    fn absolute_path() -> AbsolutePath {
117        AbsolutePath::parse("/docs/report.txt").expect("absolute path")
118    }
119
120    fn content_ref() -> ContentRef {
121        ContentRef::blob_v1(
122            ContentId::parse("con_0123456789abcdef0123456789abcdef").expect("content id"),
123            b"hello",
124        )
125    }
126
127    fn content_ref_json() -> serde_json::Value {
128        serde_json::json!({
129            "kind": "blob_v1",
130            "content_id": "con_0123456789abcdef0123456789abcdef",
131            "size_bytes": 5,
132            "checksum": {
133                "algorithm": "sha256",
134                "value": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
135            }
136        })
137    }
138
139    #[test]
140    fn a_download_request_names_only_a_path_and_a_revision() {
141        let request: BeginDownloadRequest =
142            serde_json::from_str(r#"{"path":"/docs/report.txt"}"#).expect("decode request");
143        assert_eq!(request.path, absolute_path());
144        assert_eq!(request.revision_no, None);
145
146        let pinned: BeginDownloadRequest =
147            serde_json::from_str(r#"{"path":"/docs/report.txt","revision_no":3}"#)
148                .expect("decode pinned request");
149        assert_eq!(pinned.revision_no, Some(RevisionNo(3)));
150
151        assert!(
152            serde_json::from_str::<BeginDownloadRequest>(
153                r#"{"path":"/docs/report.txt","content_id":"con_0123456789abcdef0123456789abcdef"}"#
154            )
155            .is_err(),
156            "a client must not be able to name the content object"
157        );
158    }
159
160    #[test]
161    fn a_download_grant_exposes_only_presigned_access() {
162        let response = BeginDownloadResponse {
163            namespace_id: NamespaceId::parse("demo").expect("namespace id"),
164            path: absolute_path(),
165            revision_no: RevisionNo(7),
166            content_ref: content_ref(),
167            access: ObjectTransferAccess::PresignedUrl {
168                method: "GET".to_owned(),
169                url: "https://bucket.example/object?X-Amz-Signature=abc".to_owned(),
170                headers: BTreeMap::new(),
171                expires_at_ms: 1,
172            },
173        };
174
175        assert_eq!(
176            serde_json::to_value(&response).expect("serialize response"),
177            serde_json::json!({
178                "namespace_id": "demo",
179                "path": "/docs/report.txt",
180                "revision_no": 7,
181                "content_ref": content_ref_json(),
182                "access": {
183                    "kind": "presigned_url",
184                    "method": "GET",
185                    "url": "https://bucket.example/object?X-Amz-Signature=abc",
186                    "expires_at_ms": 1
187                }
188            })
189        );
190    }
191
192    #[test]
193    fn an_inode_download_request_is_strictly_empty_and_its_grant_is_path_free() {
194        let request: BeginDownloadByInodeRequest =
195            serde_json::from_str("{}").expect("decode empty request");
196        assert_eq!(request, BeginDownloadByInodeRequest {});
197        assert!(serde_json::from_str::<BeginDownloadByInodeRequest>(r#"{"path":"/old"}"#).is_err());
198
199        let response = BeginDownloadByInodeResponse {
200            namespace_id: NamespaceId::parse("demo").expect("namespace id"),
201            inode_id: crate::InodeId(42),
202            revision_no: RevisionNo(7),
203            content_ref: content_ref(),
204            access: ObjectTransferAccess::PresignedUrl {
205                method: "GET".to_owned(),
206                url: "https://bucket.example/object?X-Amz-Signature=abc".to_owned(),
207                headers: BTreeMap::new(),
208                expires_at_ms: 1,
209            },
210        };
211        assert_eq!(
212            serde_json::to_value(&response).expect("serialize response"),
213            serde_json::json!({
214                "namespace_id": "demo",
215                "inode_id": "ino_42",
216                "revision_no": 7,
217                "content_ref": content_ref_json(),
218                "access": {
219                    "kind": "presigned_url",
220                    "method": "GET",
221                    "url": "https://bucket.example/object?X-Amz-Signature=abc",
222                    "expires_at_ms": 1
223                }
224            })
225        );
226    }
227}