Files
IronStorage/crates/storage/tests/support/compatibility.rs
2026-08-09 20:59:49 +00:00

407 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>,
}
pub struct FixtureSet {
root: PathBuf,
pub upstream: UpstreamManifest,
pub behavior: BehaviorManifest,
pub generated: GeneratedManifest,
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 layouts = parse_toml(root.join("layouts.toml"))?;
Ok(Self {
root,
upstream,
behavior,
generated,
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()))
}