use std::{
collections::HashMap,
fs::{self, File},
io::{self, Read, Seek},
path::{Component, Path, PathBuf},
};
use serde::{Deserialize, Serialize};
use crate::{
assertion::{Assertion, AssertionBase, AssertionCbor},
assertions::{labels::COLLECTION_HASH, AssetType},
asset_handlers::zip_io::{zip_central_directory_range, zip_uri_ranges},
hash_stream_by_alg,
hash_utils::{hash_size_by_alg, verify_stream_by_alg},
utils::mime,
validation_status::{
ASSERTION_COLLECTIONHASH_INCORRECT_FILE_COUNT, ASSERTION_COLLECTIONHASH_INVALID_URI,
ASSERTION_COLLECTIONHASH_MALFORMED,
},
Error, HashRange, Result,
};
const ASSERTION_CREATION_VERSION: usize = 1;
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
pub struct CollectionHash {
pub uris: HashMap<PathBuf, UriHashedDataMap>,
pub alg: String,
#[serde(with = "serde_bytes", default, skip_serializing_if = "Option::is_none")]
pub zip_central_directory_hash: Option<Vec<u8>>,
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
pub struct UriHashedDataMap {
#[serde(with = "serde_bytes", skip_serializing_if = "Option::is_none")]
pub hash: Option<Vec<u8>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub size: Option<u64>,
#[serde(rename = "dc:format", skip_serializing_if = "Option::is_none")]
pub dc_format: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub data_types: Option<Vec<AssetType>>,
}
impl CollectionHash {
pub const LABEL: &'static str = COLLECTION_HASH;
pub fn new(alg: String) -> Self {
Self {
uris: HashMap::new(),
alg,
zip_central_directory_hash: None,
}
}
pub fn add_file(&mut self, path: PathBuf) -> Result<()> {
if !path.is_file() {
return Err(Error::BadParam(format!(
"collection hashes must only contain files, got `{}`",
path.display()
)));
}
for component in path.components() {
match component {
Component::CurDir | Component::ParentDir => {
return Err(Error::BadParam(format!(
"URI `{}` must not contain relative components: `.` nor `..`",
path.display()
)));
}
Component::RootDir | Component::Prefix(_) => {
return Err(Error::BadParam(format!(
"URI `{}` must be relative, not absolute",
path.display()
)));
}
_ => {}
}
}
let format = mime::mime_from_path(&path);
let metadata = fs::metadata(&path)?;
self.uris.insert(
path,
UriHashedDataMap {
hash: None,
size: Some(metadata.len()),
dc_format: format,
data_types: None,
},
);
Ok(())
}
pub fn gen_hash(&mut self, base_path: &Path) -> Result<()> {
if base_path.is_file() {
return Err(Error::BadParam(format!(
"base path must be a directory, got `{}`",
base_path.display()
)));
}
for (uri, uri_map) in &mut self.uris {
Self::validate_uri(uri)?;
let mut file = File::open(base_path.join(uri)).map_err(|err| match err.kind() {
io::ErrorKind::NotFound => {
Error::C2PAValidation(ASSERTION_COLLECTIONHASH_INCORRECT_FILE_COUNT.to_string())
}
_ => Error::IoError(err),
})?;
let file_len = match uri_map.size {
Some(file_len) => file_len,
None => file.metadata()?.len(),
};
uri_map.hash = Some(hash_stream_by_alg(
&self.alg,
&mut file,
Some(vec![HashRange::new(0, file_len)]),
false,
)?);
}
Ok(())
}
pub fn verify_hash(&self, base_path: &Path) -> Result<()> {
if base_path.is_file() {
return Err(Error::BadParam(format!(
"base path must be a directory, got `{}`",
base_path.display()
)));
}
for (uri, uri_map) in &self.uris {
let hash = uri_map.hash.as_ref().ok_or_else(|| {
Error::C2PAValidation(ASSERTION_COLLECTIONHASH_MALFORMED.to_string())
})?;
Self::validate_uri(uri)?;
let mut file = File::open(base_path.join(uri)).map_err(|err| match err.kind() {
io::ErrorKind::NotFound => {
Error::C2PAValidation(ASSERTION_COLLECTIONHASH_INCORRECT_FILE_COUNT.to_string())
}
_ => Error::IoError(err),
})?;
let file_len = file.metadata()?.len();
if !verify_stream_by_alg(
&self.alg,
hash,
&mut file,
Some(vec![HashRange::new(0, file_len)]),
false,
) {
return Err(Error::HashMismatch(format!(
"hash for {} does not match",
uri.display()
)));
}
}
Ok(())
}
pub fn gen_hash_from_zip_stream<R>(&mut self, stream: &mut R) -> Result<()>
where
R: Read + Seek + ?Sized,
{
self.gen_zip_central_directory_hash(stream)?;
self.gen_zip_uri_hashes(stream)?;
Ok(())
}
pub fn gen_zip_uri_hashes<R>(&mut self, stream: &mut R) -> Result<()>
where
R: Read + Seek + ?Sized,
{
self.uris = HashMap::new();
for (path, hash_range) in zip_uri_ranges(stream)? {
let path = PathBuf::from(path.to_string_lossy().replace('\\', "/"));
let hash =
hash_stream_by_alg(&self.alg, stream, Some(vec![hash_range.clone()]), false)?;
let format = mime::mime_from_path(&path);
self.uris.insert(
path,
UriHashedDataMap {
hash: Some(hash),
size: Some(hash_range.length()),
dc_format: format,
data_types: None,
},
);
}
Ok(())
}
pub fn gen_zip_central_directory_hash<R>(&mut self, stream: &mut R) -> Result<()>
where
R: Read + Seek + ?Sized,
{
let zip_central_directory_inclusions = zip_central_directory_range(stream)?;
self.zip_central_directory_hash = Some(hash_stream_by_alg(
&self.alg,
stream,
Some(zip_central_directory_inclusions),
false,
)?);
Ok(())
}
pub fn set_placeholder_zip_central_directory_hash(&mut self) -> Result<()> {
self.zip_central_directory_hash = Some(vec![0u8; hash_size_by_alg(&self.alg)?]);
Ok(())
}
pub fn verify_zip_stream_hash<R>(&self, stream: &mut R, alg: Option<&str>) -> Result<()>
where
R: Read + Seek + ?Sized,
{
let alg = alg.unwrap_or(self.alg.as_str());
let zip_central_directory_hash = self
.zip_central_directory_hash
.as_ref()
.ok_or_else(|| Error::C2PAValidation(ASSERTION_COLLECTIONHASH_MALFORMED.to_string()))?;
let zip_central_directory_hash_range = zip_central_directory_range(stream)?;
if !verify_stream_by_alg(
alg,
zip_central_directory_hash,
stream,
Some(zip_central_directory_hash_range),
false,
) {
return Err(Error::HashMismatch(
"hashes do not match for ZIP central directory".to_owned(),
));
}
let uri_ranges = zip_uri_ranges(stream)?;
for (path, uri_map) in &self.uris {
let path = PathBuf::from(path.to_string_lossy().replace('\\', "/"));
Self::validate_uri(&path)?;
let hash = uri_map.hash.as_ref().ok_or_else(|| {
Error::C2PAValidation(ASSERTION_COLLECTIONHASH_MALFORMED.to_string())
})?;
let hash_range = uri_ranges.get(&path).cloned().ok_or_else(|| {
Error::C2PAValidation(ASSERTION_COLLECTIONHASH_INCORRECT_FILE_COUNT.to_string())
})?;
if !verify_stream_by_alg(alg, hash, stream, Some(vec![hash_range]), false) {
return Err(Error::HashMismatch(format!(
"hash for {} does not match",
path.display()
)));
}
}
Ok(())
}
fn validate_uri(uri: &Path) -> Result<()> {
if uri.components().any(|component| {
matches!(
component,
Component::CurDir
| Component::ParentDir
| Component::RootDir
| Component::Prefix(_)
)
}) {
return Err(Error::C2PAValidation(
ASSERTION_COLLECTIONHASH_INVALID_URI.to_string(),
));
}
Ok(())
}
}
impl AssertionBase for CollectionHash {
const LABEL: &'static str = Self::LABEL;
const VERSION: Option<usize> = Some(ASSERTION_CREATION_VERSION);
fn from_assertion(assertion: &Assertion) -> Result<Self> {
Self::from_cbor_assertion(assertion)
}
fn to_assertion(&self) -> Result<Assertion> {
Self::to_cbor_assertion(self)
}
}
impl AssertionCbor for CollectionHash {}
#[cfg(test)]
mod tests {
use std::io::Cursor;
#[cfg(not(target_arch = "wasm32"))]
use tempfile::TempDir;
use super::*;
const ZIP_SAMPLE1: &[u8] = include_bytes!("../../tests/fixtures/sample1.zip");
fn gen_zip_collection_hash() -> Result<CollectionHash> {
let mut stream = Cursor::new(ZIP_SAMPLE1);
let mut collection = CollectionHash::new("sha256".to_owned());
collection.gen_hash_from_zip_stream(&mut stream)?;
Ok(collection)
}
#[cfg(not(target_arch = "wasm32"))]
fn gen_dir_collection_hash() -> Result<(TempDir, CollectionHash)> {
let dir = tempfile::tempdir()?;
fs::write(dir.path().join("a.txt"), b"hello")?;
fs::create_dir(dir.path().join("sub"))?;
fs::write(dir.path().join("sub").join("b.txt"), b"world")?;
let mut collection = CollectionHash::new("sha256".to_owned());
for uri in ["a.txt", "sub/b.txt"] {
collection.uris.insert(
PathBuf::from(uri),
UriHashedDataMap {
hash: None,
size: None,
dc_format: None,
data_types: None,
},
);
}
collection.gen_hash(dir.path())?;
Ok((dir, collection))
}
#[test]
fn test_verify_zip_stream_hash_roundtrip() -> Result<()> {
let collection = gen_zip_collection_hash()?;
let restored = CollectionHash::from_assertion(&collection.to_assertion()?)?;
let mut stream = Cursor::new(ZIP_SAMPLE1);
restored.verify_zip_stream_hash(&mut stream, None)?;
Ok(())
}
#[test]
fn test_zip_uri_hash_separators_do_not_depend_on_operating_system() -> Result<()> {
let nested = PathBuf::from("sample1/test1/test1.txt");
for possible_separator in ['/', '\\'] {
let mut hash_collection = gen_zip_collection_hash()?;
hash_collection.uris = std::mem::take(&mut hash_collection.uris)
.into_iter()
.map(|(path, uri_map)| {
(
PathBuf::from(
path.to_string_lossy()
.replace('/', &possible_separator.to_string()),
),
uri_map,
)
})
.collect();
let mut zip_sample_one_stream = Cursor::new(ZIP_SAMPLE1);
hash_collection.verify_zip_stream_hash(&mut zip_sample_one_stream, None)?;
let mut hash_collection = gen_zip_collection_hash()?;
let mut corrupted = false;
hash_collection.uris = std::mem::take(&mut hash_collection.uris)
.into_iter()
.map(|(path, mut uri_map)| {
if path == nested {
uri_map.hash = Some(vec![0; 32]);
corrupted = true;
}
(
PathBuf::from(
path.to_string_lossy()
.replace('/', &possible_separator.to_string()),
),
uri_map,
)
})
.collect();
assert!(corrupted);
let mut zip_sample_one_stream = Cursor::new(ZIP_SAMPLE1);
assert!(matches!(
hash_collection.verify_zip_stream_hash(&mut zip_sample_one_stream, None),
Err(Error::HashMismatch(_))
));
}
Ok(())
}
#[test]
fn test_verify_zip_stream_hash_mismatch() -> Result<()> {
let mut collection = gen_zip_collection_hash()?;
if let Some(entry) = collection.uris.values_mut().next() {
entry.hash = Some(vec![0; 32]);
}
let mut stream = Cursor::new(ZIP_SAMPLE1);
assert!(matches!(
collection.verify_zip_stream_hash(&mut stream, None),
Err(Error::HashMismatch(_))
));
Ok(())
}
#[test]
fn test_verify_zip_missing_central_directory_hash_is_malformed() -> Result<()> {
let mut collection = gen_zip_collection_hash()?;
collection.zip_central_directory_hash = None;
let mut stream = Cursor::new(ZIP_SAMPLE1);
assert!(matches!(
collection.verify_zip_stream_hash(&mut stream, None),
Err(Error::C2PAValidation(code)) if code == ASSERTION_COLLECTIONHASH_MALFORMED
));
Ok(())
}
#[test]
fn test_verify_zip_invalid_uri() -> Result<()> {
let mut collection = gen_zip_collection_hash()?;
collection.uris.insert(
PathBuf::from("../evil.txt"),
UriHashedDataMap {
hash: Some(vec![0; 32]),
size: Some(0),
dc_format: None,
data_types: None,
},
);
let mut stream = Cursor::new(ZIP_SAMPLE1);
assert!(matches!(
collection.verify_zip_stream_hash(&mut stream, None),
Err(Error::C2PAValidation(code)) if code == ASSERTION_COLLECTIONHASH_INVALID_URI
));
Ok(())
}
#[test]
fn test_verify_zip_absolute_uri() -> Result<()> {
for evil in ["/etc/passwd", "/tmp/evil.txt"] {
let mut collection = gen_zip_collection_hash()?;
collection.uris.insert(
PathBuf::from(evil),
UriHashedDataMap {
hash: Some(vec![0; 32]),
size: Some(0),
dc_format: None,
data_types: None,
},
);
let mut stream = Cursor::new(ZIP_SAMPLE1);
assert!(matches!(
collection.verify_zip_stream_hash(&mut stream, None),
Err(Error::C2PAValidation(code)) if code == ASSERTION_COLLECTIONHASH_INVALID_URI
));
}
Ok(())
}
#[test]
fn test_verify_zip_incorrect_file_count() -> Result<()> {
let mut collection = gen_zip_collection_hash()?;
collection.uris.insert(
PathBuf::from("sample1/not_in_zip.txt"),
UriHashedDataMap {
hash: Some(vec![0; 32]),
size: Some(0),
dc_format: None,
data_types: None,
},
);
let mut stream = Cursor::new(ZIP_SAMPLE1);
assert!(matches!(
collection.verify_zip_stream_hash(&mut stream, None),
Err(Error::C2PAValidation(code)) if code == ASSERTION_COLLECTIONHASH_INCORRECT_FILE_COUNT
));
Ok(())
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn test_directory_collection_roundtrip() -> Result<()> {
let (dir, collection) = gen_dir_collection_hash()?;
collection.verify_hash(dir.path())?;
Ok(())
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn test_directory_verify_mismatch() -> Result<()> {
let (dir, collection) = gen_dir_collection_hash()?;
fs::write(dir.path().join("a.txt"), b"tampered")?;
assert!(matches!(
collection.verify_hash(dir.path()),
Err(Error::HashMismatch(_))
));
Ok(())
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn test_directory_verify_incorrect_file_count() -> Result<()> {
let (dir, collection) = gen_dir_collection_hash()?;
fs::remove_file(dir.path().join("a.txt"))?;
assert!(matches!(
collection.verify_hash(dir.path()),
Err(Error::C2PAValidation(code)) if code == ASSERTION_COLLECTIONHASH_INCORRECT_FILE_COUNT
));
Ok(())
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn test_directory_verify_missing_hash_is_malformed() -> Result<()> {
let (dir, mut collection) = gen_dir_collection_hash()?;
if let Some(entry) = collection.uris.values_mut().next() {
entry.hash = None;
}
assert!(matches!(
collection.verify_hash(dir.path()),
Err(Error::C2PAValidation(code)) if code == ASSERTION_COLLECTIONHASH_MALFORMED
));
Ok(())
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn test_base_path_must_be_a_directory() -> Result<()> {
let dir = tempfile::tempdir()?;
let file = dir.path().join("a.txt");
fs::write(&file, b"hello")?;
let mut collection = CollectionHash::new("sha256".to_owned());
assert!(matches!(
collection.gen_hash(&file),
Err(Error::BadParam(_))
));
assert!(matches!(
collection.verify_hash(&file),
Err(Error::BadParam(_))
));
Ok(())
}
#[test]
fn test_zip_hash() -> Result<()> {
let mut stream = Cursor::new(ZIP_SAMPLE1);
let mut collection = CollectionHash {
uris: HashMap::new(),
alg: "sha256".to_owned(),
zip_central_directory_hash: None,
};
collection.gen_hash_from_zip_stream(&mut stream)?;
assert_eq!(
collection.zip_central_directory_hash,
Some(vec![
103, 27, 141, 219, 82, 200, 254, 44, 155, 221, 183, 146, 193, 94, 154, 77, 133, 93,
148, 88, 160, 123, 224, 170, 61, 140, 13, 2, 153, 86, 225, 231
])
);
assert_eq!(
collection.uris.get(Path::new("sample1/test1.txt")),
Some(&UriHashedDataMap {
hash: Some(vec![
39, 147, 91, 240, 68, 172, 194, 43, 70, 207, 141, 151, 141, 239, 180, 17, 170,
106, 248, 168, 169, 245, 207, 172, 29, 204, 80, 155, 37, 30, 186, 60
]),
size: Some(47),
dc_format: None,
data_types: None,
})
);
assert_eq!(
collection.uris.get(Path::new("sample1/test1/test1.txt")),
Some(&UriHashedDataMap {
hash: Some(vec![
136, 103, 106, 251, 180, 19, 60, 244, 42, 171, 44, 215, 65, 252, 59, 127, 84,
63, 175, 25, 6, 118, 200, 12, 188, 128, 67, 78, 249, 182, 242, 156
]),
size: Some(57),
dc_format: None,
data_types: None,
})
);
assert_eq!(
collection.uris.get(Path::new("sample1/test1/test2.txt")),
Some(&UriHashedDataMap {
hash: Some(vec![
164, 100, 0, 41, 229, 201, 3, 228, 30, 254, 72, 205, 60, 70, 104, 78, 121, 21,
187, 230, 19, 242, 52, 212, 181, 104, 99, 179, 177, 81, 150, 33
]),
size: Some(53),
dc_format: None,
data_types: None,
})
);
assert_eq!(
collection.uris.get(Path::new("sample1/test1/test3.txt")),
Some(&UriHashedDataMap {
hash: Some(vec![
129, 96, 58, 105, 119, 67, 2, 71, 77, 151, 99, 201, 192, 32, 213, 77, 19, 22,
106, 204, 158, 142, 176, 247, 251, 174, 145, 243, 12, 22, 151, 116
]),
size: Some(68),
dc_format: None,
data_types: None,
})
);
assert_eq!(
collection.uris.get(Path::new("sample1/test2.txt")),
Some(&UriHashedDataMap {
hash: Some(vec![
118, 254, 231, 173, 246, 184, 45, 104, 69, 72, 23, 21, 177, 202, 184, 241, 162,
36, 28, 55, 23, 62, 109, 143, 182, 233, 99, 144, 23, 139, 9, 118
]),
size: Some(56),
dc_format: None,
data_types: None,
})
);
assert_eq!(
collection.uris.get(Path::new("sample1/test1")),
Some(&UriHashedDataMap {
hash: Some(vec![
199, 134, 99, 174, 54, 178, 120, 199, 28, 217, 185, 86, 200, 187, 5, 90, 182,
134, 28, 246, 5, 219, 189, 243, 221, 164, 149, 198, 146, 113, 183, 219
]),
size: Some(44),
dc_format: None,
data_types: None,
})
);
assert_eq!(
collection.uris.get(Path::new("sample1/test2")),
Some(&UriHashedDataMap {
hash: Some(vec![
9, 26, 48, 179, 113, 110, 125, 60, 147, 43, 208, 136, 111, 196, 48, 16, 226,
74, 37, 100, 184, 237, 36, 219, 220, 156, 240, 35, 129, 155, 171, 14
]),
size: Some(44),
dc_format: None,
data_types: None,
})
);
assert_eq!(collection.uris.len(), 7);
Ok(())
}
}