1use std::collections::BTreeSet;
7use std::fs::File;
8use std::io::{BufWriter, Write};
9use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize, schemars::JsonSchema)]
15#[serde(rename_all = "lowercase")]
16pub enum Encryption {
17 #[default]
19 Aes256,
20 Zipcrypto,
22}
23
24impl Encryption {
25 pub const NAMES: &[&str] = &["aes256", "zipcrypto"];
26}
27
28#[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
36pub 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
52pub 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
71pub 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 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}