426 lines
13 KiB
Rust
426 lines
13 KiB
Rust
#![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<T = ()> = Result<T, Box<dyn Error>>;
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct UpstreamManifest {
|
|
pub format: u8,
|
|
pub project: Vec<UpstreamProject>,
|
|
}
|
|
|
|
#[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<BehaviorCase>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct BehaviorCase {
|
|
pub id: String,
|
|
pub area: String,
|
|
pub argv: Vec<String>,
|
|
pub stdin: String,
|
|
pub tty: bool,
|
|
pub status: i32,
|
|
pub outcome: String,
|
|
pub recipients: Vec<String>,
|
|
pub commit: String,
|
|
pub random_stream: Option<String>,
|
|
pub clock: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct GeneratedManifest {
|
|
pub format: u8,
|
|
pub generated_by: String,
|
|
pub fixture_seed: String,
|
|
pub keys: Vec<KeyRecord>,
|
|
pub entries: Vec<EntryRecord>,
|
|
pub repositories: Vec<RepositoryRecord>,
|
|
}
|
|
|
|
#[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<String>,
|
|
pub ciphertext_sha256: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct RepositoryRecord {
|
|
pub name: String,
|
|
pub template: String,
|
|
pub head: String,
|
|
pub commits: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct GnuPgAeadFixture {
|
|
pub format: u8,
|
|
pub producer: String,
|
|
pub command_profile: String,
|
|
pub packet_tag: u8,
|
|
pub cipher: String,
|
|
pub aead_mode: String,
|
|
pub recipient_primary_fingerprint: String,
|
|
pub recipient_encryption_subkey_fingerprint: String,
|
|
pub store: String,
|
|
pub entry: String,
|
|
pub plaintext: String,
|
|
pub ciphertext_sha256: String,
|
|
}
|
|
|
|
pub struct FixtureSet {
|
|
root: PathBuf,
|
|
pub upstream: UpstreamManifest,
|
|
pub behavior: BehaviorManifest,
|
|
pub generated: GeneratedManifest,
|
|
pub gnupg_aead: GnuPgAeadFixture,
|
|
pub layouts: toml::Value,
|
|
}
|
|
|
|
impl FixtureSet {
|
|
pub fn load() -> TestResult<Self> {
|
|
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 gnupg_aead = parse_toml(root.join("gnupg-aead/fixture.toml"))?;
|
|
let layouts = parse_toml(root.join("layouts.toml"))?;
|
|
Ok(Self {
|
|
root,
|
|
upstream,
|
|
behavior,
|
|
generated,
|
|
gnupg_aead,
|
|
layouts,
|
|
})
|
|
}
|
|
|
|
pub fn path(&self, relative: impl AsRef<Path>) -> PathBuf {
|
|
self.root.join(relative)
|
|
}
|
|
|
|
pub fn read(&self, relative: impl AsRef<Path>) -> TestResult<Vec<u8>> {
|
|
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<SignedSecretKey> {
|
|
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<SignedPublicKey> {
|
|
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<TempDir> {
|
|
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<TempDir> {
|
|
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<Vec<u8>> {
|
|
decrypt_entry_with_passphrase(fixture, entry, key, &key.passphrase)
|
|
}
|
|
|
|
pub fn decrypt_entry_with_passphrase(
|
|
fixture: &FixtureSet,
|
|
entry: &EntryRecord,
|
|
key: &KeyRecord,
|
|
passphrase: &str,
|
|
) -> TestResult<Vec<u8>> {
|
|
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<T>(path: PathBuf) -> TestResult<T>
|
|
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<Vec<PathBuf>> {
|
|
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<Vec<u8>> {
|
|
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<Vec<u8>> {
|
|
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<String>) -> Box<dyn Error> {
|
|
Box::new(std::io::Error::other(message.into()))
|
|
}
|