use base64::Engine;
pub const BLOCK_SIZE: usize = 4096;
#[must_use]
pub fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}
pub const SIZE_THRESHOLD: u64 = 1_048_576;
#[derive(Debug, Clone, PartialEq)]
pub struct BlockSignature {
pub index: usize,
pub hash: String,
}
#[derive(Debug, Clone)]
pub enum DeltaOp {
Copy {
index: usize,
},
Literal {
data: Vec<u8>,
},
}
pub fn compute_signatures(data: &[u8]) -> Vec<BlockSignature> {
data.chunks(BLOCK_SIZE)
.enumerate()
.map(|(i, chunk)| {
let hash = blake3::hash(chunk);
BlockSignature {
index: i,
hash: hash.to_hex().to_string(),
}
})
.collect()
}
pub fn compute_delta(new_data: &[u8], remote_sigs: &[BlockSignature]) -> Vec<DeltaOp> {
let mut ops = Vec::new();
for (i, chunk) in new_data.chunks(BLOCK_SIZE).enumerate() {
let local_hash = blake3::hash(chunk).to_hex().to_string();
let matches_remote = remote_sigs
.get(i)
.map(|sig| sig.hash == local_hash)
.unwrap_or(false);
if matches_remote {
ops.push(DeltaOp::Copy { index: i });
} else {
ops.push(DeltaOp::Literal {
data: chunk.to_vec(),
});
}
}
ops
}
pub fn literal_count(ops: &[DeltaOp]) -> usize {
ops.iter()
.filter(|op| matches!(op, DeltaOp::Literal { .. }))
.count()
}
pub fn literal_bytes(ops: &[DeltaOp]) -> usize {
ops.iter()
.map(|op| match op {
DeltaOp::Literal { data } => data.len(),
DeltaOp::Copy { .. } => 0,
})
.sum()
}
pub fn signature_script(path: &str) -> String {
format!(
"set -euo pipefail\n\
FILE='{path}'\n\
if [ ! -f \"$FILE\" ]; then\n\
echo 'NEW_FILE'\n\
exit 0\n\
fi\n\
SIZE=$(stat -c %s \"$FILE\" 2>/dev/null || stat -f %z \"$FILE\")\n\
echo \"SIZE:$SIZE\"\n\
BLOCKS=$(( (SIZE + {BLOCK_SIZE} - 1) / {BLOCK_SIZE} ))\n\
for i in $(seq 0 $((BLOCKS - 1))); do\n\
HASH=$(dd if=\"$FILE\" bs={BLOCK_SIZE} skip=$i count=1 2>/dev/null | \
b3sum --no-names 2>/dev/null || \
dd if=\"$FILE\" bs={BLOCK_SIZE} skip=$i count=1 2>/dev/null | sha256sum | cut -d' ' -f1)\n\
echo \"$i $HASH\"\n\
done",
)
}
pub fn parse_signatures(output: &str) -> Result<Option<Vec<BlockSignature>>, String> {
let mut sigs = Vec::new();
for line in output.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if line == "NEW_FILE" {
return Ok(None);
}
if line.starts_with("SIZE:") {
continue;
}
let parts: Vec<&str> = line.splitn(2, ' ').collect();
if parts.len() != 2 {
return Err(format!("invalid signature line: {line}"));
}
let index: usize = parts[0]
.parse()
.map_err(|_| format!("invalid block index: {}", parts[0]))?;
sigs.push(BlockSignature {
index,
hash: parts[1].to_string(),
});
}
Ok(Some(sigs))
}
pub fn patch_script(
path: &str,
ops: &[DeltaOp],
expected_blake3: &str,
owner: Option<&str>,
group: Option<&str>,
mode: Option<&str>,
) -> String {
let dest = shell_quote(path);
let mut lines = vec![
"set -euo pipefail".to_string(),
format!("DEST={dest}"),
"TMPFILE=\"$DEST.forjar-delta.$$\"".to_string(),
"trap 'rm -f \"$TMPFILE\"' EXIT".to_string(),
"rm -f \"$TMPFILE\"".to_string(),
];
for op in ops {
match op {
DeltaOp::Copy { index } => {
lines.push(format!(
"dd if=\"$DEST\" bs={BLOCK_SIZE} skip={index} count=1 >> \"$TMPFILE\" 2>/dev/null",
));
}
DeltaOp::Literal { data } => {
let b64 = base64::engine::general_purpose::STANDARD.encode(data);
lines.push(format!(
"base64 -d >> \"$TMPFILE\" <<'FORJAR_B64'\n{b64}\nFORJAR_B64"
));
}
}
}
lines.push(format!("EXPECT={}", shell_quote(expected_blake3)));
lines.push(
"if command -v b3sum >/dev/null 2>&1; then \
GOT=$(b3sum --no-names \"$TMPFILE\"); \
if [ \"$GOT\" != \"$EXPECT\" ]; then echo 'copia: integrity mismatch, aborting' >&2; exit 1; fi; \
fi"
.to_string(),
);
if let Some(owner) = owner {
let spec = match group {
Some(g) => format!("{owner}:{g}"),
None => owner.to_string(),
};
lines.push(format!("chown {} \"$TMPFILE\"", shell_quote(&spec)));
}
if let Some(mode) = mode {
lines.push(format!("chmod {} \"$TMPFILE\"", shell_quote(mode)));
}
lines.push("mv \"$TMPFILE\" \"$DEST\"".to_string());
lines.push("trap - EXIT".to_string());
lines.join("\n")
}
pub fn is_eligible(source_path: &str) -> bool {
match std::fs::metadata(source_path) {
Ok(meta) => meta.len() > SIZE_THRESHOLD,
Err(_) => false,
}
}
pub fn full_transfer_script(
path: &str,
source_path: &str,
owner: Option<&str>,
group: Option<&str>,
mode: Option<&str>,
) -> Result<String, String> {
let data = std::fs::read(source_path).map_err(|e| format!("{source_path}: {e}"))?;
let b64 = base64::engine::general_purpose::STANDARD.encode(&data);
let dest = shell_quote(path);
let mut lines = vec![
"set -euo pipefail".to_string(),
format!("DEST={dest}"),
"TMPFILE=\"$DEST.forjar-new.$$\"".to_string(),
"trap 'rm -f \"$TMPFILE\"' EXIT".to_string(),
];
if let Some(parent) = std::path::Path::new(path).parent() {
if parent != std::path::Path::new("/") {
lines.push(format!(
"mkdir -p {}",
shell_quote(&parent.display().to_string())
));
}
}
lines.push(format!(
"base64 -d > \"$TMPFILE\" <<'FORJAR_B64'\n{b64}\nFORJAR_B64"
));
if let Some(owner) = owner {
let spec = match group {
Some(g) => format!("{owner}:{g}"),
None => owner.to_string(),
};
lines.push(format!("chown {} \"$TMPFILE\"", shell_quote(&spec)));
}
if let Some(mode) = mode {
lines.push(format!("chmod {} \"$TMPFILE\"", shell_quote(mode)));
}
lines.push("mv \"$TMPFILE\" \"$DEST\"".to_string());
lines.push("trap - EXIT".to_string());
Ok(lines.join("\n"))
}
#[cfg(test)]
mod tests;