Establish pass compatibility fixture harness (#1)

This commit is contained in:
Hermes Agent
2026-08-09 20:59:49 +00:00
parent fa3bb44771
commit 41e56367bd
66 changed files with 3978 additions and 18 deletions

1217
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,3 +5,15 @@ version.workspace = true
edition.workspace = true
rust-version.workspace = true
publish = false
[dev-dependencies]
flate2 = "1.1"
hex = "0.4"
pgp = { version = "0.20", default-features = false }
rand_chacha = "0.3"
serde = { version = "1", features = ["derive"] }
sha1 = "0.10"
sha2 = "0.10"
smallvec = "1.15"
tempfile = "3"
toml = "0.9"

View File

@@ -0,0 +1,571 @@
#![forbid(unsafe_code)]
use std::{
collections::BTreeMap,
error::Error,
fs,
io::Write,
path::{Path, PathBuf},
};
use flate2::{Compression, write::ZlibEncoder};
use pgp::{
composed::{
ArmorOptions, Deserializable, DetachedSignature, EncryptionCaps, KeyType, MessageBuilder,
SecretKeyParamsBuilder, SignedSecretKey, SubkeyParamsBuilder, SubpacketConfig,
},
crypto::{ecc_curve::ECCCurve, hash::HashAlgorithm, sym::SymmetricKeyAlgorithm},
packet::{Subpacket, SubpacketData},
ser::Serialize as _,
types::{KeyDetails as _, Password, Timestamp},
};
use rand_chacha::{ChaCha20Rng, rand_core::SeedableRng};
use serde::Serialize;
use sha1::{Digest as _, Sha1};
use sha2::Sha256;
use smallvec::smallvec;
const FIXTURE_EPOCH: u32 = 1_704_067_200;
const ALICE_PASSPHRASE: &str = "fixture-alice-passphrase";
const BOB_PASSPHRASE: &str = "fixture-bob-passphrase";
#[derive(Serialize)]
struct GeneratedManifest {
format: u8,
generated_by: &'static str,
fixture_seed: &'static str,
keys: Vec<KeyRecord>,
entries: Vec<EntryRecord>,
repositories: Vec<RepositoryRecord>,
}
#[derive(Serialize)]
struct KeyRecord {
name: &'static str,
user_id: &'static str,
primary_fingerprint: String,
encryption_subkey_fingerprint: String,
passphrase: &'static str,
public_armor: String,
public_binary: String,
secret_armor: String,
secret_binary: String,
}
#[derive(Serialize)]
struct EntryRecord {
store: &'static str,
path: &'static str,
plaintext: &'static str,
recipients: Vec<String>,
ciphertext_sha256: String,
}
#[derive(Serialize)]
struct RepositoryRecord {
name: &'static str,
template: String,
head: String,
commits: Vec<String>,
}
struct SyntheticKey {
name: &'static str,
user_id: &'static str,
passphrase: &'static str,
secret: SignedSecretKey,
}
impl SyntheticKey {
fn primary_fingerprint(&self) -> String {
format!("{:X}", self.secret.primary_key.fingerprint())
}
fn encryption_fingerprint(&self) -> String {
format!("{:X}", self.secret.secret_subkeys[0].fingerprint())
}
}
fn main() -> Result<(), Box<dyn Error>> {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/compatibility");
prepare_output(&root)?;
let mut rng = ChaCha20Rng::from_seed([0x49; 32]);
let alice = load_or_generate_key(
&root,
&mut rng,
"alice",
"Alice Fixture <alice@ironstorage.invalid>",
ALICE_PASSPHRASE,
)?;
let bob = load_or_generate_key(
&root,
&mut rng,
"bob",
"Bob Fixture <bob@ironstorage.invalid>",
BOB_PASSPHRASE,
)?;
let keys = vec![write_key(&root, &alice)?, write_key(&root, &bob)?];
let mut entries = Vec::new();
write_recipient_file(&root, "basic/.gpg-id", &[&alice])?;
write_signature(&root, "basic/.gpg-id.sig", &alice, single_recipient(&alice))?;
write_recipient_file(&root, "basic/team/.gpg-id", &[&bob])?;
write_signature(
&root,
"basic/team/.gpg-id.sig",
&bob,
single_recipient(&bob),
)?;
write_recipient_file(&root, "basic/shared/.gpg-id", &[&alice, &bob])?;
entries.push(write_entry(
&root,
&mut rng,
"basic",
"email/personal.gpg",
"email/personal.txt",
b"correct horse fixture\nlogin: alice@example.test\nurl: https://example.test\n",
&[&alice],
)?);
entries.push(write_entry(
&root,
&mut rng,
"basic",
"unicode/咖啡.gpg",
"unicode/咖啡.txt",
"pässwörd-猫\nlogin: 用户@example.test\nnotes: café\n".as_bytes(),
&[&alice],
)?);
entries.push(write_entry(
&root,
&mut rng,
"basic",
"otp/totp.gpg",
"otp/totp.txt",
b"fixture-password\notpauth://totp/IronStorage:alice%40example.test?secret=JBSWY3DPEHPK3PXP&issuer=IronStorage&algorithm=SHA1&digits=6&period=30\n",
&[&alice],
)?);
entries.push(write_entry(
&root,
&mut rng,
"basic",
"otp/hotp.gpg",
"otp/hotp.txt",
b"otpauth://hotp/IronStorage:counter?secret=JBSWY3DPEHPK3PXP&issuer=IronStorage&counter=0&digits=8\n",
&[&alice],
)?);
entries.push(write_entry(
&root,
&mut rng,
"basic",
"team/service.gpg",
"team/service.txt",
b"team-fixture-password\nowner: bob\n",
&[&bob],
)?);
entries.push(write_entry(
&root,
&mut rng,
"basic",
"shared/multiple.gpg",
"shared/multiple.txt",
b"shared-fixture-password\nrecipients: alice,bob\n",
&[&alice, &bob],
)?);
write_recipient_file(&root, "nested/outer/.gpg-id", &[&alice])?;
write_recipient_file(&root, "nested/outer/inner/.gpg-id", &[&bob])?;
entries.push(write_entry(
&root,
&mut rng,
"nested",
"outer/root-entry.gpg",
"outer/root-entry.txt",
b"outer repository entry\n",
&[&alice],
)?);
entries.push(write_entry(
&root,
&mut rng,
"nested",
"outer/inner/nested-entry.gpg",
"outer/inner/nested-entry.txt",
b"inner repository entry\n",
&[&bob],
)?);
let repositories = vec![
write_basic_repository(&root)?,
write_nested_repository(&root, "outer", "nested/outer", "root-entry.gpg")?,
write_nested_repository(&root, "inner", "nested/outer/inner", "nested-entry.gpg")?,
];
let manifest = GeneratedManifest {
format: 1,
generated_by: "cargo run -p ironstorage --example refresh_compatibility_fixtures",
fixture_seed: "49 repeated 32 times (hex); checked-in keys are reused on refresh",
keys,
entries,
repositories,
};
write(
root.join("generated.toml"),
toml::to_string_pretty(&manifest)?.as_bytes(),
)?;
println!("refreshed {}", root.display());
Ok(())
}
fn prepare_output(root: &Path) -> Result<(), Box<dyn Error>> {
fs::create_dir_all(root)?;
fs::create_dir_all(root.join("keys"))?;
for generated in ["stores", "expected", "repositories"] {
let path = root.join(generated);
if path.exists() {
fs::remove_dir_all(&path)?;
}
fs::create_dir_all(path)?;
}
Ok(())
}
fn load_or_generate_key(
root: &Path,
rng: &mut ChaCha20Rng,
name: &'static str,
user_id: &'static str,
passphrase: &'static str,
) -> Result<SyntheticKey, Box<dyn Error>> {
let path = root.join(format!("keys/{name}-secret.asc"));
if path.is_file() {
let (secret, _) = SignedSecretKey::from_armor_single(fs::File::open(path)?)?;
secret.verify_bindings()?;
return Ok(SyntheticKey {
name,
user_id,
passphrase,
secret,
});
}
generate_key(rng, name, user_id, passphrase)
}
fn generate_key(
rng: &mut ChaCha20Rng,
name: &'static str,
user_id: &'static str,
passphrase: &'static str,
) -> Result<SyntheticKey, Box<dyn Error>> {
let created_at = Timestamp::from_secs(FIXTURE_EPOCH);
let subkey = SubkeyParamsBuilder::default()
.key_type(KeyType::ECDH(ECCCurve::Curve25519Legacy))
.can_encrypt(EncryptionCaps::All)
.created_at(created_at)
.passphrase(Some(passphrase.to_owned()))
.build()?;
let secret = SecretKeyParamsBuilder::default()
.key_type(KeyType::Ed25519Legacy)
.can_certify(true)
.can_sign(true)
.created_at(created_at)
.primary_user_id(user_id.to_owned())
.passphrase(Some(passphrase.to_owned()))
.preferred_symmetric_algorithms(smallvec![
SymmetricKeyAlgorithm::AES256,
SymmetricKeyAlgorithm::AES128,
])
.preferred_hash_algorithms(smallvec![HashAlgorithm::Sha256])
.preferred_compression_algorithms(smallvec![])
.subkey(subkey)
.build()?
.generate(rng)?;
secret.verify_bindings()?;
Ok(SyntheticKey {
name,
user_id,
passphrase,
secret,
})
}
fn write_key(root: &Path, key: &SyntheticKey) -> Result<KeyRecord, Box<dyn Error>> {
let public = key.secret.to_public_key();
let public_armor_path = format!("keys/{}-public.asc", key.name);
let public_binary_path = format!("keys/{}-public.pgp", key.name);
let secret_armor_path = format!("keys/{}-secret.asc", key.name);
let secret_binary_path = format!("keys/{}-secret.pgp", key.name);
write(
root.join(&public_armor_path),
public
.to_armored_string(ArmorOptions::default())?
.as_bytes(),
)?;
let mut public_binary = Vec::new();
public.to_writer(&mut public_binary)?;
write(root.join(&public_binary_path), &public_binary)?;
write(
root.join(&secret_armor_path),
key.secret
.to_armored_string(ArmorOptions::default())?
.as_bytes(),
)?;
let mut secret_binary = Vec::new();
key.secret.to_writer(&mut secret_binary)?;
write(root.join(&secret_binary_path), &secret_binary)?;
Ok(KeyRecord {
name: key.name,
user_id: key.user_id,
primary_fingerprint: key.primary_fingerprint(),
encryption_subkey_fingerprint: key.encryption_fingerprint(),
passphrase: key.passphrase,
public_armor: public_armor_path,
public_binary: public_binary_path,
secret_armor: secret_armor_path,
secret_binary: secret_binary_path,
})
}
fn recipient_bytes(keys: &[&SyntheticKey]) -> Vec<u8> {
let mut bytes = keys
.iter()
.map(|key| key.primary_fingerprint())
.collect::<Vec<_>>()
.join("\n")
.into_bytes();
bytes.push(b'\n');
bytes
}
fn single_recipient(key: &SyntheticKey) -> Vec<u8> {
recipient_bytes(&[key])
}
fn write_recipient_file(
root: &Path,
relative: &str,
keys: &[&SyntheticKey],
) -> Result<(), Box<dyn Error>> {
write(root.join("stores").join(relative), &recipient_bytes(keys))
}
fn write_signature(
root: &Path,
relative: &str,
key: &SyntheticKey,
data: Vec<u8>,
) -> Result<(), Box<dyn Error>> {
let hashed = vec![
Subpacket::regular(SubpacketData::IssuerFingerprint(
key.secret.primary_key.fingerprint(),
))?,
Subpacket::regular(SubpacketData::SignatureCreationTime(Timestamp::from_secs(
FIXTURE_EPOCH,
)))?,
];
let unhashed = vec![Subpacket::regular(SubpacketData::IssuerKeyId(
key.secret.primary_key.legacy_key_id(),
))?];
let signature = DetachedSignature::sign_binary_data_with_subpackets(
ChaCha20Rng::from_seed([0x53; 32]),
&key.secret.primary_key,
&Password::from(key.passphrase),
HashAlgorithm::Sha256,
data.as_slice(),
SubpacketConfig::UserDefined { hashed, unhashed },
)?;
let mut bytes = Vec::new();
signature.to_writer(&mut bytes)?;
write(root.join("stores").join(relative), &bytes)
}
fn write_entry(
root: &Path,
rng: &mut ChaCha20Rng,
store: &'static str,
path: &'static str,
plaintext_path: &'static str,
plaintext: &[u8],
recipients: &[&SyntheticKey],
) -> Result<EntryRecord, Box<dyn Error>> {
let mut message = MessageBuilder::from_bytes("", plaintext.to_vec())
.seipd_v1(&mut *rng, SymmetricKeyAlgorithm::AES256);
for recipient in recipients {
let public = recipient.secret.to_public_key();
message.encrypt_to_key(&mut *rng, &public.public_subkeys[0])?;
}
let ciphertext = message.to_vec(&mut *rng)?;
write(root.join("stores").join(store).join(path), &ciphertext)?;
write(
root.join("expected").join(store).join(plaintext_path),
plaintext,
)?;
Ok(EntryRecord {
store,
path,
plaintext: plaintext_path,
recipients: recipients
.iter()
.map(|key| key.primary_fingerprint())
.collect(),
ciphertext_sha256: hex::encode(Sha256::digest(&ciphertext)),
})
}
fn write_basic_repository(root: &Path) -> Result<RepositoryRecord, Box<dyn Error>> {
let repository = root.join("repositories/basic.git");
prepare_git_repository(&repository)?;
let recipient = fs::read(root.join("stores/basic/.gpg-id"))?;
let encrypted = fs::read(root.join("stores/basic/email/personal.gpg"))?;
let recipient_blob = write_git_object(&repository, "blob", &recipient)?;
let initial_tree = write_git_tree(
&repository,
&[("100644", ".gpg-id", recipient_blob.clone())],
)?;
let initial = write_git_commit(
&repository,
&initial_tree,
None,
"Set GPG id to Alice Fixture.",
)?;
let encrypted_blob = write_git_object(&repository, "blob", &encrypted)?;
let email_tree = write_git_tree(&repository, &[("100644", "personal.gpg", encrypted_blob)])?;
let final_tree = write_git_tree(
&repository,
&[
("100644", ".gpg-id", recipient_blob),
("40000", "email", email_tree),
],
)?;
let head = write_git_commit(
&repository,
&final_tree,
Some(&initial),
"Add given password for email/personal to store.",
)?;
write_ref(&repository, &head)?;
Ok(RepositoryRecord {
name: "basic",
template: "repositories/basic.git".to_owned(),
head: head.clone(),
commits: vec![initial, head],
})
}
fn write_nested_repository(
root: &Path,
name: &'static str,
store: &str,
entry: &str,
) -> Result<RepositoryRecord, Box<dyn Error>> {
let template = format!("repositories/{name}.git");
let repository = root.join(&template);
prepare_git_repository(&repository)?;
let recipient = fs::read(root.join("stores").join(store).join(".gpg-id"))?;
let encrypted = fs::read(root.join("stores").join(store).join(entry))?;
let recipient_blob = write_git_object(&repository, "blob", &recipient)?;
let entry_blob = write_git_object(&repository, "blob", &encrypted)?;
let tree = write_git_tree(
&repository,
&[
("100644", ".gpg-id", recipient_blob),
("100644", entry, entry_blob),
],
)?;
let head = write_git_commit(
&repository,
&tree,
None,
&format!("Add current contents of {name} password store."),
)?;
write_ref(&repository, &head)?;
Ok(RepositoryRecord {
name,
template,
head: head.clone(),
commits: vec![head],
})
}
fn prepare_git_repository(repository: &Path) -> Result<(), Box<dyn Error>> {
fs::create_dir_all(repository.join("objects"))?;
fs::create_dir_all(repository.join("refs/heads"))?;
write(repository.join("HEAD"), b"ref: refs/heads/main\n")?;
write(
repository.join("config"),
b"[core]\n\trepositoryformatversion = 0\n\tfilemode = true\n\tbare = true\n[pass]\n\tsigncommits = false\n",
)?;
Ok(())
}
fn write_git_tree(
repository: &Path,
entries: &[(&str, &str, String)],
) -> Result<String, Box<dyn Error>> {
let mut sorted = BTreeMap::new();
for (mode, name, id) in entries {
sorted.insert(name.as_bytes(), (*mode, id));
}
let mut body = Vec::new();
for (name, (mode, id)) in sorted {
body.extend_from_slice(mode.as_bytes());
body.push(b' ');
body.extend_from_slice(name);
body.push(0);
body.extend_from_slice(&hex::decode(id)?);
}
write_git_object(repository, "tree", &body)
}
fn write_git_commit(
repository: &Path,
tree: &str,
parent: Option<&str>,
message: &str,
) -> Result<String, Box<dyn Error>> {
let mut body = format!("tree {tree}\n");
if let Some(parent) = parent {
body.push_str(&format!("parent {parent}\n"));
}
body.push_str("author IronStorage Fixture <fixture@ironstorage.invalid> 1704067200 +0000\n");
body.push_str(
"committer IronStorage Fixture <fixture@ironstorage.invalid> 1704067200 +0000\n\n",
);
body.push_str(message);
body.push('\n');
write_git_object(repository, "commit", body.as_bytes())
}
fn write_git_object(repository: &Path, kind: &str, body: &[u8]) -> Result<String, Box<dyn Error>> {
let mut canonical = format!("{kind} {}\0", body.len()).into_bytes();
canonical.extend_from_slice(body);
let id = hex::encode(Sha1::digest(&canonical));
let path = repository.join("objects").join(&id[..2]).join(&id[2..]);
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
encoder.write_all(&canonical)?;
write(path, &encoder.finish()?)?;
Ok(id)
}
fn write_ref(repository: &Path, head: &str) -> Result<(), Box<dyn Error>> {
write(
repository.join("refs/heads/main"),
format!("{head}\n").as_bytes(),
)
}
fn write(path: PathBuf, contents: &[u8]) -> Result<(), Box<dyn Error>> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, contents)?;
Ok(())
}

