Skip to main content

c2pa_zip/
binding.rs

1// Copyright 2026 WritersLogic. All rights reserved.
2// Licensed under the Apache License, Version 2.0 or the MIT license,
3// at your option.
4
5//! Inputs for the ZIP collection data hash.
6//!
7//! A ZIP asset is bound with a `c2pa.hash.collection.data` assertion: one entry
8//! per archive member, plus the additional `zip_central_directory_hash` field
9//! that covers the archive's own directory. Without that extra field the
10//! assertion binds only the members it lists, so an entry added after signing
11//! would leave the manifest valid.
12//!
13//! This module supplies the *byte ranges* the specification says to hash, and
14//! deliberately does not hash them. Locating those ranges is ZIP parsing, which
15//! is this crate's job; choosing and running a digest is the caller's, and
16//! keeping it that way is what lets this crate stay dependency-free.
17
18use crate::error::Error;
19use crate::zip::{self, ZIP_MANIFEST_PATH};
20
21/// A member of the archive, and the byte range of its stored content.
22///
23/// The `name` is the entry's path within the archive, which is the value the
24/// `uri` field of the corresponding collection entry takes.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct Member {
27    pub name: String,
28    pub content: std::ops::Range<usize>,
29}
30
31/// Every archive member except the C2PA Manifest Store entry, in archive order.
32///
33/// These are the members the collection data hash covers. The manifest entry is
34/// excluded because it cannot hash itself.
35pub fn collection_members(zip: &[u8]) -> Result<Vec<Member>, Error> {
36    Ok(zip::member_ranges(zip)?
37        .into_iter()
38        .filter(|(name, _)| name != ZIP_MANIFEST_PATH)
39        .map(|(name, content)| Member { name, content })
40        .collect())
41}
42
43/// The byte range covered by `zip_central_directory_hash`: every central
44/// directory header together with the end-of-central-directory record.
45///
46/// The specification defines this field as a hash "of every central directory
47/// header in the ZIP Central Directory as well as the end of central directory
48/// record". Those are contiguous, so the coverage is a single range running
49/// from the first header to the end of the archive.
50pub fn central_directory_range(zip: &[u8]) -> Result<std::ops::Range<usize>, Error> {
51    zip::central_directory_range(zip)
52}
53
54#[cfg(test)]
55mod tests {
56    use super::*;
57    use crate::writer::embed_manifest;
58    use crate::zip::tests::build_zip;
59
60    const MANIFEST: &[u8] = b"pretend-manifest-store";
61
62    fn fixture() -> Vec<u8> {
63        build_zip(&[
64            ("mimetype", b"application/epub+zip"),
65            ("META-INF/container.xml", b"<container/>"),
66            ("OEBPS/content.opf", b"<package/>"),
67        ])
68    }
69
70    #[test]
71    fn members_exclude_the_manifest_entry() {
72        let signed = embed_manifest(&fixture(), MANIFEST).unwrap();
73        let members = collection_members(&signed).unwrap();
74        let names: Vec<&str> = members.iter().map(|m| m.name.as_str()).collect();
75        assert_eq!(
76            names,
77            ["mimetype", "META-INF/container.xml", "OEBPS/content.opf"]
78        );
79        assert!(!names.contains(&ZIP_MANIFEST_PATH));
80    }
81
82    #[test]
83    fn member_ranges_address_the_stored_content() {
84        let zip = fixture();
85        let members = collection_members(&zip).unwrap();
86        let mimetype = members.iter().find(|m| m.name == "mimetype").unwrap();
87        assert_eq!(&zip[mimetype.content.clone()], b"application/epub+zip");
88    }
89
90    #[test]
91    fn central_directory_range_reaches_the_end_of_the_archive() {
92        let zip = fixture();
93        let range = central_directory_range(&zip).unwrap();
94        assert_eq!(range.end, zip.len(), "the EOCD record is the last thing");
95        // The range starts on a central directory header signature.
96        assert_eq!(&zip[range.start..range.start + 4], b"PK\x01\x02");
97        // And contains the EOCD signature.
98        assert!(zip[range.clone()].windows(4).any(|w| w == b"PK\x05\x06"));
99    }
100
101    #[test]
102    fn adding_an_entry_changes_the_central_directory_coverage() {
103        let zip = fixture();
104        let before = &zip[central_directory_range(&zip).unwrap()].to_vec();
105        let signed = embed_manifest(&zip, MANIFEST).unwrap();
106        let after = &signed[central_directory_range(&signed).unwrap()].to_vec();
107        // This is the property the field exists for: an entry appended after
108        // signing cannot leave the directory coverage unchanged.
109        assert_ne!(before, after);
110    }
111
112    #[test]
113    fn every_member_range_is_inside_the_archive_and_before_the_directory() {
114        let signed = embed_manifest(&fixture(), MANIFEST).unwrap();
115        let cd = central_directory_range(&signed).unwrap();
116        for m in collection_members(&signed).unwrap() {
117            assert!(m.content.end <= signed.len(), "{} past end", m.name);
118            assert!(m.content.start <= m.content.end, "{} inverted", m.name);
119            assert!(
120                m.content.end <= cd.start,
121                "{} overlaps the directory",
122                m.name
123            );
124        }
125    }
126
127    #[test]
128    fn a_truncated_archive_is_rejected_rather_than_panicking() {
129        let zip = fixture();
130        for cut in [0usize, 1, 8, zip.len() / 2, zip.len() - 1] {
131            let truncated = &zip[..cut];
132            assert!(collection_members(truncated).is_err() || truncated.is_empty());
133            assert!(central_directory_range(truncated).is_err() || truncated.is_empty());
134        }
135    }
136}