#![allow(dead_code)] use std::{ error::Error, fs, io::{Cursor, Read}, path::{Path, PathBuf}, }; use flate2::read::ZlibDecoder; use pgp::{ composed::{Deserializable, DetachedSignature, Message, SignedPublicKey, SignedSecretKey}, types::{KeyDetails as _, Password}, }; use serde::Deserialize; use sha1::{Digest as _, Sha1}; use sha2::Sha256; use tempfile::TempDir; pub type TestResult = Result>; #[derive(Debug, Deserialize)] pub struct UpstreamManifest { pub format: u8, pub project: Vec, } #[derive(Debug, Deserialize)] pub struct UpstreamProject { pub name: String, pub version: String, pub reported_version: String, pub tag: String, pub commit: String, pub source: String, pub behavior_source: String, pub license: String, } #[derive(Debug, Deserialize)] pub struct BehaviorManifest { pub format: u8, #[serde(rename = "case")] pub cases: Vec, } #[derive(Debug, Deserialize)] pub struct BehaviorCase { pub id: String, pub area: String, pub argv: Vec, pub stdin: String, pub tty: bool, pub status: i32, pub outcome: String, pub recipients: Vec, pub commit: String, pub random_stream: Option, pub clock: Option, } #[derive(Debug, Deserialize)] pub struct GeneratedManifest { pub format: u8, pub generated_by: String, pub fixture_seed: String, pub keys: Vec, pub entries: Vec, pub repositories: Vec, } #[derive(Debug, Deserialize)] pub struct KeyRecord { pub name: String, pub user_id: String, pub primary_fingerprint: String, pub encryption_subkey_fingerprint: String, pub passphrase: String, pub public_armor: String, pub public_binary: String, pub secret_armor: String, pub secret_binary: String, } #[derive(Debug, Deserialize)] pub struct EntryRecord { pub store: String, pub path: String, pub plaintext: String, pub recipients: Vec, pub ciphertext_sha256: String, } #[derive(Debug, Deserialize)] pub struct RepositoryRecord { pub name: String, pub template: String, pub head: String, pub commits: Vec, } pub struct FixtureSet { root: PathBuf, pub upstream: UpstreamManifest, pub behavior: BehaviorManifest, pub generated: GeneratedManifest, pub layouts: toml::Value, } impl FixtureSet { pub fn load() -> TestResult { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/compatibility"); let upstream = parse_toml(root.join("upstream.toml"))?; let behavior = parse_toml(root.join("behavior.toml"))?; let generated = parse_toml(root.join("generated.toml"))?; let layouts = parse_toml(root.join("layouts.toml"))?; Ok(Self { root, upstream, behavior, generated, layouts, }) } pub fn path(&self, relative: impl AsRef) -> PathBuf { self.root.join(relative) } pub fn read(&self, relative: impl AsRef) -> TestResult> { Ok(fs::read(self.path(relative))?) } pub fn key(&self, name: &str) -> TestResult<&KeyRecord> { self.generated .keys .iter() .find(|key| key.name == name) .ok_or_else(|| test_error(format!("missing fixture key {name}"))) } pub fn key_by_fingerprint(&self, fingerprint: &str) -> TestResult<&KeyRecord> { self.generated .keys .iter() .find(|key| key.primary_fingerprint == fingerprint) .ok_or_else(|| test_error(format!("missing fixture key {fingerprint}"))) } pub fn parse_secret_key(&self, key: &KeyRecord) -> TestResult { let bytes = self.read(&key.secret_armor)?; let (secret, _) = SignedSecretKey::from_armor_single(Cursor::new(bytes))?; Ok(secret) } pub fn parse_public_key(&self, key: &KeyRecord) -> TestResult { let bytes = self.read(&key.public_armor)?; let (public, _) = SignedPublicKey::from_armor_single(Cursor::new(bytes))?; Ok(public) } pub fn materialize_store(&self, name: &str) -> TestResult { let temporary = tempfile::tempdir()?; copy_tree(&self.path(Path::new("stores").join(name)), temporary.path())?; Ok(temporary) } pub fn materialize_repository(&self, name: &str) -> TestResult { let repository = self .generated .repositories .iter() .find(|repository| repository.name == name) .ok_or_else(|| test_error(format!("missing repository fixture {name}")))?; let temporary = tempfile::tempdir()?; copy_tree(&self.path(&repository.template), temporary.path())?; Ok(temporary) } } pub fn decrypt_entry( fixture: &FixtureSet, entry: &EntryRecord, key: &KeyRecord, ) -> TestResult> { decrypt_entry_with_passphrase(fixture, entry, key, &key.passphrase) } pub fn decrypt_entry_with_passphrase( fixture: &FixtureSet, entry: &EntryRecord, key: &KeyRecord, passphrase: &str, ) -> TestResult> { let secret = fixture.parse_secret_key(key)?; let ciphertext = fixture.read(Path::new("stores").join(&entry.store).join(&entry.path))?; let message = Message::from_bytes(Cursor::new(ciphertext))?; let mut decrypted = message.decrypt(&Password::from(passphrase), &secret)?; Ok(decrypted.as_data_vec()?) } pub fn validate_key_record(fixture: &FixtureSet, key: &KeyRecord) -> TestResult { let secret = fixture.parse_secret_key(key)?; let public = fixture.parse_public_key(key)?; secret.verify_bindings()?; public.verify_bindings()?; let primary = format!("{:X}", secret.primary_key.fingerprint()); let encryption = format!("{:X}", secret.secret_subkeys[0].fingerprint()); if primary != key.primary_fingerprint || encryption != key.encryption_subkey_fingerprint { return Err(test_error(format!("fingerprint mismatch for {}", key.name))); } if secret.to_public_key() != public { return Err(test_error(format!( "public and secret fixture mismatch for {}", key.name ))); } let binary_secret = fixture.read(&key.secret_binary)?; let binary_public = fixture.read(&key.public_binary)?; let parsed_secret = SignedSecretKey::from_bytes(Cursor::new(binary_secret))?; let parsed_public = SignedPublicKey::from_bytes(Cursor::new(binary_public))?; if parsed_secret != secret || parsed_public != public { return Err(test_error(format!( "armored and binary key mismatch for {}", key.name ))); } Ok(()) } pub fn validate_entry_record(fixture: &FixtureSet, entry: &EntryRecord) -> TestResult { let ciphertext = fixture.read(Path::new("stores").join(&entry.store).join(&entry.path))?; let digest = hex::encode(Sha256::digest(&ciphertext)); if digest != entry.ciphertext_sha256 { return Err(test_error(format!( "ciphertext digest mismatch for {}/{}", entry.store, entry.path ))); } let expected = fixture.read( Path::new("expected") .join(&entry.store) .join(&entry.plaintext), )?; for recipient in &entry.recipients { let key = fixture.key_by_fingerprint(recipient)?; let actual = decrypt_entry(fixture, entry, key)?; if actual != expected { return Err(test_error(format!( "plaintext mismatch for {}/{} and recipient {}", entry.store, entry.path, key.name ))); } } Ok(()) } pub fn validate_recipient_signature(fixture: &FixtureSet, store: &str, signer: &str) -> TestResult { let key = fixture.key(signer)?; let public = fixture.parse_public_key(key)?; let recipient_path = Path::new("stores").join(store).join(".gpg-id"); let signature_path = Path::new("stores").join(store).join(".gpg-id.sig"); let contents = fixture.read(recipient_path)?; let signature = DetachedSignature::from_bytes(Cursor::new(fixture.read(signature_path)?))?; signature.verify(&public.primary_key, &contents)?; Ok(()) } pub fn validate_repository(fixture: &FixtureSet, repository: &RepositoryRecord) -> TestResult { let root = fixture.path(&repository.template); let head_ref = fs::read_to_string(root.join("HEAD"))?; if head_ref != "ref: refs/heads/main\n" { return Err(test_error(format!( "unexpected HEAD for {}", repository.name ))); } let head = fs::read_to_string(root.join("refs/heads/main"))? .trim() .to_owned(); if head != repository.head { return Err(test_error(format!("head mismatch for {}", repository.name))); } for path in loose_object_paths(&root.join("objects"))? { let canonical = inflate(&path)?; let actual = hex::encode(Sha1::digest(&canonical)); let directory = path .parent() .and_then(Path::file_name) .and_then(|name| name.to_str()) .ok_or_else(|| test_error("invalid loose object directory"))?; let file = path .file_name() .and_then(|name| name.to_str()) .ok_or_else(|| test_error("invalid loose object name"))?; let expected = format!("{directory}{file}"); if actual != expected { return Err(test_error(format!( "invalid loose object {expected} in {}", repository.name ))); } } for commit in &repository.commits { let canonical = read_git_object(&root, commit)?; if !canonical.starts_with(b"commit ") { return Err(test_error(format!( "{commit} is not a commit in {}", repository.name ))); } let separator = canonical .iter() .position(|byte| *byte == 0) .ok_or_else(|| test_error("Git object has no header separator"))?; let body = std::str::from_utf8(&canonical[separator + 1..])?; let tree = body .lines() .find_map(|line| line.strip_prefix("tree ")) .ok_or_else(|| test_error("commit has no tree"))?; let tree_object = read_git_object(&root, tree)?; if !tree_object.starts_with(b"tree ") { return Err(test_error("commit tree points to a non-tree object")); } } Ok(()) } fn parse_toml(path: PathBuf) -> TestResult where T: for<'de> Deserialize<'de>, { Ok(toml::from_str(&fs::read_to_string(path)?)?) } fn copy_tree(source: &Path, destination: &Path) -> TestResult { if !source.is_dir() { return Err(test_error(format!( "fixture source is not a directory: {}", source.display() ))); } for entry in fs::read_dir(source)? { let entry = entry?; let source_path = entry.path(); let destination_path = destination.join(entry.file_name()); let kind = entry.file_type()?; if kind.is_dir() { fs::create_dir_all(&destination_path)?; copy_tree(&source_path, &destination_path)?; } else if kind.is_file() { fs::copy(source_path, destination_path)?; } else { return Err(test_error(format!( "unsupported checked-in fixture type: {}", source_path.display() ))); } } Ok(()) } fn loose_object_paths(objects: &Path) -> TestResult> { let mut paths = Vec::new(); for directory in fs::read_dir(objects)? { let directory = directory?; if !directory.file_type()?.is_dir() { continue; } let directory_name = directory.file_name(); if directory_name.to_string_lossy().len() != 2 { continue; } for object in fs::read_dir(directory.path())? { let object = object?; if object.file_type()?.is_file() { paths.push(object.path()); } } } paths.sort(); Ok(paths) } fn read_git_object(repository: &Path, id: &str) -> TestResult> { if id.len() != 40 || !id.bytes().all(|byte| byte.is_ascii_hexdigit()) { return Err(test_error(format!("invalid Git object ID {id}"))); } inflate(&repository.join("objects").join(&id[..2]).join(&id[2..])) } fn inflate(path: &Path) -> TestResult> { let mut decoder = ZlibDecoder::new(fs::File::open(path)?); let mut canonical = Vec::new(); decoder.read_to_end(&mut canonical)?; Ok(canonical) } fn test_error(message: impl Into) -> Box { Box::new(std::io::Error::other(message.into())) }