Skip to main content

loonfs_api/v0/
downloads.rs

1//! Download requests and responses for direct object-store reads in the v0 HTTP API.
2
3use super::ObjectTransferAccess;
4use crate::{AbsolutePath, ContentRef, InodeId, NamespaceId, RevisionNo, SnapshotId};
5use serde::{Deserialize, Serialize};
6
7/// The path to download and, optionally, the revision to download.
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
10#[serde(deny_unknown_fields)]
11pub struct CreateDownloadRequest {
12    /// Absolute path of the file to read.
13    pub path: AbsolutePath,
14    /// Revision to read, or `None` for the path's current revision.
15    /// Cannot be combined with `snapshot_id`.
16    #[serde(default, skip_serializing_if = "Option::is_none")]
17    #[cfg_attr(feature = "openapi", schema(nullable = false))]
18    pub revision_no: Option<RevisionNo>,
19    /// Read the file revision captured by this snapshot.
20    /// Cannot be combined with `revision_no`.
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    #[cfg_attr(feature = "openapi", schema(nullable = false))]
23    pub snapshot_id: Option<SnapshotId>,
24}
25
26/// A presigned URL for one content object.
27///
28/// The URL expires at `access.expires_at_ms`; later path changes do not change the object.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
31pub struct CreateDownloadResponse {
32    /// Namespace that was read.
33    pub namespace_id: NamespaceId,
34    /// Absolute path as rendered from stored display names.
35    pub path: AbsolutePath,
36    /// Revision the capability reads, resolved from the request.
37    pub revision_no: RevisionNo,
38    /// The identity, byte length, and checksum of the object to download.
39    pub content_ref: ContentRef,
40    /// Short-lived read capability the client uses without learning the raw object key.
41    pub access: ObjectTransferAccess,
42}
43
44/// A short-lived capability to read one inode revision.
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
47pub struct CreateDownloadByInodeResponse {
48    /// Namespace that was read.
49    pub namespace_id: NamespaceId,
50    /// File inode being read.
51    #[serde(with = "crate::public_inode_id")]
52    pub inode_id: InodeId,
53    /// Revision being read.
54    pub revision_no: RevisionNo,
55    /// Content identity, size, and checksum.
56    pub content_ref: ContentRef,
57    /// Short-lived provider access without the raw object key.
58    pub access: ObjectTransferAccess,
59}
60
61#[cfg(test)]
62mod tests {
63    use super::{CreateDownloadByInodeResponse, CreateDownloadRequest, CreateDownloadResponse};
64    use crate::v0::ObjectTransferAccess;
65    use crate::{AbsolutePath, ContentId, ContentRef, NamespaceId, RevisionNo, SnapshotId};
66    use std::collections::BTreeMap;
67
68    fn absolute_path() -> AbsolutePath {
69        AbsolutePath::parse("/docs/report.txt").expect("absolute path")
70    }
71
72    fn content_ref() -> ContentRef {
73        ContentRef::blob_v1(
74            crate::NamespaceId::parse("demo").expect("namespace id"),
75            ContentId::parse("con_0123456789abcdef0123456789abcdef").expect("content id"),
76            b"hello",
77        )
78    }
79
80    fn content_ref_json() -> serde_json::Value {
81        serde_json::json!({
82            "kind": "blob_v1",
83            "owner_namespace_id": "demo",
84            "content_id": "con_0123456789abcdef0123456789abcdef",
85            "size_bytes": 5,
86            "checksum": {
87                "algorithm": "sha256",
88                "value": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
89            }
90        })
91    }
92
93    #[test]
94    fn a_create_download_request_decodes_revision_and_snapshot_selectors() {
95        let request: CreateDownloadRequest =
96            serde_json::from_str(r#"{"path":"/docs/report.txt"}"#).expect("decode request");
97        assert_eq!(request.path, absolute_path());
98        assert_eq!(request.revision_no, None);
99
100        let pinned: CreateDownloadRequest =
101            serde_json::from_str(r#"{"path":"/docs/report.txt","revision_no":3}"#)
102                .expect("decode pinned request");
103        assert_eq!(pinned.revision_no, Some(RevisionNo(3)));
104
105        let snapshot_id =
106            SnapshotId::parse("pin_00000000000000000001-0000000000000002").expect("snapshot id");
107        for revision_no in [None, Some(RevisionNo(3))] {
108            let request: CreateDownloadRequest = serde_json::from_value(serde_json::json!({
109                "path": "/docs/report.txt",
110                "revision_no": revision_no,
111                "snapshot_id": snapshot_id,
112            }))
113            .expect("decode snapshot request");
114            assert_eq!(request.snapshot_id, Some(snapshot_id.clone()));
115            assert_eq!(request.revision_no, revision_no);
116        }
117        assert!(serde_json::from_str::<CreateDownloadRequest>(
118            r#"{"path":"/docs/report.txt","snapshot_id":"invalid"}"#,
119        )
120        .is_err());
121
122        assert!(
123            serde_json::from_str::<CreateDownloadRequest>(
124                r#"{"path":"/docs/report.txt","content_id":"con_0123456789abcdef0123456789abcdef"}"#
125            )
126            .is_err(),
127            "a client must not be able to name the content object"
128        );
129    }
130
131    #[test]
132    fn a_download_grant_exposes_only_presigned_access() {
133        let response = CreateDownloadResponse {
134            namespace_id: NamespaceId::parse("demo").expect("namespace id"),
135            path: absolute_path(),
136            revision_no: RevisionNo(7),
137            content_ref: content_ref(),
138            access: ObjectTransferAccess::PresignedUrl {
139                method: "GET".to_owned(),
140                url: "https://bucket.example/object?X-Amz-Signature=abc".to_owned(),
141                headers: BTreeMap::new(),
142                expires_at_ms: 1,
143            },
144        };
145
146        assert_eq!(
147            serde_json::to_value(&response).expect("serialize response"),
148            serde_json::json!({
149                "namespace_id": "demo",
150                "path": "/docs/report.txt",
151                "revision_no": 7,
152                "content_ref": content_ref_json(),
153                "access": {
154                    "kind": "presigned_url",
155                    "method": "GET",
156                    "url": "https://bucket.example/object?X-Amz-Signature=abc",
157                    "expires_at_ms": 1
158                }
159            })
160        );
161    }
162
163    #[test]
164    fn an_inode_download_grant_is_path_free() {
165        let response = CreateDownloadByInodeResponse {
166            namespace_id: NamespaceId::parse("demo").expect("namespace id"),
167            inode_id: crate::InodeId(42),
168            revision_no: RevisionNo(7),
169            content_ref: content_ref(),
170            access: ObjectTransferAccess::PresignedUrl {
171                method: "GET".to_owned(),
172                url: "https://bucket.example/object?X-Amz-Signature=abc".to_owned(),
173                headers: BTreeMap::new(),
174                expires_at_ms: 1,
175            },
176        };
177        assert_eq!(
178            serde_json::to_value(&response).expect("serialize response"),
179            serde_json::json!({
180                "namespace_id": "demo",
181                "inode_id": "ino_42",
182                "revision_no": 7,
183                "content_ref": content_ref_json(),
184                "access": {
185                    "kind": "presigned_url",
186                    "method": "GET",
187                    "url": "https://bucket.example/object?X-Amz-Signature=abc",
188                    "expires_at_ms": 1
189                }
190            })
191        );
192    }
193}