From 7312c98295bf6da0573d5fe80af93e0fbc4d2c57 Mon Sep 17 00:00:00 2001 From: Bradley Grainger Date: Fri, 29 May 2026 22:40:26 -0700 Subject: [PATCH] Preserve ZIP modified times when signing packages Portable package signing rewrote ZIP entries with zip::write::FileOptions defaults, which left DOS timestamps at 1980-01-01. That made signed files inside NuGet packages lose their Windows Date modified metadata. Preserve timestamps on copied ZIP entries, stamp rewritten and new entries with local wall-clock time, and cover the behavior with targeted NuGet and ZIP repack tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 12 ++++ crates/psign-opc-sign/Cargo.toml | 2 +- crates/psign-opc-sign/src/nuget.rs | 104 +++++++++++++++++++++++++--- crates/psign-opc-sign/src/opc.rs | 47 +++++++++++++ src/code.rs | 106 +++++++++++++++++++++++++++-- 5 files changed, 256 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2fe9150..c11f6f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1601,6 +1601,15 @@ dependencies = [ "libm", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "object" version = "0.37.3" @@ -2120,6 +2129,7 @@ dependencies = [ "anyhow", "base64", "sha2 0.10.9", + "time", "zip", ] @@ -3032,7 +3042,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", diff --git a/crates/psign-opc-sign/Cargo.toml b/crates/psign-opc-sign/Cargo.toml index e70b68e..5e93f49 100644 --- a/crates/psign-opc-sign/Cargo.toml +++ b/crates/psign-opc-sign/Cargo.toml @@ -10,5 +10,5 @@ repository.workspace = true anyhow = "1" base64 = "0.22" sha2 = "0.10" +time = { version = "0.3", features = ["local-offset"] } zip = { version = "0.6.6", default-features = false, features = ["deflate"] } - diff --git a/crates/psign-opc-sign/src/nuget.rs b/crates/psign-opc-sign/src/nuget.rs index e178215..b894ee8 100644 --- a/crates/psign-opc-sign/src/nuget.rs +++ b/crates/psign-opc-sign/src/nuget.rs @@ -1,4 +1,6 @@ -use crate::opc::{PackageSummary, inspect_package_path, normalize_zip_part_name}; +use crate::opc::{ + PackageSummary, current_zip_datetime, inspect_package_path, normalize_zip_part_name, +}; use anyhow::{Context, Result, anyhow}; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use sha2::{Digest, Sha256, Sha384, Sha512}; @@ -10,8 +12,10 @@ use zip::write::FileOptions; pub const PACKAGE_SIGNATURE_FILE_NAME: &str = ".signature.p7s"; pub const SIGNATURE_CONTENT_VERSION: &str = "1"; -fn nuget_zip_options(compression: zip::CompressionMethod) -> FileOptions { - FileOptions::default().compression_method(compression) +fn nuget_zip_options(compression: zip::CompressionMethod, modified: zip::DateTime) -> FileOptions { + FileOptions::default() + .compression_method(compression) + .last_modified_time(modified) } fn normalize_nuget_zip_metadata(bytes: &mut [u8]) -> Result<()> { @@ -330,7 +334,7 @@ where continue; } - let options = nuget_zip_options(file.compression()); + let options = nuget_zip_options(file.compression(), file.last_modified()); if file.is_dir() { output.add_directory(name, options)?; } else { @@ -404,6 +408,8 @@ where let mut input = zip::ZipArchive::new(reader).context("open NuGet ZIP")?; let mut out = std::io::Cursor::new(Vec::new()); let mut had_signature = false; + let signature_timestamp = + current_zip_datetime().context("determine local ZIP timestamp for NuGet signature")?; { let mut output = zip::ZipWriter::new(&mut out); @@ -421,7 +427,7 @@ where )); } - let options = nuget_zip_options(file.compression()); + let options = nuget_zip_options(file.compression(), file.last_modified()); if file.is_dir() { output.add_directory(name, options)?; } else { @@ -433,7 +439,7 @@ where if !had_signature || overwrite { output.start_file( PACKAGE_SIGNATURE_FILE_NAME, - nuget_zip_options(zip::CompressionMethod::Stored), + nuget_zip_options(zip::CompressionMethod::Stored, signature_timestamp), )?; output.write_all(signature_der)?; } @@ -453,14 +459,25 @@ mod tests { use zip::write::FileOptions; fn zip_with(entries: &[(&str, &[u8])]) -> Vec { + zip_with_timestamps( + &entries + .iter() + .map(|(name, bytes)| (*name, *bytes, zip::DateTime::default())) + .collect::>(), + ) + } + + fn zip_with_timestamps(entries: &[(&str, &[u8], zip::DateTime)]) -> Vec { let mut out = Cursor::new(Vec::new()); { let mut writer = zip::ZipWriter::new(&mut out); - for (name, bytes) in entries { + for (name, bytes, modified) in entries { let options = if *name == PACKAGE_SIGNATURE_FILE_NAME { - FileOptions::default().compression_method(zip::CompressionMethod::Stored) - } else { FileOptions::default() + .compression_method(zip::CompressionMethod::Stored) + .last_modified_time(*modified) + } else { + FileOptions::default().last_modified_time(*modified) }; writer.start_file(*name, options).unwrap(); writer.write_all(bytes).unwrap(); @@ -470,6 +487,29 @@ mod tests { out.into_inner() } + fn test_datetime( + year: u16, + month: u8, + day: u8, + hour: u8, + minute: u8, + second: u8, + ) -> zip::DateTime { + zip::DateTime::from_date_and_time(year, month, day, hour, minute, second) + .expect("valid test timestamp") + } + + fn zip_datetime_parts(dt: zip::DateTime) -> (u16, u8, u8, u8, u8, u8) { + ( + dt.year(), + dt.month(), + dt.day(), + dt.hour(), + dt.minute(), + dt.second(), + ) + } + fn eocd_offset(bytes: &[u8]) -> usize { bytes .windows(4) @@ -646,6 +686,30 @@ mod tests { ); } + #[test] + fn embed_signature_preserves_existing_entry_timestamps() { + let original = test_datetime(2001, 2, 3, 4, 5, 6); + let zip = zip_with_timestamps(&[("lib/net8.0/a.dll", b"pe", original)]); + let mut out = Cursor::new(Vec::new()); + + embed_signature(Cursor::new(zip), &mut out, b"cms", false).unwrap(); + + let mut archive = zip::ZipArchive::new(Cursor::new(out.into_inner())).unwrap(); + assert_eq!( + zip_datetime_parts(archive.by_name("lib/net8.0/a.dll").unwrap().last_modified()), + zip_datetime_parts(original) + ); + assert_ne!( + zip_datetime_parts( + archive + .by_name(PACKAGE_SIGNATURE_FILE_NAME) + .unwrap() + .last_modified() + ), + zip_datetime_parts(zip::DateTime::default()) + ); + } + #[test] fn embed_signature_rejects_existing_signature_without_overwrite() { let zip = zip_with(&[(PACKAGE_SIGNATURE_FILE_NAME, b"old")]); @@ -700,6 +764,28 @@ mod tests { ); } + #[test] + fn write_package_without_signature_preserves_existing_entry_timestamps() { + let original = test_datetime(2004, 5, 6, 7, 8, 10); + let zip = zip_with_timestamps(&[ + ("lib/net8.0/a.dll", b"pe", original), + ( + PACKAGE_SIGNATURE_FILE_NAME, + b"cms", + test_datetime(2005, 6, 7, 8, 9, 10), + ), + ]); + let mut out = Cursor::new(Vec::new()); + + write_package_without_signature(Cursor::new(zip), &mut out).unwrap(); + + let mut archive = zip::ZipArchive::new(Cursor::new(out.into_inner())).unwrap(); + assert_eq!( + zip_datetime_parts(archive.by_name("lib/net8.0/a.dll").unwrap().last_modified()), + zip_datetime_parts(original) + ); + } + #[test] fn embed_signature_clears_unix_central_directory_metadata() { let mut zip = zip_with(&[("lib/net8.0/a.dll", b"pe")]); diff --git a/crates/psign-opc-sign/src/opc.rs b/crates/psign-opc-sign/src/opc.rs index 37ca20b..f87a92a 100644 --- a/crates/psign-opc-sign/src/opc.rs +++ b/crates/psign-opc-sign/src/opc.rs @@ -2,6 +2,7 @@ use anyhow::{Context, Result, anyhow}; use std::fs::File; use std::io::{Read, Seek}; use std::path::Path; +use time::OffsetDateTime; use zip::ZipArchive; pub const CONTENT_TYPES_PART: &str = "[Content_Types].xml"; @@ -105,6 +106,29 @@ pub fn normalize_zip_part_name(name: &str) -> Result { Ok(name.to_string()) } +pub fn current_zip_datetime() -> Result { + let now = OffsetDateTime::now_local().context("determine local time for ZIP timestamp")?; + zip_datetime_from_offset_date_time(now) +} + +fn zip_datetime_from_offset_date_time(dt: OffsetDateTime) -> Result { + let year = dt.year(); + if !(1980..=2107).contains(&year) { + return Err(anyhow!( + "local time year {year} is outside the ZIP DOS timestamp range" + )); + } + zip::DateTime::from_date_and_time( + year as u16, + u8::from(dt.month()), + dt.day(), + dt.hour(), + dt.minute(), + dt.second(), + ) + .map_err(|_| anyhow!("local time could not be represented as a ZIP DOS timestamp")) +} + #[cfg(test)] mod tests { use super::*; @@ -125,6 +149,17 @@ mod tests { out.into_inner() } + fn zip_datetime_parts(dt: zip::DateTime) -> (u16, u8, u8, u8, u8, u8) { + ( + dt.year(), + dt.month(), + dt.day(), + dt.hour(), + dt.minute(), + dt.second(), + ) + } + #[test] fn detects_opc_signature_parts() { let zip = zip_with(&[ @@ -153,4 +188,16 @@ mod tests { let err = normalize_zip_part_name(r"_rels\.rels").unwrap_err(); assert!(err.to_string().contains("separators")); } + + #[test] + fn zip_datetime_from_offset_date_time_preserves_local_clock_fields() { + let dt = OffsetDateTime::from_unix_timestamp(946_684_800) + .unwrap() + .to_offset(time::UtcOffset::from_hms(-8, 0, 0).unwrap()); + + assert_eq!( + zip_datetime_parts(zip_datetime_from_offset_date_time(dt).unwrap()), + zip_datetime_parts(zip::DateTime::from_date_and_time(1999, 12, 31, 16, 0, 0).unwrap()) + ); + } } diff --git a/src/code.rs b/src/code.rs index 6bc788e..dfad938 100644 --- a/src/code.rs +++ b/src/code.rs @@ -3001,6 +3001,15 @@ pub(crate) struct ZipEntryUpdate { pub compression: zip::CompressionMethod, } +fn zip_file_options( + compression: zip::CompressionMethod, + modified: zip::DateTime, +) -> zip::write::FileOptions { + zip::write::FileOptions::default() + .compression_method(compression) + .last_modified_time(modified) +} + #[allow(dead_code)] pub(crate) fn repack_zip_with_updates( reader: R, @@ -3012,6 +3021,8 @@ where W: Write + Seek, { let mut pending = BTreeMap::new(); + let updated_timestamp = opc::current_zip_datetime() + .context("determine local ZIP timestamp for rewritten entries")?; for update in updates { let name = normalize_zip_name(&update.name)?; if pending.insert(name.clone(), update).is_some() { @@ -3027,11 +3038,11 @@ where if let Some(update) = pending.remove(&name) { output.start_file( name, - zip::write::FileOptions::default().compression_method(update.compression), + zip_file_options(update.compression, updated_timestamp), )?; output.write_all(&update.bytes)?; } else { - let options = zip::write::FileOptions::default().compression_method(file.compression()); + let options = zip_file_options(file.compression(), file.last_modified()); if file.is_dir() { output.add_directory(name, options)?; } else { @@ -3044,7 +3055,7 @@ where for (name, update) in pending { output.start_file( name, - zip::write::FileOptions::default().compression_method(update.compression), + zip_file_options(update.compression, updated_timestamp), )?; output.write_all(&update.bytes)?; } @@ -3392,6 +3403,29 @@ mod tests { use super::*; use zip::write::FileOptions; + fn test_datetime( + year: u16, + month: u8, + day: u8, + hour: u8, + minute: u8, + second: u8, + ) -> zip::DateTime { + zip::DateTime::from_date_and_time(year, month, day, hour, minute, second) + .expect("valid test timestamp") + } + + fn zip_datetime_parts(dt: zip::DateTime) -> (u16, u8, u8, u8, u8, u8) { + ( + dt.year(), + dt.month(), + dt.day(), + dt.hour(), + dt.minute(), + dt.second(), + ) + } + #[test] fn brace_expansion_supports_nested_lists_and_ranges() { assert_eq!( @@ -3456,6 +3490,59 @@ mod tests { ); } + #[test] + fn repack_zip_preserves_unchanged_entry_timestamps_and_stamps_updates() { + let original_a = test_datetime(2001, 2, 3, 4, 5, 6); + let original_readme = test_datetime(2002, 3, 4, 5, 6, 8); + let input = test_zip_with_timestamps(&[ + ("lib/net8.0/a.dll", b"old".as_slice(), original_a), + ("content/readme.txt", b"text".as_slice(), original_readme), + ]); + let mut output = Cursor::new(Vec::new()); + + repack_zip_with_updates( + Cursor::new(input), + &mut output, + vec![ + ZipEntryUpdate { + name: "lib/net8.0/a.dll".to_owned(), + bytes: b"new".to_vec(), + compression: zip::CompressionMethod::Deflated, + }, + ZipEntryUpdate { + name: ".signature.p7s".to_owned(), + bytes: b"cms".to_vec(), + compression: zip::CompressionMethod::Stored, + }, + ], + ) + .unwrap(); + + let mut archive = zip::ZipArchive::new(Cursor::new(output.into_inner())).unwrap(); + assert_eq!( + zip_datetime_parts( + archive + .by_name("content/readme.txt") + .unwrap() + .last_modified() + ), + zip_datetime_parts(original_readme) + ); + + let updated = archive.by_name("lib/net8.0/a.dll").unwrap().last_modified(); + assert_ne!(zip_datetime_parts(updated), zip_datetime_parts(original_a)); + assert_ne!( + zip_datetime_parts(updated), + zip_datetime_parts(zip::DateTime::default()) + ); + + let signature = archive.by_name(".signature.p7s").unwrap().last_modified(); + assert_ne!( + zip_datetime_parts(signature), + zip_datetime_parts(zip::DateTime::default()) + ); + } + #[test] fn repack_zip_rejects_unsafe_update_path() { let input = test_zip(&[("file.txt", b"text".as_slice())]); @@ -3474,12 +3561,21 @@ mod tests { } fn test_zip(entries: &[(&str, &[u8])]) -> Vec { + test_zip_with_timestamps( + &entries + .iter() + .map(|(name, bytes)| (*name, *bytes, zip::DateTime::default())) + .collect::>(), + ) + } + + fn test_zip_with_timestamps(entries: &[(&str, &[u8], zip::DateTime)]) -> Vec { let mut out = Cursor::new(Vec::new()); { let mut writer = zip::ZipWriter::new(&mut out); - for (name, bytes) in entries { + for (name, bytes, modified) in entries { writer - .start_file(*name, FileOptions::default()) + .start_file(*name, FileOptions::default().last_modified_time(*modified)) .expect("start zip entry"); writer.write_all(bytes).expect("write zip entry"); }