View File

@@ -0,0 +1,236 @@
#![forbid(unsafe_code)]
mod support;
use std::collections::{BTreeMap, BTreeSet};
use support::compatibility::{
FixtureSet, TestResult, decrypt_entry_with_passphrase, validate_entry_record,
validate_key_record, validate_recipient_signature, validate_repository,
};
#[test]
fn pins_the_selected_upstream_revisions() -> TestResult {
let fixture = FixtureSet::load()?;
assert_eq!(fixture.upstream.format, 1);
assert_eq!(fixture.upstream.project.len(), 2);
let projects = fixture
.upstream
.project
.iter()
.map(|project| (project.name.as_str(), project))
.collect::<BTreeMap<_, _>>();
let password_store = projects["password-store"];
assert_eq!(password_store.version, "1.7.4");
assert_eq!(password_store.reported_version, "1.7.4");
assert_eq!(
password_store.commit,
"1078f2514d579178d5df7042c6a790e9c9b731ad"
);
let pass_otp = projects["pass-otp"];
assert_eq!(pass_otp.version, "1.2.0");
assert_eq!(pass_otp.reported_version, "1.1.1");
assert_eq!(pass_otp.commit, "1e9d10ca75ae1a8672a7f192809713463657778e");
for project in projects.values() {
assert_eq!(project.commit.len(), 40);
assert!(project.commit.bytes().all(|byte| byte.is_ascii_hexdigit()));
assert!(project.source.starts_with("https://"));
assert!(!project.behavior_source.is_empty());
assert!(project.license.starts_with("GPL-"));
}
Ok(())
}
#[test]
fn behavior_catalog_covers_the_milestone_contract() -> TestResult {
let fixture = FixtureSet::load()?;
assert_eq!(fixture.behavior.format, 1);
assert!(fixture.behavior.cases.len() >= 65);
let mut ids = BTreeSet::new();
let mut commands = BTreeSet::new();
let mut flags = BTreeSet::new();
let mut areas = BTreeSet::new();
let mut has_success = false;
let mut has_failure = false;
for case in &fixture.behavior.cases {
assert!(
ids.insert(case.id.as_str()),
"duplicate case ID {}",
case.id
);
assert!(
matches!(case.status, 0 | 1),
"invalid status in {}",
case.id
);
has_success |= case.status == 0;
has_failure |= case.status != 0;
areas.insert(case.area.as_str());
if let Some(command) = case.argv.first() {
commands.insert(command.as_str());
}
for argument in &case.argv {
if argument.starts_with('-') {
let flag = argument.split('=').next().unwrap_or(argument);
flags.insert(flag);
}
}
if case.status != 0 {
assert!(case.recipients.is_empty(), "failed case has recipients");
assert!(case.commit.is_empty(), "failed case has a commit");
}
assert!(!case.outcome.is_empty());
assert!(!case.stdin.is_empty());
}
assert!(has_success && has_failure);
for area in [
"init", "show", "list", "find", "grep", "insert", "edit", "generate", "remove", "move",
"copy", "git", "otp", "meta",
] {
assert!(areas.contains(area), "missing behavior area {area}");
}
for command in [
"init",
"show",
"ls",
"list",
"find",
"search",
"grep",
"insert",
"add",
"edit",
"generate",
"rm",
"remove",
"delete",
"mv",
"rename",
"cp",
"copy",
"git",
"otp",
"help",
"version",
"--help",
"--version",
] {
assert!(
commands.contains(command),
"missing command or alias {command}"
);
}
for flag in [
"-p",
"--path",
"-c",
"--clip",
"-q2",
"--qrcode",
"-e",
"--echo",
"-m",
"--multiline",
"-f",
"--force",
"-n",
"--no-symbols",
"-i",
"--in-place",
"-r",
"--recursive",
"-s",
"--secret",
"-a",
"--account",
"--issuer",
] {
assert!(flags.contains(flag), "missing option spelling {flag}");
}
Ok(())
}
#[test]
fn generated_openpgp_fixtures_are_self_consistent() -> TestResult {
let fixture = FixtureSet::load()?;
assert_eq!(fixture.generated.format, 1);
assert_eq!(fixture.generated.keys.len(), 2);
assert!(fixture.generated.entries.len() >= 8);
for key in &fixture.generated.keys {
validate_key_record(&fixture, key)?;
}
for entry in &fixture.generated.entries {
validate_entry_record(&fixture, entry)?;
}
let bob_entry = fixture
.generated
.entries
.iter()
.find(|entry| entry.store == "basic" && entry.path == "team/service.gpg")
.expect("Bob-only entry");
let bob = fixture.key("bob")?;
assert!(
decrypt_entry_with_passphrase(&fixture, bob_entry, bob, "fixture-alice-passphrase",)
.is_err(),
"the encrypted secret-key passphrase must be required"
);
validate_recipient_signature(&fixture, "basic", "alice")?;
validate_recipient_signature(&fixture, "basic/team", "bob")?;
Ok(())
}
#[test]
fn generated_git_repositories_have_valid_objects_and_refs() -> TestResult {
let fixture = FixtureSet::load()?;
assert_eq!(fixture.generated.repositories.len(), 3);
for repository in &fixture.generated.repositories {
validate_repository(&fixture, repository)?;
}
Ok(())
}
#[test]
fn fixtures_materialize_without_touching_a_user_store() -> TestResult {
let fixture = FixtureSet::load()?;
let store = fixture.materialize_store("basic")?;
assert!(store.path().join(".gpg-id").is_file());
assert!(store.path().join("email/personal.gpg").is_file());
assert!(store.path().join("team/.gpg-id").is_file());
let repository = fixture.materialize_repository("basic")?;
assert!(repository.path().join("HEAD").is_file());
assert!(repository.path().join("objects").is_dir());
assert!(repository.path().join("refs/heads/main").is_file());
Ok(())
}
#[test]
fn hostile_and_interruption_recipes_are_present() -> TestResult {
let fixture = FixtureSet::load()?;
let nodes = fixture
.layouts
.get("synthetic_node")
.and_then(toml::Value::as_array)
.expect("synthetic_node array");
assert!(nodes.len() >= 4);
let outcomes = nodes
.iter()
.filter_map(|node| node.get("expected"))
.filter_map(toml::Value::as_str)
.collect::<BTreeSet<_>>();
assert!(outcomes.contains("reject-symlink-escape"));
assert!(outcomes.contains("reject-unsupported-file-type"));
assert!(outcomes.contains("require-trailing-slash-disambiguation"));
let interruptions = fixture
.layouts
.get("interruption")
.and_then(toml::Value::as_array)
.expect("interruption array");
assert_eq!(interruptions.len(), 3);
Ok(())
}

