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
+4 -3
View File
@@ -90,9 +90,10 @@ fn mtime<R: Read>(entry: &mut tar::Entry<R>) -> Result<Instant> {
if let Ok(Some(exts)) = entry.pax_extensions() {
for ext in exts.flatten() {
if ext.key_bytes() == b"mtime"
&& let Some(t) = std::str::from_utf8(ext.value_bytes()).ok().and_then(parse_pax_time) {
return Ok(t);
}
&& let Some(t) = std::str::from_utf8(ext.value_bytes()).ok().and_then(parse_pax_time)
{
return Ok(t);
}
}
}
let secs = entry
+17 -7
View File
@@ -24,12 +24,18 @@ use std::path::{Path, PathBuf};
use std::time::Duration;
pub const AGENT_CMD: &str = "__agent";
#[cfg_attr(not(unix), allow(dead_code))]
const GET: u8 = b'g';
#[cfg_attr(not(unix), allow(dead_code))]
const QUIT: u8 = b'q';
#[cfg_attr(not(unix), allow(dead_code))]
const REPLY: usize = 2 + crate::crypto::kem::SEED_SIZE;
#[cfg_attr(not(unix), allow(dead_code))]
const TIMEOUT: Duration = Duration::from_secs(2);
/// How long `unlock` waits for a starting agent to say it is ready.
#[cfg_attr(not(unix), allow(dead_code))]
const START_TIMEOUT: Duration = Duration::from_secs(10);
#[cfg_attr(not(unix), allow(dead_code))]
const SECRET_ENVS: [&str; 2] = ["VAULT_PASSPHRASE", "VAULT_DECOY_PASSPHRASE"];
/// Go's `filepath.Abs`: lexical, with `os.Getwd`'s preference for `$PWD`.
@@ -53,10 +59,12 @@ fn go_getwd() -> std::io::Result<PathBuf> {
let dot = std::fs::metadata(".")?;
if let Some(pwd) = std::env::var_os("PWD").map(PathBuf::from)
&& pwd.is_absolute()
&& let Ok(m) = std::fs::metadata(&pwd)
&& m.dev() == dot.dev() && m.ino() == dot.ino() {
return Ok(pwd);
}
&& let Ok(m) = std::fs::metadata(&pwd)
&& m.dev() == dot.dev()
&& m.ino() == dot.ino()
{
return Ok(pwd);
}
std::env::current_dir()
}
@@ -265,9 +273,11 @@ mod imp {
// Remove the socket only if it is still the one this agent made.
use std::os::unix::fs::MetadataExt;
if let Ok(m) = std::fs::symlink_metadata(sock)
&& m.ino() == ours.ino() && m.dev() == ours.dev() {
let _ = std::fs::remove_file(sock);
}
&& m.ino() == ours.ino()
&& m.dev() == ours.dev()
{
let _ = std::fs::remove_file(sock);
}
};
{
use std::os::unix::fs::PermissionsExt;
+8 -6
View File
@@ -94,9 +94,10 @@ macro_rules! say {
fn out(line: &str) {
let mut o = std::io::stdout().lock();
if let Err(e) = o.write_all(line.as_bytes()).and_then(|_| o.flush())
&& e.kind() == std::io::ErrorKind::BrokenPipe {
std::process::exit(128 + 13);
}
&& e.kind() == std::io::ErrorKind::BrokenPipe
{
std::process::exit(128 + 13);
}
}
#[derive(Default)]
@@ -319,9 +320,10 @@ fn default_path(flag: &Option<String>, env: &str, name: &str) -> Result<PathBuf>
/// environment, else from the key file with the passphrase.
fn key_identity(key: &Path) -> Result<crate::crypto::kem::Identity> {
if std::env::var_os(PASSPHRASE_ENV).is_none()
&& let Ok(id) = agent::identity(key) {
return Ok(id);
}
&& let Ok(id) = agent::identity(key)
{
return Ok(id);
}
let pass = read_passphrase("Enter passphrase:")?;
keyfile::unlock(key, pass.expose()).map_err(|e| Error::new(e.to_string()))
}
+2 -2
View File
@@ -29,8 +29,8 @@
//! it; that file holds plaintext until it is removed, on every path, once
//! stored. HTML and CSS for rewriting are held in memory instead.
mod html;
mod url;
pub mod html;
pub mod url;
#[cfg(test)]
mod tests;
+15 -1
View File
@@ -81,7 +81,12 @@ fn unescape(s: &str, mode: Mode) -> Result<String, String> {
b'%' => {
let (h, l) = match (b.get(i + 1).copied().and_then(unhex), b.get(i + 2).copied().and_then(unhex)) {
(Some(h), Some(l)) => (h, l),
_ => return Err(format!("invalid URL escape {:?}", &s[i..(i + 3).min(s.len())])),
_ => {
return Err(format!(
"invalid URL escape {:?}",
String::from_utf8_lossy(&b[i..(i + 3).min(b.len())])
));
}
};
if mode == Mode::Host && h < 8 && &s[i..i + 3] != "%25" {
return Err(format!("invalid URL escape {:?}", &s[i..i + 3]));
@@ -447,6 +452,15 @@ pub(crate) mod tests {
assert_eq!(super::super::key(&u), s("key"), "{ctx} key");
}
/// Found by fuzzing: a bad escape before a multi-byte character used to
/// slice through the character and panic.
#[test]
fn bad_escape_before_multibyte_character() {
assert!(Url::parse("/K%\u{8e3}").is_err());
assert!(Url::parse("http://h/%é").is_err());
assert!(Url::parse("http://h/%2").is_err());
}
#[test]
fn parse_matches_go() {
for v in vectors()["parse"].as_array().unwrap() {
+1
View File
@@ -36,6 +36,7 @@ pub fn create_private_dir_all(p: &Path) -> io::Result<()> {
}
pub fn create_private_dir(p: &Path) -> io::Result<()> {
#[cfg_attr(not(unix), allow(unused_mut))]
let mut b = std::fs::DirBuilder::new();
#[cfg(unix)]
b.mode(0o700);
+1
View File
@@ -14,6 +14,7 @@
//! `/proc/PID/mem` access. Other Unix systems get the rlimit only. Windows
//! is not supported by the agent (see SECURITY.md).
#[cfg(unix)]
use std::io;
#[cfg(unix)]