Cross builds, packaging split, CI, fuzzing, supply-chain policy

build.sh builds the Rust targets with the Go-era names, refuses an OUT
that is /, $HOME or the tree, checks static linking and names the Go
targets Rust cannot build. packaging/package.sh builds and inspects
packages without signing or publishing; repo.sh reuses it. Seven fuzz
targets (one found a URL-parser panic, fixed here with a regression
test), cargo-deny policy, CI for lint, tests on Linux/macOS/Windows,
cross builds with qemu runs, supply chain and fuzzing. Compatibility
specification and security notes.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Andy Dixon
2026-10-08 11:44:23 +01:00
co-authored by Claude
parent a98931779a
commit cddde5ca52
27 changed files with 2347 additions and 114 deletions
+1283
View File
File diff suppressed because it is too large Load Diff
+68
View File
@@ -0,0 +1,68 @@
[package]
name = "vault-fuzz"
version = "0.0.0"
publish = false
edition = "2024"
[package.metadata]
cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4"
tar = { version = "0.4.46", default-features = false }
vault = { path = ".." }
# Not part of the main package's workspace.
[workspace]
[profile.release]
debug = 1
[[bin]]
name = "header"
path = "fuzz_targets/header.rs"
test = false
doc = false
bench = false
[[bin]]
name = "stream"
path = "fuzz_targets/stream.rs"
test = false
doc = false
bench = false
[[bin]]
name = "keyfile"
path = "fuzz_targets/keyfile.rs"
test = false
doc = false
bench = false
[[bin]]
name = "record"
path = "fuzz_targets/record.rs"
test = false
doc = false
bench = false
[[bin]]
name = "names"
path = "fuzz_targets/names.rs"
test = false
doc = false
bench = false
[[bin]]
name = "html"
path = "fuzz_targets/html.rs"
test = false
doc = false
bench = false
[[bin]]
name = "tar"
path = "fuzz_targets/tar.rs"
test = false
doc = false
bench = false
+15
View File
@@ -0,0 +1,15 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//! The Fury header parser never panics, and accepts only canonical headers:
//! whatever it accepts re-serialises to exactly the bytes it consumed.
#![no_main]
use std::io::Read;
use vault::crypto::header;
libfuzzer_sys::fuzz_target!(|data: &[u8]| {
if let Ok((h, mut rest)) = header::parse(data) {
let mut tail = Vec::new();
rest.read_to_end(&mut tail).unwrap();
let consumed = &data[..data.len() - tail.len()];
assert_eq!(h.marshal(), consumed, "non-canonical header accepted");
}
});
+15
View File
@@ -0,0 +1,15 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//! The mirror's HTML and CSS rewriters never panic, and rewriting nothing
//! returns the input byte for byte.
#![no_main]
use vault::net::html;
libfuzzer_sys::fuzz_target!(|data: &[u8]| {
let _ = html::tokenize(data);
assert_eq!(html::rewrite_html(data, &mut |_| None, &mut |_| None), data);
assert_eq!(html::rewrite_css(data, false, &mut |_| None), data);
let _ = html::rewrite_html(data, &mut |_| None, &mut |u| Some(format!("x/{}", u.len())));
if let Ok(s) = std::str::from_utf8(data) {
let _ = vault::net::Url::parse(s).map(|u| u.join("../a?b#c"));
}
});
+11
View File
@@ -0,0 +1,11 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//! PEM decoding never panics, and decoding what it encoded is the identity.
#![no_main]
use vault::keyfile::{decode_pem, encode_pem, parse_body};
libfuzzer_sys::fuzz_target!(|data: &[u8]| {
if let Some(body) = decode_pem(data) {
let _ = parse_body(&body);
assert_eq!(decode_pem(encode_pem(&body).as_bytes()), Some(body));
}
});
+18
View File
@@ -0,0 +1,18 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//! Path validation: a valid vault name is clean, relative and confined; the
//! tar name check only ever yields valid-shaped relative names.
#![no_main]
use vault::store::VaultName;
libfuzzer_sys::fuzz_target!(|data: &[u8]| {
let Ok(s) = std::str::from_utf8(data) else { return };
if let Some(n) = VaultName::new(s) {
let n = n.as_str();
assert_eq!(vault::util::clean(n), n);
assert!(!n.starts_with('/') && !n.contains('\\') && !n.split('/').any(|c| c == ".." || c == "." || c.is_empty()));
assert!(n.len() <= 1024);
}
if let Some(n) = vault::absorb::safe_name(s) {
assert!(!n.is_empty() && !n.starts_with('/') && !n.split('/').any(|c| c == ".."));
}
});
+15
View File
@@ -0,0 +1,15 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//! Record JSON parsing never panics; an accepted record re-encodes (Go's
//! JSON) to something that parses back to the same values.
#![no_main]
use vault::store::{Id, Record};
libfuzzer_sys::fuzz_target!(|data: &[u8]| {
if let Ok(r) = Record::from_json(data, Id([0; 16])) {
let Some(j) = r.to_json() else { return };
let r2 = Record::from_json(&j, Id([0; 16])).expect("own encoding parses");
assert_eq!(r.name, r2.name);
assert_eq!((r.size, r.mode, r.modified, r.added), (r2.size, r2.mode, r2.modified, r2.added));
assert!(r.seed.same_as(&r2.seed));
}
});
+46
View File
@@ -0,0 +1,46 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//! The streaming, in-memory and random-access STREAM readers agree on every
//! input: the same plaintext or all reject. Inputs are mostly forgeries;
//! the first byte selects a mode that starts from a valid stream instead.
#![no_main]
use std::io::{Read, Write};
use vault::crypto::stream::{StreamReader, StreamReaderAt, StreamWriter, open_all};
const KEY: [u8; 32] = [7; 32];
libfuzzer_sys::fuzz_target!(|data: &[u8]| {
let Some((&mode, rest)) = data.split_first() else { return };
let input = if mode & 1 == 1 {
// A valid stream of the given plaintext, then a mutation.
let mut w = StreamWriter::new(&KEY, Vec::new());
let n = rest.len().min(200_000);
w.write_all(&rest[..n]).unwrap();
let mut c = w.finish().unwrap();
if mode & 2 == 2 && !c.is_empty() {
let i = (mode as usize * 7919) % c.len();
c[i] ^= mode | 1;
}
if mode & 4 == 4 {
c.truncate(c.len().saturating_sub(mode as usize % 40));
}
c
} else {
rest.to_vec()
};
let mut a = Vec::new();
let streaming = StreamReader::new(&KEY, &input[..]).read_to_end(&mut a).map(|_| a);
let mut b = input.clone();
let whole = open_all(&KEY, &mut b).map(|n| b[..n].to_vec());
assert_eq!(streaming.is_ok(), whole.is_ok());
if let (Ok(x), Ok(y)) = (&streaming, &whole) {
assert_eq!(x, y);
}
if let Ok(r) = StreamReaderAt::new(&KEY, &input[..], input.len() as u64) {
let mut out = vec![0u8; r.size() as usize];
let got = r.read_at(&mut out, 0).map(|n| out[..n].to_vec());
assert_eq!(got.is_ok(), whole.is_ok());
if let (Ok(x), Ok(y)) = (got, whole) {
assert_eq!(x, y);
}
}
});
+19
View File
@@ -0,0 +1,19 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//! Walking arbitrary tar input as absorb does never panics, and every name
//! it would store is confined.
#![no_main]
use std::io::Read;
libfuzzer_sys::fuzz_target!(|data: &[u8]| {
let mut a = tar::Archive::new(data);
let Ok(entries) = a.entries() else { return };
for e in entries.take(64) {
let Ok(mut e) = e else { return };
let name = e.path_bytes().into_owned();
if let Some(n) = std::str::from_utf8(&name).ok().and_then(vault::absorb::safe_name) {
assert!(!n.split('/').any(|c| c == ".."));
}
let mut sink = Vec::new();
let _ = e.by_ref().take(1 << 20).read_to_end(&mut sink);
}
});