View File

@@ -0,0 +1,52 @@
# Compatibility fixtures
These fixtures pin password-store 1.7.4 at commit
`1078f2514d579178d5df7042c6a790e9c9b731ad` and pass-otp 1.2.0 at commit
`1e9d10ca75ae1a8672a7f192809713463657778e`. The behavioral catalog was
written from the projects' published command documentation and pinned source;
no upstream source or test code is copied here.
The v1.2.0 pass-otp tag reports `1.1.1` from its `version` command. The
fixture records that observable upstream behavior explicitly rather than
silently correcting it.
All identities, passphrases, plaintexts, OTP secrets, Git identities, and
remote names are synthetic and public. They must never be reused for real
secrets. The generator reuses the checked-in identities and produces:
- ASCII-armored and binary public keys;
- passphrase-protected ASCII-armored and binary secret keys;
- binary OpenPGP messages in an ordinary pass directory tree;
- root, nested, signed, and multi-recipient `.gpg-id` policies;
- TOTP and HOTP entries;
- valid loose-object Git repository templates, including an automatic-commit
history and nested-repository boundaries.
Rust tests parse keys, verify signatures, decrypt every entry, compare exact
plaintext bytes, validate ciphertext digests, validate Git objects and refs,
and materialize isolated stores and repositories. They never invoke `pass`,
`gpg`, `git`, a shell, or an OTP/QR helper.
## Refreshing fixtures
From the repository root:
```sh
cargo run -p ironstorage --example refresh_compatibility_fixtures
cargo test -p ironstorage --test compatibility_fixtures
```
Normal refreshes are byte-for-byte reproducible at the locked dependency
versions because checked-in identities are reused. Deleting the key fixtures
explicitly rotates the synthetic identities; OpenPGP self-signature timestamps
then make that rotation intentionally non-reproducible. Review all binary
changes and the generated fingerprints, digests, and Git object IDs before
committing them.
An optional developer interoperability audit may import the generated keys
into a disposable GnuPG home, decrypt the `.gpg` entries, and point an
upstream `pass` 1.7.4 checkout at a materialized store. That audit is never a
test prerequisite and must use a temporary keyring and the synthetic fixture
passphrases. Refreshing observable behavior against upstream `pass` or
`pass-otp` is similarly development-only; application/runtime code may not
execute either tool.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
correct horse fixture
login: alice@example.test
url: https://example.test

