Skip to main content

dre_core/
zip_pack.rs

1//! `zip:` on a destination entry: the output's files, plus every `attach:`ed output's, packed
2//! flat into one zip during the run, optionally with a password (AES-256 by default, ZipCrypto
3//! on request). The zip is built in the run folder, so a held entry records it and its SHA-256,
4//! and `dre release` sends exactly those bytes.
5
6use std::collections::BTreeSet;
7use std::fs::File;
8use std::io::{BufWriter, Write};
9use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12
13/// How a zip's files are encrypted.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize, schemars::JsonSchema)]
15#[serde(rename_all = "lowercase")]
16pub enum Encryption {
17    /// WinZip AES-256: strong; opens in 7-Zip, WinRAR, Keka, The Unarchiver.
18    #[default]
19    Aes256,
20    /// The legacy zip password: weak, but opens in Windows Explorer and macOS Archive Utility.
21    Zipcrypto,
22}
23
24impl Encryption {
25    pub const NAMES: &[&str] = &["aes256", "zipcrypto"];
26}
27
28/// A destination entry's `zip:`, as written: the password may hold Jinja.
29#[derive(Debug, Clone, PartialEq, Serialize)]
30pub struct ZipSpec {
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub password: Option<String>,
33    pub encryption: Encryption,
34}
35
36/// The zip's file name: the last part of the destination's `path`, else `<output>.zip`.
37/// An error when the path ends in another extension.
38pub fn zip_name(path: Option<&str>, output: &str) -> Result<String, String> {
39    let name = match path {
40        Some(p) => p.rsplit(['/', '\\']).next().unwrap_or(p).to_string(),
41        None => format!("{output}.zip"),
42    };
43    if name.to_ascii_lowercase().ends_with(".zip") {
44        Ok(name)
45    } else {
46        Err(format!(
47            "a zipping destination's path must end in `.zip`, but it names `{name}`"
48        ))
49    }
50}
51
52/// The flat names the files take in the zip; two files with one name are an error.
53pub fn entry_names(files: &[PathBuf]) -> Result<Vec<String>, String> {
54    let mut seen = BTreeSet::new();
55    let mut names = Vec::with_capacity(files.len());
56    for f in files {
57        let name = f
58            .file_name()
59            .map(|n| n.to_string_lossy().into_owned())
60            .ok_or_else(|| format!("{} has no file name", f.display()))?;
61        if !seen.insert(name.clone()) {
62            return Err(format!(
63                "two files named `{name}` would go into one zip (it's flat); give the outputs different `name:`s"
64            ));
65        }
66        names.push(name);
67    }
68    Ok(names)
69}
70
71/// Pack `files` flat into the zip `to`. Entries carry a fixed date, so the same files make
72/// the same zip (without a password; encryption salts are random).
73pub fn build(
74    to: &Path,
75    files: &[PathBuf],
76    password: Option<&str>,
77    encryption: Encryption,
78) -> Result<(), String> {
79    use zip::unstable::write::FileOptionsExt;
80    use zip::write::SimpleFileOptions;
81
82    if password == Some("") {
83        return Err(
84            "the zip's `password` is empty; an empty password would leave the zip unencrypted".into(),
85        );
86    }
87    let names = entry_names(files)?;
88    if let Some(dir) = to.parent() {
89        std::fs::create_dir_all(dir).map_err(|e| format!("can't create {}: {e}", dir.display()))?;
90    }
91    let out = File::create(to).map_err(|e| format!("can't write {}: {e}", to.display()))?;
92    let mut w = zip::ZipWriter::new(BufWriter::new(out));
93    let base = SimpleFileOptions::default()
94        .compression_method(zip::CompressionMethod::Deflated)
95        .last_modified_time(zip::DateTime::default())
96        .unix_permissions(0o644);
97    for (f, name) in files.iter().zip(&names) {
98        let opts = match (password, encryption) {
99            (None, _) => base,
100            (Some(pw), Encryption::Aes256) => base.with_aes_encryption(zip::AesMode::Aes256, pw),
101            (Some(pw), Encryption::Zipcrypto) => base
102                .with_deprecated_encryption(pw.as_bytes())
103                .map_err(|e| format!("can't encrypt the zip: {e}"))?,
104        };
105        w.start_file(name.as_str(), opts)
106            .map_err(|e| format!("can't add {name} to the zip: {e}"))?;
107        let bytes = std::fs::read(f).map_err(|e| format!("can't read {}: {e}", f.display()))?;
108        w.write_all(&bytes)
109            .map_err(|e| format!("can't add {name} to the zip: {e}"))?;
110    }
111    w.finish()
112        .map_err(|e| format!("can't write {}: {e}", to.display()))?
113        .flush()
114        .map_err(|e| format!("can't write {}: {e}", to.display()))
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use std::io::Read;
121
122    fn read(zip: &Path, name: &str, pw: Option<&str>) -> Result<String, String> {
123        let mut a = zip::ZipArchive::new(File::open(zip).unwrap()).map_err(|e| e.to_string())?;
124        let mut s = String::new();
125        match pw {
126            Some(pw) => a.by_name_decrypt(name, pw.as_bytes()),
127            None => a.by_name(name),
128        }
129        .map_err(|e| e.to_string())?
130        .read_to_string(&mut s)
131        .map_err(|e| e.to_string())?;
132        Ok(s)
133    }
134
135    #[test]
136    fn plain_aes_and_zipcrypto_zips_open_with_their_password_only() {
137        let d = tempfile::tempdir().unwrap();
138        let a = d.path().join("a/summary.csv");
139        let b = d.path().join("b/detail.csv");
140        std::fs::create_dir_all(a.parent().unwrap()).unwrap();
141        std::fs::create_dir_all(b.parent().unwrap()).unwrap();
142        std::fs::write(&a, "x\n1\n").unwrap();
143        std::fs::write(&b, "y\n2\n").unwrap();
144        let files = vec![a.clone(), b.clone()];
145        let plain = d.path().join("plain.zip");
146        build(&plain, &files, None, Encryption::Aes256).unwrap();
147        assert_eq!(read(&plain, "summary.csv", None).unwrap(), "x\n1\n");
148        // The same files make the same zip.
149        let again = d.path().join("again.zip");
150        build(&again, &files, None, Encryption::Aes256).unwrap();
151        assert_eq!(std::fs::read(&plain).unwrap(), std::fs::read(&again).unwrap());
152        for enc in [Encryption::Aes256, Encryption::Zipcrypto] {
153            let z = d.path().join(format!("{enc:?}.zip"));
154            build(&z, &files, Some("Pack-pw-1"), enc).unwrap();
155            assert_eq!(read(&z, "detail.csv", Some("Pack-pw-1")).unwrap(), "y\n2\n");
156            assert!(read(&z, "detail.csv", Some("wrong")).is_err(), "{enc:?}");
157            assert!(read(&z, "detail.csv", None).is_err(), "{enc:?}");
158        }
159        assert!(build(&d.path().join("e.zip"), &files, Some(""), Encryption::Aes256).is_err());
160        let clash = vec![a.clone(), d.path().join("summary.csv")];
161        let e = entry_names(&clash).unwrap_err();
162        assert!(e.contains("two files named `summary.csv`"), "{e}");
163    }
164
165    #[test]
166    fn names() {
167        assert_eq!(
168            zip_name(Some("/out/pack-2026.zip"), "detail").unwrap(),
169            "pack-2026.zip"
170        );
171        assert_eq!(zip_name(None, "detail").unwrap(), "detail.zip");
172        assert!(zip_name(Some("out/pack.csv"), "detail").is_err());
173    }
174}