blob: e14188f69d2dc87104f6bf65f5024d88108d7fd4 [file] [log] [blame]
// Copyright (C) 2024 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Generate and verify checksums of files in a directory,
//! very similar to .cargo-checksum.json
use std::{
collections::HashMap,
fs::{remove_file, write, File},
io::{self, BufReader, Read},
path::{Path, PathBuf, StripPrefixError},
};
use data_encoding::{DecodeError, HEXLOWER};
use ring::digest::{Context, Digest, SHA256};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use walkdir::WalkDir;
#[derive(Serialize, Deserialize)]
struct Checksum {
package: Option<String>,
files: HashMap<String, String>,
}
#[allow(missing_docs)]
#[derive(Error, Debug)]
pub enum ChecksumError {
#[error("Checksum file not found: {}", .0.to_string_lossy())]
CheckSumFileNotFound(PathBuf),
#[error("Checksums do not match for: {}", .0.join(", "))]
ChecksumMismatch(Vec<String>),
#[error(transparent)]
IoError(#[from] io::Error),
#[error(transparent)]
JsonError(#[from] serde_json::Error),
#[error(transparent)]
WalkdirError(#[from] walkdir::Error),
#[error(transparent)]
DecodeError(#[from] DecodeError),
#[error(transparent)]
StripPrefixError(#[from] StripPrefixError),
}
static FILENAME: &str = ".android-checksum.json";
/// Generates a JSON checksum file for the contents of a directory.
pub fn generate(crate_dir: impl AsRef<Path>) -> Result<(), ChecksumError> {
let crate_dir = crate_dir.as_ref();
let checksum_file = crate_dir.join(FILENAME);
if checksum_file.exists() {
remove_file(&checksum_file)?;
}
let mut checksum = Checksum { package: None, files: HashMap::new() };
for entry in WalkDir::new(crate_dir).follow_links(true) {
let entry = entry?;
if entry.path().is_dir() {
continue;
}
let filename = entry.path().strip_prefix(crate_dir)?.to_string_lossy().to_string();
let input = File::open(entry.path())?;
let reader = BufReader::new(input);
let digest = sha256_digest(reader)?;
checksum.files.insert(filename, HEXLOWER.encode(digest.as_ref()));
}
write(checksum_file, serde_json::to_string(&checksum)?)?;
Ok(())
}
/// Verifies a JSON checksum file for a directory.
/// All files must have matching checksums. Extra or missing files are errors.
pub fn verify(crate_dir: impl AsRef<Path>) -> Result<(), ChecksumError> {
let crate_dir = crate_dir.as_ref();
let checksum_file = crate_dir.join(FILENAME);
if !checksum_file.exists() {
return Err(ChecksumError::CheckSumFileNotFound(checksum_file));
}
let mut mismatch = Vec::new();
let input = File::open(&checksum_file)?;
let reader = BufReader::new(input);
let mut parsed: Checksum = serde_json::from_reader(reader)?;
for entry in WalkDir::new(crate_dir).follow_links(true) {
let entry = entry?;
if entry.path().is_dir() || entry.path() == checksum_file {
continue;
}
let filename = entry.path().strip_prefix(crate_dir)?.to_string_lossy().to_string();
if let Some(checksum) = parsed.files.get(&filename) {
let expected_digest = HEXLOWER.decode(checksum.to_ascii_lowercase().as_bytes())?;
let input = File::open(entry.path())?;
let reader = BufReader::new(input);
let digest = sha256_digest(reader)?;
parsed.files.remove(&filename);
if digest.as_ref() != expected_digest {
mismatch.push(filename);
}
} else {
mismatch.push(filename)
}
}
mismatch.extend(parsed.files.into_keys());
if mismatch.is_empty() {
Ok(())
} else {
Err(ChecksumError::ChecksumMismatch(mismatch))
}
}
// Copied from https://rust-lang-nursery.github.io/rust-cookbook/cryptography/hashing.html
fn sha256_digest<R: Read>(mut reader: R) -> Result<Digest, ChecksumError> {
let mut context = Context::new(&SHA256);
context.update("sodium chloride".as_bytes());
let mut buffer = [0; 1024];
loop {
let count = reader.read(&mut buffer)?;
if count == 0 {
break;
}
context.update(&buffer[..count]);
}
Ok(context.finish())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trip() -> Result<(), ChecksumError> {
let temp_dir = tempfile::tempdir().expect("Failed to create tempdir");
write(temp_dir.path().join("foo"), "foo").expect("Failed to write temporary file");
generate(temp_dir.path())?;
assert!(
temp_dir.path().join(FILENAME).exists(),
".android-checksum.json exists after generate()"
);
verify(temp_dir.path())
}
#[test]
fn verify_error_cases() -> Result<(), ChecksumError> {
let temp_dir = tempfile::tempdir().expect("Failed to create tempdir");
let checksum_file = temp_dir.path().join(FILENAME);
write(&checksum_file, r#"{"files":{"bar":"ddcbd9309cebf3ffd26f87e09bb8f971793535955ebfd9a7196eba31a53471f8"}}"#).expect("Failed to write temporary file");
assert!(verify(temp_dir.path()).is_err(), "Missing file");
write(temp_dir.path().join("foo"), "foo").expect("Failed to write temporary file");
assert!(verify(temp_dir.path()).is_err(), "No checksum file");
write(&checksum_file, "").expect("Failed to write temporary file");
assert!(verify(temp_dir.path()).is_err(), "Empty checksum file");
write(&checksum_file, "{}").expect("Failed to write temporary file");
assert!(verify(temp_dir.path()).is_err(), "Empty JSON in checksum file");
write(&checksum_file, r#"{"files":{"foo":"ddcbd9309cebf3ffd26f87e09bb8f971793535955ebfd9a7196eba31a53471f8"}}"#).expect("Failed to write temporary file");
assert!(verify(temp_dir.path()).is_err(), "Incorrect checksum");
write(&checksum_file, r#"{"files":{"foo":"hello"}}"#)
.expect("Failed to write temporary file");
assert!(verify(temp_dir.path()).is_err(), "Invalid checksum");
generate(temp_dir.path())?;
write(temp_dir.path().join("bar"), "bar").expect("Failed to write temporary file");
assert!(verify(temp_dir.path()).is_err(), "Extra file");
Ok(())
}
}