View File

@@ -0,0 +1 @@
otpauth://hotp/IronStorage:counter?secret=JBSWY3DPEHPK3PXP&issuer=IronStorage&counter=0&digits=8

View File

@@ -0,0 +1,2 @@
fixture-password
otpauth://totp/IronStorage:alice%40example.test?secret=JBSWY3DPEHPK3PXP&issuer=IronStorage&algorithm=SHA1&digits=6&period=30

View File

@@ -0,0 +1,2 @@
shared-fixture-password
recipients: alice,bob

View File

@@ -0,0 +1,2 @@
team-fixture-password
owner: bob

View File

@@ -0,0 +1,3 @@
pässwörd-猫
login: 用户@example.test
notes: café

View File

@@ -0,0 +1 @@
inner repository entry

View File

@@ -0,0 +1 @@
outer repository entry

View File

@@ -0,0 +1,105 @@
format = 1
generated_by = "cargo run -p ironstorage --example refresh_compatibility_fixtures"
fixture_seed = "49 repeated 32 times (hex); checked-in keys are reused on refresh"
[[keys]]
name = "alice"
user_id = "Alice Fixture <alice@ironstorage.invalid>"
primary_fingerprint = "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30"
encryption_subkey_fingerprint = "10208D0B84AD13D2157795D5EB55885EC93C0A26"
passphrase = "fixture-alice-passphrase"
public_armor = "keys/alice-public.asc"
public_binary = "keys/alice-public.pgp"
secret_armor = "keys/alice-secret.asc"
secret_binary = "keys/alice-secret.pgp"
[[keys]]
name = "bob"
user_id = "Bob Fixture <bob@ironstorage.invalid>"
primary_fingerprint = "B37027B56FC406BD3F6A622B2AC03492B992D06F"
encryption_subkey_fingerprint = "46E34F32333752A01E4E019B4AC0B1A0B4D6278D"
passphrase = "fixture-bob-passphrase"
public_armor = "keys/bob-public.asc"
public_binary = "keys/bob-public.pgp"
secret_armor = "keys/bob-secret.asc"
secret_binary = "keys/bob-secret.pgp"
[[entries]]
store = "basic"
path = "email/personal.gpg"
plaintext = "email/personal.txt"
recipients = ["7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30"]
ciphertext_sha256 = "8259de0c1ae6b5d4fc3ebd410452f96a266b0d55d111cfaf9c609baf3deda1b2"
[[entries]]
store = "basic"
path = "unicode/咖啡.gpg"
plaintext = "unicode/咖啡.txt"
recipients = ["7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30"]
ciphertext_sha256 = "c5c829b46e517109bcfea7731c846ca6d09eff8076fe4444aaa1c2512fc06d44"
[[entries]]
store = "basic"
path = "otp/totp.gpg"
plaintext = "otp/totp.txt"
recipients = ["7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30"]
ciphertext_sha256 = "260a0d7371964ca9d115e2d818cf1a8a63e2a4ff9a172ca421c569aa649cf014"
[[entries]]
store = "basic"
path = "otp/hotp.gpg"
plaintext = "otp/hotp.txt"
recipients = ["7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30"]
ciphertext_sha256 = "337bb6568bc3805b8c269463654b081da88d3755da62603b263b6865babf9bd2"
[[entries]]
store = "basic"
path = "team/service.gpg"
plaintext = "team/service.txt"
recipients = ["B37027B56FC406BD3F6A622B2AC03492B992D06F"]
ciphertext_sha256 = "b325904fbdbe335bc9cd2319ace3e598da194e6450bf3f47371507b1f47306ae"
[[entries]]
store = "basic"
path = "shared/multiple.gpg"
plaintext = "shared/multiple.txt"
recipients = [
"7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30",
"B37027B56FC406BD3F6A622B2AC03492B992D06F",
]
ciphertext_sha256 = "7a8ac913dfb930d6ed8d98d40b12be9f522685c5be45d6b2b30b1bc407b5996b"
[[entries]]
store = "nested"
path = "outer/root-entry.gpg"
plaintext = "outer/root-entry.txt"
recipients = ["7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30"]
ciphertext_sha256 = "58c1470b623ec7076ddf52e8c3a8372e508ab4b84b7ad146e7878d85e4b872de"
[[entries]]
store = "nested"
path = "outer/inner/nested-entry.gpg"
plaintext = "outer/inner/nested-entry.txt"
recipients = ["B37027B56FC406BD3F6A622B2AC03492B992D06F"]
ciphertext_sha256 = "b8757d983ad7d78205f3f7b74cfb22b949aebae25bc70febcffe0c5d6ec4882b"
[[repositories]]
name = "basic"
template = "repositories/basic.git"
head = "c1bec8815d40fab6e23162443ef55ce1c46bddf6"
commits = [
"a7d88415df6027082096f6fd035387ac76726920",
"c1bec8815d40fab6e23162443ef55ce1c46bddf6",
]
[[repositories]]
name = "outer"
template = "repositories/outer.git"
head = "2c32c59867d2e4b4ab07ca8e9225e8eaa887a58e"
commits = ["2c32c59867d2e4b4ab07ca8e9225e8eaa887a58e"]
[[repositories]]
name = "inner"
template = "repositories/inner.git"
head = "308371ba7fd91255536f4e6afee2e23a02af6915"
commits = ["308371ba7fd91255536f4e6afee2e23a02af6915"]

