use crate::report::human_bytes;
use anyhow::{bail, Context, Result};
use cavs_hash::{hash_chunk, ChunkHash};
use cavs_proto::errors::ErrorCode;
use std::io::Read;
use std::path::Path;
use std::process::Command;
pub const PATCH_MAGIC: [u8; 8] = *b"CAVSPCH1";
pub const PATCH_VERSION: u16 = 1;
pub struct PatchHeader {
pub algo: String,
pub compression: String,
pub old_size: u64,
pub old_blake3: ChunkHash,
pub new_size: u64,
pub new_blake3: ChunkHash,
}
fn read_var(input: &mut &[u8]) -> Result<u64> {
let mut value = 0u64;
let mut shift = 0u32;
for i in 0..10 {
let Some(&byte) = input.get(i) else {
bail!("truncated varint");
};
value |= ((byte & 0x7f) as u64) << shift;
if byte & 0x80 == 0 {
*input = &input[i + 1..];
return Ok(value);
}
shift += 7;
}
bail!("overlong varint")
}
fn read_str(input: &mut &[u8]) -> Result<String> {
let len = read_var(input)? as usize;
if len > input.len() || len > 256 {
bail!("truncated string");
}
let (head, tail) = input.split_at(len);
*input = tail;
String::from_utf8(head.to_vec()).context("string not UTF-8")
}
fn take<'a>(input: &mut &'a [u8], n: usize) -> Result<&'a [u8]> {
if n > input.len() {
bail!("truncated");
}
let (head, tail) = input.split_at(n);
*input = tail;
Ok(head)
}
pub fn apply(old: &Path, patch: &Path, out: &Path) -> Result<()> {
let bytes = std::fs::read(patch).with_context(|| format!("cannot read {}", patch.display()))?;
let (header, payload) = decode(&bytes)?;
let old_bytes = std::fs::read(old)?;
if old_bytes.len() as u64 != header.old_size || hash_chunk(&old_bytes) != header.old_blake3 {
bail!(
"{}",
ErrorCode::ApplyHashMismatch.msg(format!(
"{} is not the old version this patch expects",
old.display()
))
);
}
let raw_patch = decompress(payload, &header.compression)?;
let dir = tempfile::tempdir()?;
let patch_tmp = dir.path().join("patch.bin");
std::fs::write(&patch_tmp, &raw_patch)?;
let part = out.with_extension("cavspatch.part");
run_apply_tool(&header.algo, old, &patch_tmp, &part)?;
let produced = std::fs::read(&part)?;
if produced.len() as u64 != header.new_size || hash_chunk(&produced) != header.new_blake3 {
let _ = std::fs::remove_file(&part);
bail!(
"{}",
ErrorCode::ApplyHashMismatch.msg("sidecar apply produced wrong output")
);
}
std::fs::rename(&part, out)?;
println!(
"apply : OK — {} ({} via {}+{})",
out.display(),
human_bytes(header.new_size),
header.algo,
header.compression,
);
Ok(())
}
pub fn decode(bytes: &[u8]) -> Result<(PatchHeader, &[u8])> {
if bytes.len() < 8 + 2 + 32 || bytes[..8] != PATCH_MAGIC {
bail!("{}", ErrorCode::PlanCorrupt.msg("not a .cavspatch"));
}
let body_len = bytes.len() - 32;
let expected: ChunkHash = bytes[body_len..].try_into().unwrap();
if hash_chunk(&bytes[..body_len]) != expected {
bail!(
"{}",
ErrorCode::PlanCorrupt.msg(".cavspatch integrity trailer mismatch")
);
}
let mut input = &bytes[8..body_len];
let version = u16::from_le_bytes(take(&mut input, 2)?.try_into().unwrap());
if version != PATCH_VERSION {
bail!("unsupported .cavspatch version {version}");
}
let algo = read_str(&mut input)?;
let compression = read_str(&mut input)?;
let old_size = read_var(&mut input)?;
let old_blake3: ChunkHash = take(&mut input, 32)?.try_into().unwrap();
let new_size = read_var(&mut input)?;
let new_blake3: ChunkHash = take(&mut input, 32)?.try_into().unwrap();
let payload_len = read_var(&mut input)?;
if payload_len as usize != input.len() {
bail!(
"{}",
ErrorCode::PlanCorrupt.msg(".cavspatch payload length mismatch")
);
}
Ok((
PatchHeader {
algo,
compression,
old_size,
old_blake3,
new_size,
new_blake3,
},
input,
))
}
pub(crate) fn missing(tool: &str) -> anyhow::Error {
anyhow::anyhow!(ErrorCode::PairwiseToolMissing.msg(format!("{tool} not found on PATH")))
}
pub(crate) fn run_diff_tool(algo: &str, old: &Path, new: &Path) -> Result<Vec<u8>> {
let dir = tempfile::tempdir()?;
let patch = dir.path().join("raw.patch");
let status = match algo {
"bsdiff" => Command::new("bsdiff")
.args([old.as_os_str(), new.as_os_str(), patch.as_os_str()])
.status(),
"xdelta3" => Command::new("xdelta3")
.args(["-e", "-9", "-f", "-S", "djw"])
.args(["-s"])
.args([old.as_os_str(), new.as_os_str(), patch.as_os_str()])
.status(),
_ => unreachable!(),
}
.map_err(|_| missing(algo))?;
if !status.success() {
bail!("{algo} diff failed with {status}");
}
Ok(std::fs::read(&patch)?)
}
pub(crate) fn run_apply_tool(algo: &str, old: &Path, patch: &Path, out: &Path) -> Result<()> {
let status = match algo {
"bsdiff" => Command::new("bspatch")
.args([old.as_os_str(), out.as_os_str(), patch.as_os_str()])
.status()
.map_err(|_| missing("bspatch"))?,
"xdelta3" => Command::new("xdelta3")
.args(["-d", "-f", "-s"])
.args([old.as_os_str(), patch.as_os_str(), out.as_os_str()])
.status()
.map_err(|_| missing("xdelta3"))?,
other => bail!("unknown patch algo {other}"),
};
if !status.success() {
bail!("{algo} apply failed with {status}");
}
Ok(())
}
pub fn compress(data: &[u8], compression: &str) -> Result<Vec<u8>> {
match compression {
"none" => Ok(data.to_vec()),
_ if compression.starts_with("zstd-") => {
let level: i32 = compression[5..].parse().context("bad zstd level")?;
Ok(zstd::bulk::compress(data, level)?)
}
_ if compression.starts_with("brotli-") => {
let quality = &compression[7..];
run_pipe("brotli", &["-c", &format!("--quality={quality}")], data)
}
other => bail!("unknown compression {other} (use zstd-N, brotli-N or none)"),
}
}
pub(crate) fn decompress(data: &[u8], compression: &str) -> Result<Vec<u8>> {
match compression {
"none" => Ok(data.to_vec()),
_ if compression.starts_with("zstd-") => {
let mut out = Vec::new();
zstd::stream::copy_decode(data, &mut out)?;
Ok(out)
}
_ if compression.starts_with("brotli-") => run_pipe("brotli", &["-d", "-c"], data),
other => bail!("unknown compression {other}"),
}
}
fn run_pipe(bin: &str, args: &[&str], data: &[u8]) -> Result<Vec<u8>> {
let mut child = Command::new(bin)
.args(args)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
.map_err(|_| missing(bin))?;
let mut stdin = child.stdin.take().unwrap();
let input = data.to_vec();
let writer = std::thread::spawn(move || {
use std::io::Write as _;
let _ = stdin.write_all(&input);
});
let mut out = Vec::new();
child.stdout.take().unwrap().read_to_end(&mut out)?;
let _ = writer.join();
if !child.wait()?.success() {
bail!("{bin} failed");
}
Ok(out)
}