1use std::{
24 fs::File,
25 io::{self, BufWriter, Write},
26 path::Path,
27};
28
29use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};
30
31const ZIP_WRITE_BUF: usize = 64 * 1024;
38
39pub struct PackOptions<'a> {
41 pub zip_path: &'a str,
43 pub html_filename: &'a str,
46 pub html: &'a str,
48 pub log_filename: Option<&'a str>,
50 pub log: &'a str,
52 pub status: &'a str,
54 pub resource_dir: Option<&'a Path>,
58 pub telemetry_json: Option<&'a str>,
63 pub source_date_epoch: Option<u64>,
69}
70
71pub fn pack_archive(opts: &PackOptions) -> io::Result<()> {
82 let file = File::create(opts.zip_path)?;
83 let buf_file = BufWriter::with_capacity(ZIP_WRITE_BUF, file);
84 let mut zip = ZipWriter::new(buf_file);
85 let mut zip_options =
86 SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
87 if let Some(epoch) = opts.source_date_epoch {
93 if let Some(dt) = epoch_to_zip_datetime(epoch) {
94 zip_options = zip_options.last_modified_time(dt);
95 }
96 }
97
98 zip
100 .start_file(opts.html_filename, zip_options)
101 .map_err(io_err)?;
102 zip.write_all(opts.html.as_bytes())?;
103
104 if let Some(dir) = opts.resource_dir {
106 if dir.exists() {
107 add_dir_to_zip(&mut zip, dir, dir, &zip_options)?;
108 }
109 }
110
111 if let Some(log_name) = opts.log_filename {
113 if !opts.log.is_empty() {
114 zip.start_file(log_name, zip_options).map_err(io_err)?;
115 zip.write_all(opts.log.as_bytes())?;
116 }
117 }
118
119 zip.start_file("status", zip_options).map_err(io_err)?;
121 zip.write_all(opts.status.as_bytes())?;
122
123 if let Some(tjson) = opts.telemetry_json {
125 zip
126 .start_file("telemetry.json", zip_options)
127 .map_err(io_err)?;
128 zip.write_all(tjson.as_bytes())?;
129 }
130
131 zip.finish().map_err(io_err)?;
132 Ok(())
133}
134
135fn add_dir_to_zip<W: Write + io::Seek>(
149 zip: &mut ZipWriter<W>,
150 dir: &Path,
151 base: &Path,
152 options: &SimpleFileOptions,
153) -> io::Result<()> {
154 for entry in std::fs::read_dir(dir)? {
155 let entry = entry?;
156 let path = entry.path();
157 let rel = path.strip_prefix(base).unwrap_or(&path);
158 let name = rel
163 .to_string_lossy()
164 .replace(std::path::MAIN_SEPARATOR, "/");
165 let basename = entry.file_name().to_string_lossy().to_string();
166
167 if path.is_dir() {
168 if !is_excluded_archive_entry(&basename) {
172 add_dir_to_zip(zip, &path, base, options)?;
173 }
174 } else if !name.ends_with(".html") && !is_excluded_archive_entry(&basename) {
175 zip.start_file(&name, *options).map_err(io_err)?;
176 let f = File::open(&path)?;
177 let mut buf_reader = io::BufReader::with_capacity(ZIP_WRITE_BUF, f);
178 io::copy(&mut buf_reader, zip)?;
179 }
180 }
181 Ok(())
182}
183
184fn is_excluded_archive_entry(basename: &str) -> bool {
193 if basename.starts_with('.') || basename.ends_with('~') {
194 return true;
195 }
196 match basename.rsplit_once('.') {
199 Some((_, ext)) => matches!(
200 ext.to_ascii_lowercase().as_str(),
201 "zip" | "gz" | "epub" | "tex" | "bib" | "mobi" | "cache"
202 ),
203 None => false,
204 }
205}
206
207fn epoch_to_zip_datetime(epoch: u64) -> Option<zip::DateTime> {
214 let days = (epoch / 86_400) as i64;
215 let secs_of_day = (epoch % 86_400) as u32;
216 let (hour, minute, second) = (
217 (secs_of_day / 3600) as u8,
218 ((secs_of_day % 3600) / 60) as u8,
219 (secs_of_day % 60) as u8,
220 );
221 let z = days + 719_468;
223 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
224 let doe = z - era * 146_097; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; let year = yoe + era * 400;
227 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let day = (doy - (153 * mp + 2) / 5 + 1) as u8; let month = (if mp < 10 { mp + 3 } else { mp - 9 }) as u8; let year = year + i64::from(month <= 2);
232 if !(1980..=2107).contains(&year) {
233 return None;
234 }
235 zip::DateTime::from_date_and_time(year as u16, month, day, hour, minute, second).ok()
236}
237
238fn io_err(e: zip::result::ZipError) -> io::Error {
242 match e {
243 zip::result::ZipError::Io(inner) => inner,
244 other => io::Error::other(other.to_string()),
245 }
246}
247
248#[cfg(test)]
249mod tests {
250 use std::io::Read;
251
252 use super::*;
253
254 fn zip_entry_names(zip_path: &Path) -> Vec<String> {
256 let f = File::open(zip_path).expect("open zip");
257 let mut archive = zip::ZipArchive::new(f).expect("parse zip");
258 (0..archive.len())
259 .map(|i| archive.by_index(i).expect("entry").name().to_string())
260 .collect()
261 }
262
263 #[test]
264 fn excludes_perl_archive_ext_set() {
265 assert!(is_excluded_archive_entry("paper.tex"));
268 assert!(is_excluded_archive_entry("refs.bib"));
269 assert!(is_excluded_archive_entry("bundle.zip"));
270 assert!(is_excluded_archive_entry("page.gz"));
271 assert!(is_excluded_archive_entry("book.epub"));
272 assert!(is_excluded_archive_entry("book.mobi"));
273 assert!(is_excluded_archive_entry("LaTeXML.cache"));
274 assert!(is_excluded_archive_entry(".hidden"));
275 assert!(is_excluded_archive_entry("backup~"));
276 assert!(!is_excluded_archive_entry("fig1.png"));
278 assert!(!is_excluded_archive_entry("diagram.svg"));
279 assert!(!is_excluded_archive_entry("LaTeXML.css"));
280 assert!(!is_excluded_archive_entry("logo.jpg"));
281 }
282
283 #[test]
284 fn pack_archive_bundles_resources_minus_excluded() {
285 let staging = tempfile::tempdir().expect("tempdir");
286 let p = staging.path();
287 std::fs::write(p.join("fig1.png"), b"PNGDATA").unwrap();
289 std::fs::write(p.join("LaTeXML.css"), b"body{}").unwrap();
290 std::fs::create_dir(p.join("sub")).unwrap();
291 std::fs::write(p.join("sub").join("img.svg"), b"<svg/>").unwrap();
292 std::fs::write(p.join("paper.tex"), b"\\documentclass{article}").unwrap();
294 std::fs::write(p.join("refs.bib"), b"@book{x}").unwrap();
295 std::fs::write(p.join("LaTeXML.cache"), b"cache").unwrap();
296 std::fs::write(p.join(".hidden"), b"secret").unwrap();
297 std::fs::write(p.join("backup~"), b"old").unwrap();
298 std::fs::write(p.join("doc.html"), b"<html>staging copy</html>").unwrap();
301
302 let out = tempfile::tempdir().expect("out dir");
303 let zip_path = out.path().join("bundle.zip");
304 let zip_path_str = zip_path.to_string_lossy().to_string();
305
306 pack_archive(&PackOptions {
307 zip_path: &zip_path_str,
308 html_filename: "doc.html",
309 html: "<html>real document</html>",
310 log_filename: Some("doc.log"),
311 log: "log line",
312 status: "Status:conversion:0",
313 resource_dir: Some(p),
314 telemetry_json: None,
315 source_date_epoch: None,
316 })
317 .expect("pack archive");
318
319 let names = zip_entry_names(&zip_path);
320 assert!(names.iter().any(|n| n == "fig1.png"), "names: {names:?}");
322 assert!(names.iter().any(|n| n == "LaTeXML.css"), "names: {names:?}");
323 assert!(
324 names.iter().any(|n| n == "sub/img.svg"),
325 "subdir resource missing; names: {names:?}"
326 );
327 assert!(names.iter().any(|n| n == "doc.html"));
329 assert!(names.iter().any(|n| n == "doc.log"));
330 assert!(names.iter().any(|n| n == "status"));
331 for forbidden in [
333 "paper.tex",
334 "refs.bib",
335 "LaTeXML.cache",
336 ".hidden",
337 "backup~",
338 ] {
339 assert!(
340 !names.iter().any(|n| n == forbidden),
341 "{forbidden} must be excluded; names: {names:?}"
342 );
343 }
344 assert_eq!(
346 names.iter().filter(|n| n.as_str() == "doc.html").count(),
347 1,
348 "doc.html must not be double-added; names: {names:?}"
349 );
350 let f = File::open(&zip_path).unwrap();
352 let mut archive = zip::ZipArchive::new(f).unwrap();
353 let mut html_entry = archive.by_name("doc.html").unwrap();
354 let mut body = String::new();
355 html_entry.read_to_string(&mut body).unwrap();
356 assert_eq!(body, "<html>real document</html>");
357 }
358
359 #[test]
360 fn source_date_epoch_sets_member_timestamp() {
361 let staging = tempfile::tempdir().expect("tempdir");
365 std::fs::write(staging.path().join("fig.png"), b"x").unwrap();
366 let out = tempfile::tempdir().expect("out");
367 let zip_path = out.path().join("ts.zip");
368 let zip_path_str = zip_path.to_string_lossy().to_string();
369 pack_archive(&PackOptions {
370 zip_path: &zip_path_str,
371 html_filename: "d.html",
372 html: "<html/>",
373 log_filename: None,
374 log: "",
375 status: "ok",
376 resource_dir: Some(staging.path()),
377 telemetry_json: None,
378 source_date_epoch: Some(1_609_459_200),
379 })
380 .expect("pack");
381
382 let f = File::open(&zip_path).unwrap();
383 let mut archive = zip::ZipArchive::new(f).unwrap();
384 let entry = archive.by_name("fig.png").unwrap();
385 let dt = entry.last_modified().expect("has mod time");
386 assert_eq!(dt.year(), 2021, "year");
387 assert_eq!(dt.month(), 1, "month");
388 assert_eq!(dt.day(), 1, "day");
389 }
390}