View File

@@ -0,0 +1,13 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
xjMEZZIAgBYJKwYBBAHaRw8BAQdAZihauC+eAYkipuDUFUMqq+UVCvWVZCK8gvTi
rkx1grXNKUFsaWNlIEZpeHR1cmUgPGFsaWNlQGlyb25zdG9yYWdlLmludmFsaWQ+
wokEExYIADEFAmp457IWIQR+XFJBsl9v+q1xfr+hMtyy3COuMAIbAwIeAQMLCQcC
FQgBFgEnAhkBAAoJEKEy3LLcI64wEAABAKqDzgiRwCBFC+kcbKGNOWO9O/nKVDHR
Q2d4r8HOUXItAQCTGZHFaBkaZNyDjAOLTBBWMBxa0i5WatkAx5ZnKJT8Cs44BGWS
AIASCisGAQQBl1UBBQEBB0BIPO/SwIrdEJLCGmXEG1/szwa9u7WK0rPQArtvrYMx
PAMBCAfCeAQYFggAIAUCanjnswIbDBYhBH5cUkGyX2/6rXF+v6Ey3LLcI64wAAoJ
EKEy3LLcI64wyKIA/i7fuf/N31swIwzJXLBkzU4BvWJfv6NuHuvQej4qmnhrAQDi
ZJ+POdCbAxeTE7wNT5llULI8oowyUXfcF7C7UzDCAw==
=Mbap
-----END PGP PUBLIC KEY BLOCK-----

