Establish pass compatibility fixture harness (#1)
This commit is contained in:
1 parent
fa3bb44771
commit
41e56367bd
66 files changed
+3978
-18
No files matched your search
@@ -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"
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,3 @@
|
||||
correct horse fixture
|
||||
login: alice@example.test
|
||||
url: https://example.test
|
||||
@@ -0,0 +1 @@
|
||||
otpauth://hotp/IronStorage:counter?secret=JBSWY3DPEHPK3PXP&issuer=IronStorage&counter=0&digits=8
|
||||
@@ -0,0 +1,2 @@
|
||||
fixture-password
|
||||
otpauth://totp/IronStorage:alice%40example.test?secret=JBSWY3DPEHPK3PXP&issuer=IronStorage&algorithm=SHA1&digits=6&period=30
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
shared-fixture-password
|
||||
recipients: alice,bob
|
||||
@@ -0,0 +1,2 @@
|
||||
team-fixture-password
|
||||
owner: bob
|
||||
@@ -0,0 +1,3 @@
|
||||
pässwörd-猫
|
||||
login: 用户@example.test
|
||||
notes: café
|
||||
+1
@@ -0,0 +1 @@
|
||||
inner repository entry
|
||||
+1
@@ -0,0 +1 @@
|
||||
outer repository entry
|
||||
@@ -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"]
|
||||
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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"
|
||||
@@ -0,0 +1 @@
|
||||
ref: refs/heads/main
|
||||
@@ -0,0 +1,6 @@
|
||||
[core]
|
||||
repositoryformatversion = 0
|
||||
filemode = true
|
||||
bare = true
|
||||
[pass]
|
||||
signcommits = false
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
||||
c1bec8815d40fab6e23162443ef55ce1c46bddf6
|
||||
@@ -0,0 +1 @@
|
||||
ref: refs/heads/main
|
||||
@@ -0,0 +1,6 @@
|
||||
[core]
|
||||
repositoryformatversion = 0
|
||||
filemode = true
|
||||
bare = true
|
||||
[pass]
|
||||
signcommits = false
|
||||
BIN
Binary file not shown.
+4
@@ -0,0 +1,4 @@
|
||||
xœŽK
|
||||
Â0@]÷³Ê˜¤IÝ®=A>S
ØD¦Sõø¢½‚»/ÖqÌʘ•0D¯·Q‡Áû¤u¯¬²
|
||||
U?8§BLØYÝãûÆÏr«g®å"•ý•à”ß23ÁnXà�¹–i‘m.OÏi‡Sˆ°FDlâoAè/±æ˜Ä™™Š@¬E¨Èu€\
|
||||
1<ü4½*'ø–¨m>.ET
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
||||
308371ba7fd91255536f4e6afee2e23a02af6915
|
||||
@@ -0,0 +1 @@
|
||||
ref: refs/heads/main
|
||||
@@ -0,0 +1,6 @@
|
||||
[core]
|
||||
repositoryformatversion = 0
|
||||
filemode = true
|
||||
bare = true
|
||||
[pass]
|
||||
signcommits = false
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
||||
2c32c59867d2e4b4ab07ca8e9225e8eaa887a58e
|
||||
@@ -0,0 +1 @@
|
||||
7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,2 @@
|
||||
Á^ëUˆ^É<
|
||||
&@ÞLT5µ–« w캩ÆÉaО�—Õ×¶Ú8%£–ï@0~±`ÇAEŠÄauÚ#‰¡‚kröøáÆ@ÓFãMiÜJ1Z›†ðy ûFÛ…”}ý˜>Ò’G™M+n²¼/xu†=³uÙ;C¸ùV½n�AÛ¬v!7ÿaÞ]XV@x]þˆ ðÁîÓÃæ“Ã{<GB�úï’JÓç‘e5u¨€]ƒþf¶†eÝ è�S¼D³žÐMÜ¥@ÂÍ_ÓO‘°`švÂþ¾·×…'þ.¿e|n„Ò!Ñ
måDR›Ç
|
||||
Binary file not shown.
@@ -0,0 +1,2 @@
|
||||
7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30
|
||||
B37027B56FC406BD3F6A622B2AC03492B992D06F
|
||||
@@ -0,0 +1,3 @@
|
||||
Á^ëUˆ^É<
|
||||
&@ÆÀ€–D¾bó
|
||||
ƒ–²ÁÎUšIJà ‚x¸Ä[¦üO0l‹]¦žÍé2+û'µþ¡jfEÜd+ܲɜE“¦b™x4”Öð™Fõ¾Ðj‡'ÕM÷»šÁ^JÀ± ´Ö'�@«>Ó˜ÖÁ§·‚&Ί¡àc™Íë€Èâ¶d\ô_x0#À—Û´Œ6‹AûŠ˜þ¥X ·uë'c¿°(„�¬hÈŽ¢�´x�`ËŃë—hrØÞmÒ_Á𨗲w:â^–>ûzûêó#7:ã§$U€¦ñ��êãJ×óÅEN6V•®Ol$�óÕ½ÅZ•5)µ–®þÁÅ^/™›.Š£/Vn_[&ú%…Ý‚—ùýEÍ{Ž&œ
|
||||
@@ -0,0 +1 @@
|
||||
B37027B56FC406BD3F6A622B2AC03492B992D06F
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
Á^JÀ± ´Ö'�@˜RžEmF¶z}àŠÜwMþŽÖy³?T9ôL0÷Y³é¦ºõ"‹Gw©�/§á„ÒÓJfƒ¸Žy2àªVëtÏ«¹ô±XÐÆÉ;¾h¥ÒR!ÜŽbd‘'E^[Ì“_|“ã¿Åû¿cwk˜ 0j¡ÑÚ=xÔü8(ˆjqVý¿¢˜7®ž¸qW�K‘Ð.+'Î×ß9)�W
åoíUô9
|
||||
@@ -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кщ▓Щр╚Д
|
||||
@@ -0,0 +1 @@
|
||||
7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30
|
||||
@@ -0,0 +1 @@
|
||||
B37027B56FC406BD3F6A622B2AC03492B992D06F
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
Á^JÀ± ´Ö'�@YC
|
||||
ÔEÕõÚËêPô˜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¼‚�êyï`
|
||||
BIN
Binary file not shown.
@@ -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"
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod compatibility;
|
||||
Reference in new issue
Block a user