use std::io;
use std::path::Path;
pub use blake3::Hash;
pub fn blake3_file(path: &Path) -> io::Result<Hash> {
let mut hasher = blake3::Hasher::new();
hasher.update_mmap_rayon(path)?;
Ok(hasher.finalize())
}
#[cfg(feature = "client")]
const MAX_STAMP_LEN: usize = 64;
#[cfg(feature = "client")]
fn is_safe_stamp(value: &str) -> bool {
!value.is_empty()
&& value.len() <= MAX_STAMP_LEN
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_' | b'+'))
}
#[cfg(feature = "client")]
fn file_hex16(path: &Path) -> io::Result<String> {
Ok(blake3_file(path)?.to_hex()[..16].to_owned())
}
#[cfg(feature = "client")]
fn stamp_from_hex(version: &str, hex16: &str) -> String {
format!("{version}-{hex16}")
}
#[cfg(feature = "client")]
fn resolve_stamp(
dev_scope: bool,
inherited: Option<String>,
compute: impl FnOnce() -> io::Result<String>,
) -> io::Result<Option<String>> {
if !dev_scope {
return Ok(None);
}
match inherited {
Some(value) if is_safe_stamp(&value) => Ok(Some(value)),
_ => compute().map(Some),
}
}
#[cfg(feature = "client")]
pub fn daemon_identity_stamp(version: &str) -> io::Result<Option<String>> {
static EXE_HEX: std::sync::OnceLock<String> = std::sync::OnceLock::new();
let dev_scope = crate::env_vars::DAEMON_SCOPE
.text()
.is_some_and(|scope| scope.eq_ignore_ascii_case("dev"));
resolve_stamp(
dev_scope,
crate::env_vars::DAEMON_IDENTITY_STAMP.text(),
|| {
let hex = match EXE_HEX.get() {
Some(hex) => hex.clone(),
None => {
let hex = file_hex16(&std::env::current_exe()?)?;
EXE_HEX.get_or_init(|| hex).clone()
}
};
Ok(stamp_from_hex(version, &hex))
},
)
}
#[cfg(feature = "client")]
pub fn daemon_identity_stamp_env(version: &str) -> io::Result<Option<(&'static str, String)>> {
Ok(daemon_identity_stamp(version)?
.map(|value| (crate::env_vars::DAEMON_IDENTITY_STAMP.name, value)))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn write_temp(name: &str, bytes: &[u8]) -> std::path::PathBuf {
let mut path = std::env::temp_dir();
path.push(format!(
"rp-content-hash-{}-{}-{}",
std::process::id(),
name,
bytes.len()
));
let mut f = std::fs::File::create(&path).expect("create temp file");
f.write_all(bytes).expect("write temp file");
f.flush().expect("flush temp file");
path
}
#[test]
fn hashes_the_bytes_not_the_path() {
let bytes = b"the quick brown fox jumps over the lazy dog";
let path = write_temp("bytes", bytes);
let got = blake3_file(&path).expect("hash temp file");
std::fs::remove_file(&path).ok();
assert_eq!(got, blake3::hash(bytes));
}
#[test]
fn same_contents_at_different_paths_hash_equal() {
let bytes = b"identical contents";
let a = write_temp("dup-a", bytes);
let b = write_temp("dup-b", bytes);
let ha = blake3_file(&a).expect("hash a");
let hb = blake3_file(&b).expect("hash b");
std::fs::remove_file(&a).ok();
std::fs::remove_file(&b).ok();
assert_eq!(ha, hb, "content-based hash must ignore the path");
}
#[test]
fn different_contents_hash_differently() {
let a = write_temp("diff-a", b"content one");
let b = write_temp("diff-b", b"content two");
let ha = blake3_file(&a).expect("hash a");
let hb = blake3_file(&b).expect("hash b");
std::fs::remove_file(&a).ok();
std::fs::remove_file(&b).ok();
assert_ne!(ha, hb);
}
#[test]
fn empty_file_hashes_like_empty_input() {
let path = write_temp("empty", b"");
let got = blake3_file(&path).expect("hash empty file");
std::fs::remove_file(&path).ok();
assert_eq!(got, blake3::hash(b""));
}
#[test]
fn first_16_hex_is_a_stable_stamp() {
let bytes = b"stamp me";
let path = write_temp("stamp", bytes);
let hash = blake3_file(&path).expect("hash temp file");
std::fs::remove_file(&path).ok();
let hex = hash.to_hex();
let stamp16 = &hex[..16];
assert_eq!(stamp16.len(), 16);
assert!(stamp16.chars().all(|c| c.is_ascii_hexdigit()));
assert_eq!(stamp16, &blake3::hash(bytes).to_hex()[..16]);
}
#[test]
fn missing_file_is_an_io_error() {
let mut path = std::env::temp_dir();
path.push(format!(
"rp-content-hash-does-not-exist-{}",
std::process::id()
));
let err = blake3_file(&path).expect_err("missing file must error");
assert_eq!(err.kind(), io::ErrorKind::NotFound);
}
#[cfg(feature = "client")]
mod stamp {
use super::super::*;
use super::write_temp;
fn never_computed() -> io::Result<String> {
panic!("an inherited stamp must not be re-hashed")
}
#[test]
fn stamp_is_none_outside_dev_scope_even_when_inherited() {
let inherited = Some("4.1.0-deadbeefdeadbeef".to_owned());
assert_eq!(
resolve_stamp(false, inherited, never_computed).unwrap(),
None
);
assert_eq!(resolve_stamp(false, None, never_computed).unwrap(), None);
}
#[test]
fn stamp_uses_inherited_value_verbatim_without_hashing() {
let inherited = Some("4.1.0-deadbeefdeadbeef".to_owned());
assert_eq!(
resolve_stamp(true, inherited, never_computed).unwrap(),
Some("4.1.0-deadbeefdeadbeef".to_owned())
);
}
#[test]
fn stamp_is_computed_when_dev_scope_has_nothing_inherited() {
let got = resolve_stamp(true, None, || Ok("1.2.3-0123456789abcdef".into())).unwrap();
assert_eq!(got, Some("1.2.3-0123456789abcdef".to_owned()));
}
#[test]
fn unsafe_inherited_value_is_recomputed_not_trusted() {
for bad in ["", "a/b", "a\\b", "nul\0byte", "has space", &"x".repeat(65)] {
let got =
resolve_stamp(true, Some(bad.to_owned()), || Ok("1.0.0-cafe".into())).unwrap();
assert_eq!(got, Some("1.0.0-cafe".to_owned()), "{bad:?}");
}
}
#[test]
fn computed_stamp_is_version_dash_sixteen_hex_of_the_file_bytes() {
let path = write_temp("stamp-shape", b"some binary");
let stamp = stamp_from_hex("4.1.0", &file_hex16(&path).unwrap());
std::fs::remove_file(&path).ok();
let expected = blake3::hash(b"some binary").to_hex();
assert_eq!(stamp, format!("4.1.0-{}", &expected[..16]));
assert!(is_safe_stamp(&stamp));
}
#[test]
fn different_bytes_give_different_stamps_and_same_bytes_the_same() {
let a = write_temp("stamp-a", b"build one");
let b = write_temp("stamp-b", b"build two");
let c = write_temp("stamp-c", b"build one");
let stamp = |path| stamp_from_hex("4.1.0", &file_hex16(path).unwrap());
let (sa, sb, sc) = (stamp(&a), stamp(&b), stamp(&c));
for path in [&a, &b, &c] {
std::fs::remove_file(path).ok();
}
assert_ne!(sa, sb, "same version, different bytes must not collide");
assert_eq!(sa, sc, "the path must not matter");
}
}
}