Skip to main content

internetarchive_rs/
upload.rs

1//! Upload and delete option types.
2
3use std::path::{Path, PathBuf};
4
5use mime::Mime;
6
7/// Replacement policy for uploads targeting an existing file name.
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub enum FileConflictPolicy {
10    /// Return an error if the file already exists.
11    Error,
12    /// Skip uploads for files that already exist.
13    Skip,
14    /// Overwrite the existing file in place.
15    Overwrite,
16    /// Overwrite the existing file while preserving its old version.
17    OverwriteKeepingHistory,
18}
19
20/// Per-upload options for IA S3 requests.
21#[derive(Clone, Debug, Default, PartialEq, Eq)]
22pub struct UploadOptions {
23    /// Skip the derive queue for this upload.
24    pub skip_derive: bool,
25    /// Keep the old file version if the key already exists.
26    pub keep_old_version: bool,
27    /// Request interactive priority in the ingest queue.
28    pub interactive_priority: bool,
29    /// Optional size hint for the full bucket.
30    pub size_hint: Option<u64>,
31}
32
33/// Options for S3 deletes.
34#[derive(Clone, Debug, PartialEq, Eq, Default)]
35pub struct DeleteOptions {
36    /// Delete related derivative files.
37    pub cascade_delete: bool,
38    /// Keep the old file version in history.
39    pub keep_old_version: bool,
40}
41
42/// Upload source kind.
43#[derive(Clone, Debug, PartialEq, Eq)]
44pub enum UploadSource {
45    /// Upload from a local file path.
46    Path(PathBuf),
47    /// Upload from in-memory bytes.
48    Bytes(Vec<u8>),
49}
50
51/// One file upload specification.
52#[derive(Clone, Debug, PartialEq, Eq)]
53pub struct UploadSpec {
54    /// Final file name inside the item.
55    pub filename: String,
56    /// Source content.
57    pub source: UploadSource,
58    /// MIME type used for the request.
59    pub content_type: Mime,
60}
61
62impl UploadSpec {
63    /// Builds an upload spec from a local path, using the file name from the path.
64    ///
65    /// # Errors
66    ///
67    /// Returns an error if the path does not contain a final file name.
68    pub fn from_path(path: impl AsRef<Path>) -> Result<Self, std::io::Error> {
69        let path = path.as_ref();
70        let filename = path_filename(path)?;
71
72        Ok(Self {
73            filename,
74            source: UploadSource::Path(path.to_path_buf()),
75            content_type: guess_content_type(path, None, None),
76        })
77    }
78
79    /// Builds an upload spec from a local path and an explicit archive file name.
80    ///
81    /// The content type is guessed from the archive file name first and falls
82    /// back to the local path when the new name has no recognizable extension.
83    ///
84    /// # Errors
85    ///
86    /// Returns an error if the archive file name is empty.
87    pub fn from_path_as(
88        path: impl AsRef<Path>,
89        filename: impl Into<String>,
90    ) -> Result<Self, std::io::Error> {
91        let path = path.as_ref();
92        let filename = validate_archive_filename(filename.into())?;
93
94        Ok(Self {
95            content_type: guess_content_type(path, Some(&filename), None),
96            filename,
97            source: UploadSource::Path(path.to_path_buf()),
98        })
99    }
100
101    /// Builds upload specs from `(archive_filename, local_path)` manifest pairs.
102    ///
103    /// # Errors
104    ///
105    /// Returns an error if any archive file name is empty.
106    pub fn from_manifest<I, F, P>(entries: I) -> Result<Vec<Self>, std::io::Error>
107    where
108        I: IntoIterator<Item = (F, P)>,
109        F: Into<String>,
110        P: AsRef<Path>,
111    {
112        entries
113            .into_iter()
114            .map(|(filename, path)| Self::from_path_as(path, filename))
115            .collect()
116    }
117
118    /// Builds an upload spec from a custom file name and bytes.
119    #[must_use]
120    pub fn from_bytes(filename: impl Into<String>, bytes: impl Into<Vec<u8>>) -> Self {
121        let filename = filename.into();
122        Self {
123            content_type: guess_content_type(Path::new(&filename), Some(&filename), None),
124            filename,
125            source: UploadSource::Bytes(bytes.into()),
126        }
127    }
128
129    /// Overrides the archive file name.
130    ///
131    /// The content type is re-guessed from the new file name and falls back to
132    /// the previous content type when the name has no recognizable extension.
133    #[must_use]
134    pub fn with_filename(mut self, filename: impl Into<String>) -> Self {
135        let filename = filename.into();
136        self.content_type = match &self.source {
137            UploadSource::Path(path) => {
138                guess_content_type(path, Some(&filename), Some(self.content_type.clone()))
139            }
140            UploadSource::Bytes(_) => guess_content_type(
141                Path::new(&filename),
142                Some(&filename),
143                Some(self.content_type.clone()),
144            ),
145        };
146        self.filename = filename;
147        self
148    }
149
150    /// Overrides the upload content type.
151    #[must_use]
152    pub fn with_content_type(mut self, content_type: Mime) -> Self {
153        self.content_type = content_type;
154        self
155    }
156}
157
158fn path_filename(path: &Path) -> Result<String, std::io::Error> {
159    path.file_name()
160        .and_then(|value| value.to_str())
161        .ok_or_else(|| {
162            std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no filename")
163        })
164        .map(str::to_owned)
165}
166
167fn validate_archive_filename(filename: String) -> Result<String, std::io::Error> {
168    if filename.is_empty() {
169        return Err(std::io::Error::new(
170            std::io::ErrorKind::InvalidInput,
171            "archive filename cannot be empty",
172        ));
173    }
174    Ok(filename)
175}
176
177fn guess_content_type(path: &Path, archive_filename: Option<&str>, fallback: Option<Mime>) -> Mime {
178    archive_filename
179        .and_then(|filename| mime_guess::from_path(filename).first())
180        .or_else(|| mime_guess::from_path(path).first())
181        .or(fallback)
182        .unwrap_or(mime::APPLICATION_OCTET_STREAM)
183}
184
185#[cfg(test)]
186mod tests {
187    use std::path::Path;
188
189    use super::{FileConflictPolicy, UploadOptions, UploadSource, UploadSpec};
190
191    #[test]
192    fn upload_spec_from_bytes_guesses_content_type() {
193        let spec = UploadSpec::from_bytes("demo.txt", b"hello");
194        assert_eq!(spec.filename, "demo.txt");
195        assert_eq!(spec.content_type, mime::TEXT_PLAIN);
196    }
197
198    #[test]
199    fn upload_options_default_to_safe_values() {
200        let options = UploadOptions::default();
201        assert!(!options.skip_derive);
202        assert!(!options.keep_old_version);
203        assert_eq!(
204            FileConflictPolicy::OverwriteKeepingHistory,
205            FileConflictPolicy::OverwriteKeepingHistory
206        );
207    }
208
209    #[test]
210    fn upload_spec_from_path_and_content_type_override_work() {
211        let directory = tempfile::tempdir().unwrap();
212        let path = directory.path().join("artifact.bin");
213        std::fs::write(&path, [1_u8, 2, 3]).unwrap();
214
215        let spec = UploadSpec::from_path(&path)
216            .unwrap()
217            .with_content_type(mime::APPLICATION_OCTET_STREAM);
218
219        assert_eq!(spec.filename, "artifact.bin");
220        assert_eq!(spec.content_type, mime::APPLICATION_OCTET_STREAM);
221        assert!(matches!(spec.source, UploadSource::Path(ref source) if source == &path));
222    }
223
224    #[test]
225    fn upload_spec_from_path_as_uses_archive_filename_for_name_and_content_type() {
226        let directory = tempfile::tempdir().unwrap();
227        let path = directory.path().join("artifact.bin");
228        std::fs::write(&path, [1_u8, 2, 3]).unwrap();
229
230        let spec = UploadSpec::from_path_as(&path, "artifact.txt").unwrap();
231
232        assert_eq!(spec.filename, "artifact.txt");
233        assert_eq!(spec.content_type, mime::TEXT_PLAIN);
234        assert!(matches!(spec.source, UploadSource::Path(ref source) if source == &path));
235    }
236
237    #[test]
238    fn upload_spec_with_filename_refreshes_content_type() {
239        let directory = tempfile::tempdir().unwrap();
240        let path = directory.path().join("artifact.bin");
241        std::fs::write(&path, [1_u8, 2, 3]).unwrap();
242
243        let spec = UploadSpec::from_path(&path)
244            .unwrap()
245            .with_filename("artifact.txt");
246
247        assert_eq!(spec.filename, "artifact.txt");
248        assert_eq!(spec.content_type, mime::TEXT_PLAIN);
249        assert!(matches!(spec.source, UploadSource::Path(ref source) if source == &path));
250    }
251
252    #[test]
253    fn upload_spec_from_manifest_builds_renamed_specs_in_order() {
254        let directory = tempfile::tempdir().unwrap();
255        let first = directory.path().join("first.bin");
256        let second = directory.path().join("second.bin");
257        std::fs::write(&first, [1_u8]).unwrap();
258        std::fs::write(&second, [2_u8]).unwrap();
259
260        let specs = UploadSpec::from_manifest([
261            ("release/first.txt", first.as_path()),
262            ("release/second.bin", second.as_path()),
263        ])
264        .unwrap();
265
266        assert_eq!(specs.len(), 2);
267        assert_eq!(specs[0].filename, "release/first.txt");
268        assert_eq!(specs[0].content_type, mime::TEXT_PLAIN);
269        assert_eq!(specs[1].filename, "release/second.bin");
270        assert_eq!(specs[1].content_type, mime::APPLICATION_OCTET_STREAM);
271        assert!(matches!(specs[0].source, UploadSource::Path(ref source) if source == &first));
272        assert!(matches!(specs[1].source, UploadSource::Path(ref source) if source == &second));
273    }
274
275    #[test]
276    fn upload_spec_from_path_rejects_paths_without_a_filename() {
277        let error = UploadSpec::from_path(Path::new("/")).unwrap_err();
278        assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
279    }
280
281    #[test]
282    fn upload_spec_from_path_as_rejects_empty_archive_filename() {
283        let directory = tempfile::tempdir().unwrap();
284        let path = directory.path().join("artifact.bin");
285        std::fs::write(&path, [1_u8, 2, 3]).unwrap();
286
287        let error = UploadSpec::from_path_as(&path, "").unwrap_err();
288        assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
289    }
290}