Establish pass compatibility fixture harness (#1)
This commit is contained in:
1217
Cargo.lock
generated
1217
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
571
crates/storage/examples/refresh_compatibility_fixtures.rs
Normal file
571
crates/storage/examples/refresh_compatibility_fixtures.rs
Normal 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(())
|
||||
}
|
||||
236
crates/storage/tests/compatibility_fixtures.rs
Normal file
236
crates/storage/tests/compatibility_fixtures.rs
Normal 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(())
|
||||
}
|
||||
52
crates/storage/tests/fixtures/compatibility/README.md
vendored
Normal file
52
crates/storage/tests/fixtures/compatibility/README.md
vendored
Normal 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.
|
||||
1205
crates/storage/tests/fixtures/compatibility/behavior.toml
vendored
Normal file
1205
crates/storage/tests/fixtures/compatibility/behavior.toml
vendored
Normal file
File diff suppressed because it is too large
Load Diff
3
crates/storage/tests/fixtures/compatibility/expected/basic/email/personal.txt
vendored
Normal file
3
crates/storage/tests/fixtures/compatibility/expected/basic/email/personal.txt
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
correct horse fixture
|
||||
login: alice@example.test
|
||||
url: https://example.test
|
||||
1
crates/storage/tests/fixtures/compatibility/expected/basic/otp/hotp.txt
vendored
Normal file
1
crates/storage/tests/fixtures/compatibility/expected/basic/otp/hotp.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
otpauth://hotp/IronStorage:counter?secret=JBSWY3DPEHPK3PXP&issuer=IronStorage&counter=0&digits=8
|
||||
2
crates/storage/tests/fixtures/compatibility/expected/basic/otp/totp.txt
vendored
Normal file
2
crates/storage/tests/fixtures/compatibility/expected/basic/otp/totp.txt
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
fixture-password
|
||||
otpauth://totp/IronStorage:alice%40example.test?secret=JBSWY3DPEHPK3PXP&issuer=IronStorage&algorithm=SHA1&digits=6&period=30
|
||||
2
crates/storage/tests/fixtures/compatibility/expected/basic/shared/multiple.txt
vendored
Normal file
2
crates/storage/tests/fixtures/compatibility/expected/basic/shared/multiple.txt
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
shared-fixture-password
|
||||
recipients: alice,bob
|
||||
2
crates/storage/tests/fixtures/compatibility/expected/basic/team/service.txt
vendored
Normal file
2
crates/storage/tests/fixtures/compatibility/expected/basic/team/service.txt
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
team-fixture-password
|
||||
owner: bob
|
||||
3
crates/storage/tests/fixtures/compatibility/expected/basic/unicode/咖啡.txt
vendored
Normal file
3
crates/storage/tests/fixtures/compatibility/expected/basic/unicode/咖啡.txt
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
pässwörd-猫
|
||||
login: 用户@example.test
|
||||
notes: café
|
||||
1
crates/storage/tests/fixtures/compatibility/expected/nested/outer/inner/nested-entry.txt
vendored
Normal file
1
crates/storage/tests/fixtures/compatibility/expected/nested/outer/inner/nested-entry.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
inner repository entry
|
||||
1
crates/storage/tests/fixtures/compatibility/expected/nested/outer/root-entry.txt
vendored
Normal file
1
crates/storage/tests/fixtures/compatibility/expected/nested/outer/root-entry.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
outer repository entry
|
||||
105
crates/storage/tests/fixtures/compatibility/generated.toml
vendored
Normal file
105
crates/storage/tests/fixtures/compatibility/generated.toml
vendored
Normal 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"]
|
||||
13
crates/storage/tests/fixtures/compatibility/keys/alice-public.asc
vendored
Normal file
13
crates/storage/tests/fixtures/compatibility/keys/alice-public.asc
vendored
Normal 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-----
|
||||
BIN
crates/storage/tests/fixtures/compatibility/keys/alice-public.pgp
vendored
Normal file
BIN
crates/storage/tests/fixtures/compatibility/keys/alice-public.pgp
vendored
Normal file
Binary file not shown.
17
crates/storage/tests/fixtures/compatibility/keys/alice-secret.asc
vendored
Normal file
17
crates/storage/tests/fixtures/compatibility/keys/alice-secret.asc
vendored
Normal 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-----
|
||||
BIN
crates/storage/tests/fixtures/compatibility/keys/alice-secret.pgp
vendored
Normal file
BIN
crates/storage/tests/fixtures/compatibility/keys/alice-secret.pgp
vendored
Normal file
Binary file not shown.
13
crates/storage/tests/fixtures/compatibility/keys/bob-public.asc
vendored
Normal file
13
crates/storage/tests/fixtures/compatibility/keys/bob-public.asc
vendored
Normal 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-----
|
||||
BIN
crates/storage/tests/fixtures/compatibility/keys/bob-public.pgp
vendored
Normal file
BIN
crates/storage/tests/fixtures/compatibility/keys/bob-public.pgp
vendored
Normal file
Binary file not shown.
17
crates/storage/tests/fixtures/compatibility/keys/bob-secret.asc
vendored
Normal file
17
crates/storage/tests/fixtures/compatibility/keys/bob-secret.asc
vendored
Normal 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-----
|
||||
BIN
crates/storage/tests/fixtures/compatibility/keys/bob-secret.pgp
vendored
Normal file
BIN
crates/storage/tests/fixtures/compatibility/keys/bob-secret.pgp
vendored
Normal file
Binary file not shown.
51
crates/storage/tests/fixtures/compatibility/layouts.toml
vendored
Normal file
51
crates/storage/tests/fixtures/compatibility/layouts.toml
vendored
Normal 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"
|
||||
1
crates/storage/tests/fixtures/compatibility/repositories/basic.git/HEAD
vendored
Normal file
1
crates/storage/tests/fixtures/compatibility/repositories/basic.git/HEAD
vendored
Normal file
@@ -0,0 +1 @@
|
||||
ref: refs/heads/main
|
||||
6
crates/storage/tests/fixtures/compatibility/repositories/basic.git/config
vendored
Normal file
6
crates/storage/tests/fixtures/compatibility/repositories/basic.git/config
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
[core]
|
||||
repositoryformatversion = 0
|
||||
filemode = true
|
||||
bare = true
|
||||
[pass]
|
||||
signcommits = false
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
1
crates/storage/tests/fixtures/compatibility/repositories/basic.git/refs/heads/main
vendored
Normal file
1
crates/storage/tests/fixtures/compatibility/repositories/basic.git/refs/heads/main
vendored
Normal file
@@ -0,0 +1 @@
|
||||
c1bec8815d40fab6e23162443ef55ce1c46bddf6
|
||||
1
crates/storage/tests/fixtures/compatibility/repositories/inner.git/HEAD
vendored
Normal file
1
crates/storage/tests/fixtures/compatibility/repositories/inner.git/HEAD
vendored
Normal file
@@ -0,0 +1 @@
|
||||
ref: refs/heads/main
|
||||
6
crates/storage/tests/fixtures/compatibility/repositories/inner.git/config
vendored
Normal file
6
crates/storage/tests/fixtures/compatibility/repositories/inner.git/config
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
[core]
|
||||
repositoryformatversion = 0
|
||||
filemode = true
|
||||
bare = true
|
||||
[pass]
|
||||
signcommits = false
|
||||
Binary file not shown.
@@ -0,0 +1,4 @@
|
||||
xœŽK
|
||||
Â0@]÷³Ê˜¤IÝ®=A>S
|
||||
ØD¦Sõø¢½‚»/ÖqÌʘ•0D¯·Q‡Áû¤u¯¬²
|
||||
U?8§BLØYÝãûÆÏr«g®å"•ý•à”ß23ÁnXà<58>¹–i‘m.OÏi‡Sˆ°FDlâoAè/±æ˜Ä™™Š@¬E¨Èu€\
|
||||
Binary file not shown.
Binary file not shown.
1
crates/storage/tests/fixtures/compatibility/repositories/inner.git/refs/heads/main
vendored
Normal file
1
crates/storage/tests/fixtures/compatibility/repositories/inner.git/refs/heads/main
vendored
Normal file
@@ -0,0 +1 @@
|
||||
308371ba7fd91255536f4e6afee2e23a02af6915
|
||||
1
crates/storage/tests/fixtures/compatibility/repositories/outer.git/HEAD
vendored
Normal file
1
crates/storage/tests/fixtures/compatibility/repositories/outer.git/HEAD
vendored
Normal file
@@ -0,0 +1 @@
|
||||
ref: refs/heads/main
|
||||
6
crates/storage/tests/fixtures/compatibility/repositories/outer.git/config
vendored
Normal file
6
crates/storage/tests/fixtures/compatibility/repositories/outer.git/config
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
[core]
|
||||
repositoryformatversion = 0
|
||||
filemode = true
|
||||
bare = true
|
||||
[pass]
|
||||
signcommits = false
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
1
crates/storage/tests/fixtures/compatibility/repositories/outer.git/refs/heads/main
vendored
Normal file
1
crates/storage/tests/fixtures/compatibility/repositories/outer.git/refs/heads/main
vendored
Normal file
@@ -0,0 +1 @@
|
||||
2c32c59867d2e4b4ab07ca8e9225e8eaa887a58e
|
||||
1
crates/storage/tests/fixtures/compatibility/stores/basic/.gpg-id
vendored
Normal file
1
crates/storage/tests/fixtures/compatibility/stores/basic/.gpg-id
vendored
Normal file
@@ -0,0 +1 @@
|
||||
7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30
|
||||
BIN
crates/storage/tests/fixtures/compatibility/stores/basic/.gpg-id.sig
vendored
Normal file
BIN
crates/storage/tests/fixtures/compatibility/stores/basic/.gpg-id.sig
vendored
Normal file
Binary file not shown.
BIN
crates/storage/tests/fixtures/compatibility/stores/basic/email/personal.gpg
vendored
Normal file
BIN
crates/storage/tests/fixtures/compatibility/stores/basic/email/personal.gpg
vendored
Normal file
Binary file not shown.
2
crates/storage/tests/fixtures/compatibility/stores/basic/otp/hotp.gpg
vendored
Normal file
2
crates/storage/tests/fixtures/compatibility/stores/basic/otp/hotp.gpg
vendored
Normal 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>úï’JÓç‘e5u¨€]ƒþf¶†eÝ è<0E>S¼D³žÐMÜ¥@ÂÍ_ÓO‘°`švÂþ¾·×…'þ.¿e|n„Ò!Ñ
|
||||
BIN
crates/storage/tests/fixtures/compatibility/stores/basic/otp/totp.gpg
vendored
Normal file
BIN
crates/storage/tests/fixtures/compatibility/stores/basic/otp/totp.gpg
vendored
Normal file
Binary file not shown.
2
crates/storage/tests/fixtures/compatibility/stores/basic/shared/.gpg-id
vendored
Normal file
2
crates/storage/tests/fixtures/compatibility/stores/basic/shared/.gpg-id
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30
|
||||
B37027B56FC406BD3F6A622B2AC03492B992D06F
|
||||
3
crates/storage/tests/fixtures/compatibility/stores/basic/shared/multiple.gpg
vendored
Normal file
3
crates/storage/tests/fixtures/compatibility/stores/basic/shared/multiple.gpg
vendored
Normal 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#À—Û´Œ6‹AûŠ˜þ¥X ·uë'c¿°(„<>¬hÈŽ¢<C5BD>´x<C2B4>`ËŃë—hrØÞmÒ_Á𨗲w:â^–>ûzûêó#7:ã§$U€¦ñ<C2A6><C3B1>êãJ×óÅEN6V•®Ol$<24>óÕ½ÅZ•5)µ–®þÁÅ^/™›.Š£/Vn_[&ú%…Ý‚—ùýEÍ{Ž&œ
|
||||
1
crates/storage/tests/fixtures/compatibility/stores/basic/team/.gpg-id
vendored
Normal file
1
crates/storage/tests/fixtures/compatibility/stores/basic/team/.gpg-id
vendored
Normal file
@@ -0,0 +1 @@
|
||||
B37027B56FC406BD3F6A622B2AC03492B992D06F
|
||||
BIN
crates/storage/tests/fixtures/compatibility/stores/basic/team/.gpg-id.sig
vendored
Normal file
BIN
crates/storage/tests/fixtures/compatibility/stores/basic/team/.gpg-id.sig
vendored
Normal file
Binary file not shown.
1
crates/storage/tests/fixtures/compatibility/stores/basic/team/service.gpg
vendored
Normal file
1
crates/storage/tests/fixtures/compatibility/stores/basic/team/service.gpg
vendored
Normal 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
|
||||
2
crates/storage/tests/fixtures/compatibility/stores/basic/unicode/咖啡.gpg
vendored
Normal file
2
crates/storage/tests/fixtures/compatibility/stores/basic/unicode/咖啡.gpg
vendored
Normal 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кщ▓Щр╚Д
|
||||
1
crates/storage/tests/fixtures/compatibility/stores/nested/outer/.gpg-id
vendored
Normal file
1
crates/storage/tests/fixtures/compatibility/stores/nested/outer/.gpg-id
vendored
Normal file
@@ -0,0 +1 @@
|
||||
7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30
|
||||
1
crates/storage/tests/fixtures/compatibility/stores/nested/outer/inner/.gpg-id
vendored
Normal file
1
crates/storage/tests/fixtures/compatibility/stores/nested/outer/inner/.gpg-id
vendored
Normal file
@@ -0,0 +1 @@
|
||||
B37027B56FC406BD3F6A622B2AC03492B992D06F
|
||||
2
crates/storage/tests/fixtures/compatibility/stores/nested/outer/inner/nested-entry.gpg
vendored
Normal file
2
crates/storage/tests/fixtures/compatibility/stores/nested/outer/inner/nested-entry.gpg
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
Á^JÀ± ´Ö'<27>@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¼‚<C2BC>êyï`
|
||||
BIN
crates/storage/tests/fixtures/compatibility/stores/nested/outer/root-entry.gpg
vendored
Normal file
BIN
crates/storage/tests/fixtures/compatibility/stores/nested/outer/root-entry.gpg
vendored
Normal file
Binary file not shown.
21
crates/storage/tests/fixtures/compatibility/upstream.toml
vendored
Normal file
21
crates/storage/tests/fixtures/compatibility/upstream.toml
vendored
Normal 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"
|
||||
406
crates/storage/tests/support/compatibility.rs
Normal file
406
crates/storage/tests/support/compatibility.rs
Normal 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()))
|
||||
}
|
||||
1
crates/storage/tests/support/mod.rs
Normal file
1
crates/storage/tests/support/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod compatibility;
|
||||
Reference in New Issue
Block a user