Binary file not shown.

View File

@@ -0,0 +1,17 @@
-----BEGIN PGP PRIVATE KEY BLOCK-----
xYYEZZIAgBYJKwYBBAHaRw8BAQdAZihauC+eAYkipuDUFUMqq+UVCvWVZCK8gvTi
rkx1grX+CQMIY/R1q1/mEXzgJkJdUlNlns+1/EtfSNxLhlzT8XaaLL/pf7ZPI7e0
mvzQuBEvkEGTVAxt2u80sGMY7mbZvcG6L++dUpP1FqvPdM8fjCv8PM0pQWxpY2Ug
Rml4dHVyZSA8YWxpY2VAaXJvbnN0b3JhZ2UuaW52YWxpZD7CiQQTFggAMQUCanjn
shYhBH5cUkGyX2/6rXF+v6Ey3LLcI64wAhsDAh4BAwsJBwIVCAEWAScCGQEACgkQ
oTLcstwjrjAQAAEAqoPOCJHAIEUL6RxsoY05Y707+cpUMdFDZ3ivwc5Rci0BAJMZ
kcVoGRpk3IOMA4tMEFYwHFrSLlZq2QDHlmcolPwKx4sEZZIAgBIKKwYBBAGXVQEF
AQEHQEg879LAit0QksIaZcQbX+zPBr27tYrSs9ACu2+tgzE8AwEIB/4JAwhRDjBN
dWgYi+AGAFqqDR6n0O+kc1WyeUYI+G8+R9EDz6Ar0xGGaZZ8GUU17BWGDY/UbxUU
z5LHAQ2KB05GYf/9VjHTaKZCSiKFJlCElEASwngEGBYIACAFAmp457MCGwwWIQR+
XFJBsl9v+q1xfr+hMtyy3COuMAAKCRChMtyy3COuMMiiAP4u37n/zd9bMCMMyVyw
ZM1OAb1iX7+jbh7r0Ho+Kpp4awEA4mSfjznQmwMXkxO8DU+ZZVCyPKKMMlF33Bew
u1MwwgM=
=ij6E
-----END PGP PRIVATE KEY BLOCK-----

