Skip to main content

apk/archive/
writer.rs

1//! Deterministic, aligned archive writing on top of the `zip` crate.
2
3use std::collections::HashSet;
4use std::fs::File;
5use std::io::{self, Read, Seek, Write};
6use std::path::Path;
7
8use zip::write::SimpleFileOptions;
9use zip::{CompressionMethod, DateTime, ZipArchive, ZipWriter};
10
11use super::is_signed_entry;
12use super::{Content, ZipError, ZipLimit};
13use crate::digest::{DigestAlgorithm, EntryHasher};
14use crate::error::{Error, FileOperation};
15use crate::types::ArchivePath;
16
17/// How entry data is compressed.
18#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
19pub enum Compression {
20    Stored,
21    Deflated,
22}
23
24/// How one entry is written.
25#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
26pub struct EntryOptions {
27    pub compression: Compression,
28    /// The alignment of stored data within the file. Compressed data is
29    /// never aligned (it cannot be memory-mapped).
30    pub alignment: u16,
31}
32
33impl EntryOptions {
34    /// Uncompressed with 4-byte alignment, zipalign's default for stored entries.
35    pub const STORED: Self = Self { compression: Compression::Stored, alignment: 4 };
36    /// Deflate-compressed.
37    pub const DEFLATED: Self = Self { compression: Compression::Deflated, alignment: 1 };
38
39    /// Uncompressed and aligned to `page_size`, so Android can map the data
40    /// directly (native libraries with `extractNativeLibs="false"`).
41    pub const fn page_aligned(page_size: u16) -> Self {
42        Self { compression: Compression::Stored, alignment: page_size }
43    }
44
45    /// Stored for formats that are already compressed (as aapt2 does), and
46    /// deflated otherwise.
47    pub fn for_path(path: &ArchivePath) -> Self {
48        const PRECOMPRESSED: &[&str] = &[
49            "jpg", "jpeg", "png", "gif", "webp", "wav", "mp2", "mp3", "ogg", "aac", "mpg", "mpeg", "mid", "midi",
50            "smf", "jet", "rtttl", "imy", "xmf", "mp4", "m4a", "m4v", "3gp", "3gpp", "3g2", "3gpp2", "amr", "awb",
51            "wma", "wmv", "webm", "mkv", "zip", "jar", "apk", "gz", "zst", "br", "xz", "bz2", "7z", "ktx2", "basis",
52        ];
53        match path.extension() {
54            Some(ext) if PRECOMPRESSED.iter().any(|p| p.eq_ignore_ascii_case(ext)) => Self::STORED,
55            _ => Self::DEFLATED,
56        }
57    }
58
59    fn zip_options(self) -> SimpleFileOptions {
60        let (method, alignment) = match self.compression {
61            Compression::Stored => (CompressionMethod::Stored, self.alignment.max(1)),
62            Compression::Deflated => (CompressionMethod::Deflated, 1),
63        };
64        SimpleFileOptions::default()
65            .compression_method(method)
66            .last_modified_time(DateTime::default())
67            .unix_permissions(0o644)
68            .large_file(false)
69            .with_alignment(alignment)
70    }
71}
72
73/// Writes APK entries with reproducible metadata and per-entry alignment.
74///
75/// Entry names are unique, timestamps are fixed at 1980-01-01, and
76/// permissions are `0644`, so identical inputs produce identical archives.
77/// When digesting is enabled, the digest of every entry JAR signing covers
78/// is recorded as it is written.
79pub(crate) struct ApkWriter<W: Write + Seek> {
80    zip: ZipWriter<W>,
81    names: HashSet<String>,
82    digest: Option<DigestAlgorithm>,
83    digests: Vec<(String, Vec<u8>)>,
84}
85
86impl<W: Write + Seek> ApkWriter<W> {
87    pub fn new(sink: W) -> Self {
88        Self { zip: ZipWriter::new(sink), names: HashSet::new(), digest: None, digests: Vec::new() }
89    }
90
91    /// Records a digest of each entry's uncompressed data, for JAR signing
92    /// without re-reading the archive.
93    pub fn digest_entries(mut self, algorithm: DigestAlgorithm) -> Self {
94        self.digest = Some(algorithm);
95        self
96    }
97
98    /// The name and digest of every digested entry written so far.
99    pub fn digests(&self) -> &[(String, Vec<u8>)] {
100        &self.digests
101    }
102
103    /// Adds an entry with the contents of `data`.
104    pub fn add(&mut self, path: &ArchivePath, data: &[u8], options: EntryOptions) -> Result<(), ZipError> {
105        self.add_reader(path, data, options).map(drop)
106    }
107
108    /// Adds an entry streamed from `reader` and returns its size.
109    pub fn add_reader(
110        &mut self,
111        path: &ArchivePath,
112        reader: impl Read,
113        options: EntryOptions,
114    ) -> Result<u64, ZipError> {
115        self.start(path.as_str())?;
116        self.zip.start_file(path.as_str(), options.zip_options())?;
117        self.stream(path.as_str(), reader)
118    }
119
120    /// Adds an entry from in-memory or file [`Content`]. A file must not
121    /// change size while it is copied.
122    pub fn add_content(&mut self, path: &ArchivePath, content: &Content, options: EntryOptions) -> Result<u64, Error> {
123        match content {
124            Content::Bytes(bytes) => Ok(self.add_reader(path, &bytes[..], options)?),
125            Content::File(source) => self.add_file(path, source, options),
126        }
127    }
128
129    fn add_file(&mut self, path: &ArchivePath, source: &Path, options: EntryOptions) -> Result<u64, Error> {
130        let file = File::open(source).map_err(Error::file(FileOperation::Open, source))?;
131        let expected = file.metadata().map_err(Error::file(FileOperation::Inspect, source))?.len();
132        if expected >= u64::from(u32::MAX) {
133            return Err(ZipError::LimitExceeded { limit: ZipLimit::Size }.into());
134        }
135        // Reading one byte past the expected length detects growth.
136        let actual = self.add_reader(path, file.take(expected + 1), options)?;
137        if actual != expected {
138            return Err(ZipError::SourceChanged { name: path.to_string(), expected, actual }.into());
139        }
140        Ok(actual)
141    }
142
143    /// Copies entry `index` of `archive`. Stored data is realigned to
144    /// `alignment`; compressed data is copied without recompressing it.
145    /// Metadata is normalized as for new entries.
146    pub fn copy<R: Read + Seek>(
147        &mut self,
148        archive: &mut ZipArchive<R>,
149        index: usize,
150        alignment: u16,
151    ) -> Result<(), ZipError> {
152        let (name, encrypted, stored) = {
153            let entry = archive.by_index_data(index)?;
154            let name = String::from_utf8(entry.name_raw().to_vec()).map_err(|_| ZipError::NonUtf8Name { index })?;
155            (name, entry.encrypted(), entry.compression() == CompressionMethod::Stored)
156        };
157        if encrypted {
158            return Err(ZipError::EncryptedEntry { name });
159        }
160        // Names end up in JAR manifests, where control characters could forge
161        // attributes, so they must be safe archive paths. As for Android,
162        // only a trailing `/` marks a directory.
163        let directory = name.ends_with('/');
164        ArchivePath::try_new(name.strip_suffix('/').unwrap_or(&name))
165            .map_err(|source| ZipError::UnsafeName { name: name.clone(), source })?;
166        self.start(&name)?;
167        if stored && !directory {
168            let options = EntryOptions { compression: Compression::Stored, alignment }.zip_options();
169            self.zip.start_file(name.as_str(), options)?;
170            // Reading checks the data against its CRC.
171            let data = archive.by_index(index)?;
172            self.stream(&name, data)?;
173            return Ok(());
174        }
175        if let Some(algorithm) = self.digest.filter(|_| is_signed_entry(&name)) {
176            let mut hasher = algorithm.hasher();
177            io::copy(&mut archive.by_index(index)?, &mut hasher)?;
178            self.digests.push((name, hasher.finish()));
179        }
180        let raw = archive.by_index_raw(index)?;
181        self.zip.raw_copy_file_touch(raw, DateTime::default(), Some(0o644))?;
182        Ok(())
183    }
184
185    /// Sets the archive comment.
186    pub fn set_comment(&mut self, comment: &[u8]) -> Result<(), ZipError> {
187        Ok(self.zip.set_raw_comment(comment.into())?)
188    }
189
190    fn start(&mut self, name: &str) -> Result<(), ZipError> {
191        if self.names.len() >= usize::from(u16::MAX) - 1 {
192            return Err(ZipError::LimitExceeded { limit: ZipLimit::EntryCount });
193        }
194        if !self.names.insert(name.to_owned()) {
195            return Err(ZipError::DuplicateEntry { name: name.to_owned() });
196        }
197        Ok(())
198    }
199
200    /// Writes the data of the entry just started, digesting it if enabled.
201    fn stream(&mut self, name: &str, mut reader: impl Read) -> Result<u64, ZipError> {
202        let mut hasher = self.digest.filter(|_| is_signed_entry(name)).map(DigestAlgorithm::hasher);
203        let copied = io::copy(&mut reader, &mut Tee { zip: &mut self.zip, hasher: hasher.as_mut() });
204        let size = match copied {
205            Ok(size) => size,
206            Err(error) => {
207                let _ = self.zip.abort_file();
208                return Err(error.into());
209            }
210        };
211        if let Some(hasher) = hasher {
212            self.digests.push((name.to_owned(), hasher.finish()));
213        }
214        Ok(size)
215    }
216
217    /// Writes the central directory and returns the sink.
218    pub fn finish(self) -> Result<W, ZipError> {
219        Ok(self.zip.finish()?)
220    }
221}
222
223/// Feeds written bytes to the archive and, optionally, a digest.
224struct Tee<'a, W: Write + Seek> {
225    zip: &'a mut ZipWriter<W>,
226    hasher: Option<&'a mut EntryHasher>,
227}
228
229impl<W: Write + Seek> Write for Tee<'_, W> {
230    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
231        let written = self.zip.write(buf)?;
232        if let Some(hasher) = self.hasher.as_deref_mut() {
233            hasher.update(&buf[..written]);
234        }
235        Ok(written)
236    }
237
238    fn flush(&mut self) -> io::Result<()> {
239        self.zip.flush()
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use crate::archive::{ApkLayout, open};
247    use std::io::Cursor;
248
249    fn path(p: &str) -> ArchivePath {
250        ArchivePath::try_new(p).unwrap()
251    }
252
253    fn data_offset(bytes: &[u8], name: &str) -> u64 {
254        let mut archive = ZipArchive::new(Cursor::new(bytes)).unwrap();
255        archive.by_name(name).unwrap().data_start().unwrap()
256    }
257
258    #[test]
259    fn aligns_stored_entries_and_is_reproducible() {
260        let build = || {
261            let mut writer = ApkWriter::new(Cursor::new(Vec::new()));
262            writer.add(&path("AndroidManifest.xml"), b"<manifest/>", EntryOptions::DEFLATED).unwrap();
263            writer.add(&path("resources.arsc"), b"table", EntryOptions::STORED).unwrap();
264            writer.add(&path("lib/arm64-v8a/libmain.so"), &[7; 1000], EntryOptions::page_aligned(16384)).unwrap();
265            writer.finish().unwrap().into_inner()
266        };
267        let bytes = build();
268        assert_eq!(bytes, build(), "identical inputs must produce identical archives");
269        assert_eq!(data_offset(&bytes, "resources.arsc") % 4, 0);
270        assert_eq!(data_offset(&bytes, "lib/arm64-v8a/libmain.so") % 16384, 0);
271        assert_eq!(ApkLayout::read(&mut Cursor::new(&bytes)).unwrap().entry_count(), 3);
272    }
273
274    #[test]
275    fn digests_signed_entries_and_rejects_duplicates() {
276        let mut writer = ApkWriter::new(Cursor::new(Vec::new())).digest_entries(DigestAlgorithm::Sha256);
277        writer.add(&path("a.txt"), b"abc", EntryOptions::DEFLATED).unwrap();
278        writer.add(&path("META-INF/CERT.SF"), b"not digested", EntryOptions::STORED).unwrap();
279        assert!(matches!(
280            writer.add(&path("a.txt"), b"again", EntryOptions::STORED),
281            Err(ZipError::DuplicateEntry { name }) if name == "a.txt"
282        ));
283        assert_eq!(writer.digests(), [("a.txt".to_owned(), DigestAlgorithm::Sha256.digest(b"abc"))]);
284    }
285
286    #[test]
287    fn copies_realign_stored_data_and_keep_compressed_data() {
288        let mut source = ZipWriter::new(Cursor::new(Vec::new()));
289        source
290            .start_file("lib/x86/libm.so", SimpleFileOptions::default().compression_method(CompressionMethod::Stored))
291            .unwrap();
292        source.write_all(&[1; 100]).unwrap();
293        // Preserve the original encoder settings rather than recompressing at the default level.
294        let options =
295            SimpleFileOptions::default().compression_method(CompressionMethod::Deflated).compression_level(Some(1));
296        source.start_file("notes.txt", options).unwrap();
297        source.write_all(&[b'n'; 500]).unwrap();
298        let source = source.finish().unwrap().into_inner();
299        let (mut archive, _) = open(Cursor::new(&source)).unwrap();
300        let mut compressed = Vec::new();
301        archive.by_index_raw(1).unwrap().read_to_end(&mut compressed).unwrap();
302
303        let mut copy = ApkWriter::new(Cursor::new(Vec::new())).digest_entries(DigestAlgorithm::Sha256);
304        copy.copy(&mut archive, 0, 4096).unwrap();
305        copy.copy(&mut archive, 1, 4).unwrap();
306        let digests = copy.digests().to_vec();
307        let copied = copy.finish().unwrap().into_inner();
308
309        assert_eq!(data_offset(&copied, "lib/x86/libm.so") % 4096, 0);
310        let (mut copied_archive, _) = open(Cursor::new(&copied)).unwrap();
311        let mut copied_compressed = Vec::new();
312        copied_archive.by_index_raw(1).unwrap().read_to_end(&mut copied_compressed).unwrap();
313        assert_eq!(copied_compressed, compressed, "deflated bytes changed");
314        let mut text = Vec::new();
315        copied_archive.by_name("notes.txt").unwrap().read_to_end(&mut text).unwrap();
316        assert_eq!(text, [b'n'; 500]);
317        assert_eq!(digests[1], ("notes.txt".to_owned(), DigestAlgorithm::Sha256.digest(&[b'n'; 500])));
318    }
319}