Binary file not shown.

View File

@@ -0,0 +1,13 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
xjMEZZIAgBYJKwYBBAHaRw8BAQdANd08TxPj0ZuIl1cMvEf0FMCxubDwlzXDLX6w
9M+959XNJUJvYiBGaXh0dXJlIDxib2JAaXJvbnN0b3JhZ2UuaW52YWxpZD7CiQQT
FggAMQUCanjntBYhBLNwJ7VvxAa9P2piKyrANJK5ktBvAhsDAh4BAwsJBwIVCAEW
AScCGQEACgkQKsA0krmS0G9BCwD/daZy2BdmNQX8TldPBtoLTrugeaFmtrcVDQZ3
AzWEMcMA/16q87Zkaw2ozanLSBcRiq4iZnBTAmU/7bUSlA2myZoHzjgEZZIAgBIK
KwYBBAGXVQEFAQEHQByJpci1aMxEcUVlw9mByZUPyaQ7kI1emdJOuN1hcLEKAwEI
B8J4BBgWCAAgBQJqeOe1AhsMFiEEs3AntW/EBr0/amIrKsA0krmS0G8ACgkQKsA0
krmS0G+2lwEAvD9R7o52H37GsfeMS15UJBeIHBaYHjpsIyfoOWq0vsEA/j0oL4IG
SIakL3z1C1KHU+4iz3rzz2vNnhgGQd7+DS8H
=vS0q
-----END PGP PUBLIC KEY BLOCK-----

Binary file not shown.

View File

@@ -0,0 +1,17 @@
-----BEGIN PGP PRIVATE KEY BLOCK-----
xYYEZZIAgBYJKwYBBAHaRw8BAQdANd08TxPj0ZuIl1cMvEf0FMCxubDwlzXDLX6w
9M+959X+CQMIx1A5+eCcr6bgYLtP6nkTdu/qlT/dwB7B38PIlZekNRoN2yd48DcO
WmOqWrkE+Fxovj8zITOJUGP16kHJjW0727wsY1LI4BrX2Gsa3m0jV80lQm9iIEZp
eHR1cmUgPGJvYkBpcm9uc3RvcmFnZS5pbnZhbGlkPsKJBBMWCAAxBQJqeOe0FiEE
s3AntW/EBr0/amIrKsA0krmS0G8CGwMCHgEDCwkHAhUIARYBJwIZAQAKCRAqwDSS
uZLQb0ELAP91pnLYF2Y1BfxOV08G2gtOu6B5oWa2txUNBncDNYQxwwD/XqrztmRr
DajNqctIFxGKriJmcFMCZT/ttRKUDabJmgfHiwRlkgCAEgorBgEEAZdVAQUBAQdA
HImlyLVozERxRWXD2YHJlQ/JpDuQjV6Z0k643WFwsQoDAQgH/gkDCJdPSdzYfIpD
4M3cgwcGp2Ok/jR9F18ObkKhc/M6UfllIkZKLNl9zjUnDagCIzy+ZiLWTxAAKl02
bGkW56gOcrp+PIARjn85GEBRXXYogE7CeAQYFggAIAUCanjntQIbDBYhBLNwJ7Vv
xAa9P2piKyrANJK5ktBvAAoJECrANJK5ktBvtpcBALw/Ue6Odh9+xrH3jEteVCQX
iBwWmB46bCMn6DlqtL7BAP49KC+CBkiGpC989QtSh1PuIs96889rzZ4YBkHe/g0v
Bw==
=w2Yf
-----END PGP PRIVATE KEY BLOCK-----

Binary file not shown.

View File

@@ -0,0 +1,51 @@
format = 1
[[layout]]
id = "basic"
store = "stores/basic"
purpose = "ordinary, Unicode, OTP, nested recipient, signature, and multi-recipient entries"
[[layout]]
id = "nested"
store = "stores/nested/outer"
purpose = "an outer store and an inner recipient/Git boundary"
[[synthetic_node]]
layout = "hostile"
kind = "symlink"
path = "escape.gpg"
target = "../outside.gpg"
expected = "reject-symlink-escape"
[[synthetic_node]]
layout = "hostile"
kind = "symlink"
path = "directory-link"
target = "../outside"
expected = "reject-symlink-escape"
[[synthetic_node]]
layout = "hostile"
kind = "fifo"
path = "unsupported.gpg"
target = ""
expected = "reject-unsupported-file-type"
[[synthetic_node]]
layout = "collision"
kind = "entry-directory-collision"
path = "ambiguous"
target = "ambiguous.gpg"
expected = "require-trailing-slash-disambiguation"
[[interruption]]
id = "before-temp-fsync"
expected = "original-entry-intact"
[[interruption]]
id = "after-temp-fsync-before-rename"
expected = "original-entry-intact"
[[interruption]]
id = "after-rename-before-directory-fsync"
expected = "new-entry-complete"

View File

@@ -0,0 +1 @@
ref: refs/heads/main

View File

@@ -0,0 +1,6 @@
[core]
repositoryformatversion = 0
filemode = true
bare = true
[pass]
signcommits = false

View File

@@ -0,0 +1 @@
c1bec8815d40fab6e23162443ef55ce1c46bddf6

View File

@@ -0,0 +1 @@
ref: refs/heads/main

View File

@@ -0,0 +1,6 @@
[core]
repositoryformatversion = 0
filemode = true
bare = true
[pass]
signcommits = false

View File

@@ -0,0 +1,4 @@
­ŽK
Â0@]÷³ʘ¤IÝ®=A>S
ØD¦Sõø¢½»/ÖqÌʘ•0D¯·Q‡Áû¤u¯¬²
U?8§BLØYÝãûÆÏr« g®å"•ý•à”ß23ÁnXà<58>¹im.OÏi­Sˆ°FDlâoAè/±æ˜Ä™™Š@¬E¨Èu€\

View File

@@ -0,0 +1 @@
308371ba7fd91255536f4e6afee2e23a02af6915

View File

@@ -0,0 +1 @@
ref: refs/heads/main

View File

@@ -0,0 +1,6 @@
[core]
repositoryformatversion = 0
filemode = true
bare = true
[pass]
signcommits = false

View File

@@ -0,0 +1 @@
2c32c59867d2e4b4ab07ca8e9225e8eaa887a58e

View File

@@ -0,0 +1 @@
7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30

View File

@@ -0,0 +1,2 @@
Á^ëUˆ^É<
&@ÞLT5µ« w캩ÆÉaО<13>—Õ×¶Ú8%£–ï@0~±`ÇAEŠÄauÚ#‰¡kröøáÆ@ÓFãMiÜJ1Z†ðy ûFÛ…”}ý˜>Ò’G™M+n²¼/xu†=³uÙ;C¸ùV½n<C2BD>AÛ¬v!7ÿaÞ]XV@x]þˆ ð­ÁîÓÃæ“Ã{<GB<47>úïçe5u¨€]ƒ þf¶†eÝ è<0E>S¼D³žÐMÜ¥@ÂÍ_ÓO°`švÂþ¾·×…'þ.¿e|n„Ò!Ñ

View File

@@ -0,0 +1,2 @@
7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30
B37027B56FC406BD3F6A622B2AC03492B992D06F

View File

@@ -0,0 +1,3 @@
Á^ëUˆ^É<
&@ÆÀ€D¾bó
ƒ²ÁÎUšIJà­ x¸Ä[¦üO0l]¦žÍé2+û'µþ¡jfEÜd+ܲɜE“¦b™x4”Öð™Fõ¾Ðj‡'ÕM÷»šÁ^JÀ± ´Ö'<27>@«>Ó˜ÖÁ§·‚&Ί¡àc™Íë€Èâ¶d\ô_x0#À—Û´Œ6AûŠ˜þ¥X ·uë'c¿°(„<>¬hÈŽ¢<C5BD>´x<C2B4>`ËŃë—hrØÞmÒ_Á𨗲w:â^zûêó#7:ã§$U€¦ñ<C2A6><C3B1>êãJ×óÅEN6V•®Ol$<24>óÕ½ÅZ•5)µ–®þÁÅ^/™›.Š­£/Vn_[&ú%…Ý—ùýEÍ{Ž&œ

View File

@@ -0,0 +1 @@
B37027B56FC406BD3F6A622B2AC03492B992D06F

View File

@@ -0,0 +1 @@
Á^JÀ± ´Ö'<27>@˜RžEmF¶z}àŠÜwMþŽÖy³?T9ô L0÷Y³é¦ºõ"Gw©<77>/§á„ÒÓJfƒ¸Žy2àªVëtÏ«¹ô±XÐÆÉ;¾h¥ÒR!ÜŽbd'E^[Ì“_|“ã¿Åû¿cwk˜ 0j¡ÑÚ=xÔü8(ˆjqVý¿¢˜7®ž¸qW<71>KÐ.+'Î×ß9)<29>W

View File

@@ -0,0 +1,2 @@
а^КU┬^и<
&@╧c┐╩▓f≤╟}▐Т╔ц│[v┐ЦТк`%@Вvw0ЕвБ╜К┬©©SЭgя┬┤жbжЯ■l║≥Х  Р┐HЪhФGG╡╛.д┼≈╔Kырh∙╦й└ус·┬╝┌─PР Ь~x2Б°IБ4<÷╙%1Bпmиk?ЦYГ╒o▄з╩\л2млХTbеВ%┴╫F,■▀gВ∙И┬┐уЗ°Уhm┤C?{g╖ъ▐i╣╠z_gкщ▓Щр╚Д

View File

@@ -0,0 +1 @@
7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30

View File

@@ -0,0 +1 @@
B37027B56FC406BD3F6A622B2AC03492B992D06F

View File

@@ -0,0 +1,2 @@
Á^JÀ± ´Ö'<27>@YC
ÔEÕõÚËê˜l¼w`|÷ǵkPsSk©µ•H0Ð_ç´²ka¢g#Ôú‚õ¿½<O¼(jYÚЧ§ÄèäÑì-°Äj'³ñLÒHå:o£ |·Ö!=:c:ƒé²ãEÑw÷R¬»—cÕbÍYôvÐ.U¨Bä}ZÈB;ž.£éœ\V}ów ¼<C2BC>êyï`

View File

@@ -0,0 +1,21 @@
format = 1
[[project]]
name = "password-store"
version = "1.7.4"
reported_version = "1.7.4"
tag = "1.7.4"
commit = "1078f2514d579178d5df7042c6a790e9c9b731ad"
source = "https://git.zx2c4.com/password-store/"
behavior_source = "src/password-store.sh"
license = "GPL-2.0-or-later"
[[project]]
name = "pass-otp"
version = "1.2.0"
reported_version = "1.1.1"
tag = "v1.2.0"
commit = "1e9d10ca75ae1a8672a7f192809713463657778e"
source = "https://github.com/tadfisher/pass-otp"
behavior_source = "otp.bash"
license = "GPL-3.0-only"

View File

@@ -0,0 +1,406 @@
#![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()))
}

View File

@@ -0,0 +1 @@
pub mod compatibility;