use std::collections::HashMap;
use std::path::Path;
use crate::hash::git_sha256::compute_git_sha256_from_bytes;
use crate::manifest::schema::PatchFileInfo;
use crate::patch::cow::break_hardlink_if_needed;
use crate::patch::diff::apply_diff;
use crate::patch::file_hash::compute_file_git_sha256;
use crate::patch::package::read_archive_filtered;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VerifyStatus {
Ready,
AlreadyPatched,
HashMismatch,
NotFound,
}
#[derive(Debug, Clone)]
pub struct VerifyResult {
pub file: String,
pub status: VerifyStatus,
pub message: Option<String>,
pub current_hash: Option<String>,
pub expected_hash: Option<String>,
pub target_hash: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppliedVia {
Package,
Diff,
Blob,
}
impl AppliedVia {
pub fn as_tag(&self) -> &'static str {
match self {
AppliedVia::Package => "package",
AppliedVia::Diff => "diff",
AppliedVia::Blob => "blob",
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct PatchSources<'a> {
pub blobs_path: &'a Path,
pub packages_path: Option<&'a Path>,
pub diffs_path: Option<&'a Path>,
}
impl<'a> PatchSources<'a> {
pub fn blobs_only(blobs_path: &'a Path) -> Self {
Self {
blobs_path,
packages_path: None,
diffs_path: None,
}
}
}
#[derive(Debug, Clone)]
pub struct ApplyResult {
pub package_key: String,
pub package_path: String,
pub success: bool,
pub files_verified: Vec<VerifyResult>,
pub files_patched: Vec<String>,
pub applied_via: HashMap<String, AppliedVia>,
pub error: Option<String>,
pub sidecar: Option<crate::patch::sidecars::SidecarRecord>,
}
pub fn normalize_file_path(file_name: &str) -> &str {
const PACKAGE_PREFIX: &str = "package/";
if let Some(stripped) = file_name.strip_prefix(PACKAGE_PREFIX) {
stripped
} else {
file_name
}
}
pub async fn verify_file_patch(
pkg_path: &Path,
file_name: &str,
file_info: &PatchFileInfo,
) -> VerifyResult {
let normalized = normalize_file_path(file_name);
let filepath = pkg_path.join(normalized);
let is_new_file = file_info.before_hash.is_empty();
if tokio::fs::metadata(&filepath).await.is_err() {
if is_new_file {
return VerifyResult {
file: file_name.to_string(),
status: VerifyStatus::Ready,
message: None,
current_hash: None,
expected_hash: None,
target_hash: Some(file_info.after_hash.clone()),
};
}
return VerifyResult {
file: file_name.to_string(),
status: VerifyStatus::NotFound,
message: Some("File not found".to_string()),
current_hash: None,
expected_hash: None,
target_hash: None,
};
}
let current_hash = match compute_file_git_sha256(&filepath).await {
Ok(h) => h,
Err(e) => {
return VerifyResult {
file: file_name.to_string(),
status: VerifyStatus::NotFound,
message: Some(format!("Failed to hash file: {}", e)),
current_hash: None,
expected_hash: None,
target_hash: None,
};
}
};
if current_hash == file_info.after_hash {
return VerifyResult {
file: file_name.to_string(),
status: VerifyStatus::AlreadyPatched,
message: None,
current_hash: Some(current_hash),
expected_hash: None,
target_hash: None,
};
}
if is_new_file {
return VerifyResult {
file: file_name.to_string(),
status: VerifyStatus::Ready,
message: None,
current_hash: Some(current_hash),
expected_hash: None,
target_hash: Some(file_info.after_hash.clone()),
};
}
if current_hash != file_info.before_hash {
return VerifyResult {
file: file_name.to_string(),
status: VerifyStatus::HashMismatch,
message: Some("File hash does not match expected value".to_string()),
current_hash: Some(current_hash),
expected_hash: Some(file_info.before_hash.clone()),
target_hash: Some(file_info.after_hash.clone()),
};
}
VerifyResult {
file: file_name.to_string(),
status: VerifyStatus::Ready,
message: None,
current_hash: Some(current_hash),
expected_hash: None,
target_hash: Some(file_info.after_hash.clone()),
}
}
pub async fn apply_file_patch(
pkg_path: &Path,
file_name: &str,
patched_content: &[u8],
expected_hash: &str,
) -> Result<(), std::io::Error> {
let normalized = normalize_file_path(file_name);
let filepath = pkg_path.join(normalized);
let content_hash = compute_git_sha256_from_bytes(patched_content);
if content_hash != expected_hash {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"Hash verification failed before patch. Expected: {}, Got: {}",
expected_hash, content_hash
),
));
}
let existing_meta = tokio::fs::metadata(&filepath).await.ok();
if let Some(parent) = filepath.parent() {
tokio::fs::create_dir_all(parent).await?;
}
break_hardlink_if_needed(&filepath).await?;
write_atomic(&filepath, patched_content).await?;
restore_file_permissions(&filepath, existing_meta.as_ref()).await?;
Ok(())
}
async fn write_atomic(target: &Path, content: &[u8]) -> std::io::Result<()> {
let parent = target.parent().unwrap_or_else(|| Path::new("."));
let stem = target
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "anon".to_string());
let stage = parent.join(format!(
".socket-stage-{}-{}",
stem,
uuid::Uuid::new_v4()
));
let mut file = tokio::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&stage)
.await?;
use tokio::io::AsyncWriteExt;
if let Err(e) = file.write_all(content).await {
let _ = tokio::fs::remove_file(&stage).await;
return Err(e);
}
if let Err(e) = file.sync_all().await {
let _ = tokio::fs::remove_file(&stage).await;
return Err(e);
}
drop(file);
if let Err(e) = tokio::fs::rename(&stage, target).await {
let _ = tokio::fs::remove_file(&stage).await;
return Err(e);
}
Ok(())
}
async fn restore_file_permissions(
filepath: &Path,
pre_patch: Option<&std::fs::Metadata>,
) -> Result<(), std::io::Error> {
#[cfg(unix)]
{
use std::os::unix::fs::{MetadataExt, PermissionsExt};
match pre_patch {
Some(meta) => {
let restored = std::fs::Permissions::from_mode(meta.mode());
tokio::fs::set_permissions(filepath, restored).await?;
let uid = meta.uid();
let gid = meta.gid();
chown_blocking(filepath.to_path_buf(), Some(uid), Some(gid)).await?;
}
None => {
if let Some(parent) = filepath.parent() {
if let Ok(parent_meta) = tokio::fs::metadata(parent).await {
let uid = parent_meta.uid();
let gid = parent_meta.gid();
chown_blocking(filepath.to_path_buf(), Some(uid), Some(gid))
.await?;
}
}
let readonly = std::fs::Permissions::from_mode(0o444);
tokio::fs::set_permissions(filepath, readonly).await?;
}
}
}
#[cfg(windows)]
{
match pre_patch {
Some(meta) => {
let perms = meta.permissions();
tokio::fs::set_permissions(filepath, perms).await?;
}
None => {
if let Ok(meta) = tokio::fs::metadata(filepath).await {
let mut perms = meta.permissions();
perms.set_readonly(true);
tokio::fs::set_permissions(filepath, perms).await?;
}
}
}
}
let _ = filepath;
let _ = pre_patch;
Ok(())
}
#[cfg(unix)]
async fn chown_blocking(
path: std::path::PathBuf,
uid: Option<u32>,
gid: Option<u32>,
) -> Result<(), std::io::Error> {
tokio::task::spawn_blocking(move || std::os::unix::fs::chown(&path, uid, gid))
.await
.map_err(|e| std::io::Error::other(e.to_string()))?
}
pub async fn apply_package_patch(
package_key: &str,
pkg_path: &Path,
files: &HashMap<String, PatchFileInfo>,
sources: &PatchSources<'_>,
uuid: Option<&str>,
dry_run: bool,
force: bool,
) -> ApplyResult {
let mut result = ApplyResult {
package_key: package_key.to_string(),
package_path: pkg_path.display().to_string(),
success: false,
files_verified: Vec::new(),
files_patched: Vec::new(),
applied_via: HashMap::new(),
error: None,
sidecar: None,
};
for (file_name, file_info) in files {
let mut verify_result = verify_file_patch(pkg_path, file_name, file_info).await;
if verify_result.status != VerifyStatus::Ready
&& verify_result.status != VerifyStatus::AlreadyPatched
{
if force {
match verify_result.status {
VerifyStatus::HashMismatch => {
verify_result.status = VerifyStatus::Ready;
}
VerifyStatus::NotFound => {
result.files_verified.push(verify_result);
continue;
}
_ => {}
}
} else {
let msg = verify_result
.message
.clone()
.unwrap_or_else(|| format!("{:?}", verify_result.status));
result.error = Some(format!(
"Cannot apply patch: {} - {}",
verify_result.file, msg
));
result.files_verified.push(verify_result);
return result;
}
}
result.files_verified.push(verify_result);
}
let all_already_patched = result
.files_verified
.iter()
.all(|v| v.status == VerifyStatus::AlreadyPatched);
if all_already_patched {
result.success = true;
return result;
}
let all_done_or_skipped = result
.files_verified
.iter()
.all(|v| v.status == VerifyStatus::AlreadyPatched || v.status == VerifyStatus::NotFound);
if all_done_or_skipped {
let not_found_count = result.files_verified.iter()
.filter(|v| v.status == VerifyStatus::NotFound)
.count();
result.success = true;
result.error = Some(format!(
"All patch files were skipped: {} not found on disk (--force)",
not_found_count
));
return result;
}
if dry_run {
result.success = true;
return result;
}
let package_entries = match (uuid, sources.packages_path) {
(Some(uuid), Some(dir)) => load_archive_if_present(dir, uuid, files).await,
_ => None,
};
let diff_entries = match (uuid, sources.diffs_path) {
(Some(uuid), Some(dir)) => load_archive_if_present(dir, uuid, files).await,
_ => None,
};
for (file_name, file_info) in files {
let verify_result = result.files_verified.iter().find(|v| v.file == *file_name);
if let Some(vr) = verify_result {
if vr.status == VerifyStatus::AlreadyPatched
|| vr.status == VerifyStatus::NotFound
{
continue;
}
}
let normalized = normalize_file_path(file_name).to_string();
if try_apply_from_archive(
package_entries.as_ref(),
&normalized,
pkg_path,
file_name,
file_info,
)
.await
{
result.files_patched.push(file_name.clone());
result
.applied_via
.insert(file_name.clone(), AppliedVia::Package);
continue;
}
let current_hash_for_diff = verify_result.and_then(|v| v.current_hash.as_deref());
if try_apply_from_diff(
diff_entries.as_ref(),
&normalized,
pkg_path,
file_name,
file_info,
current_hash_for_diff,
)
.await
{
result.files_patched.push(file_name.clone());
result
.applied_via
.insert(file_name.clone(), AppliedVia::Diff);
continue;
}
let blob_path = sources.blobs_path.join(&file_info.after_hash);
let patched_content = match tokio::fs::read(&blob_path).await {
Ok(content) => content,
Err(e) => {
result.error = Some(format!(
"Failed to read blob {}: {}",
file_info.after_hash, e
));
return result;
}
};
if let Err(e) =
apply_file_patch(pkg_path, file_name, &patched_content, &file_info.after_hash).await
{
result.error = Some(e.to_string());
return result;
}
result.files_patched.push(file_name.clone());
result
.applied_via
.insert(file_name.clone(), AppliedVia::Blob);
}
if !result.files_patched.is_empty() {
use crate::patch::sidecars::{
dispatch_fixup, SidecarAdvisory, SidecarAdvisoryCode, SidecarRecord, SidecarSeverity,
};
match dispatch_fixup(package_key, pkg_path, &result.files_patched, files).await {
Ok(Some(record)) => result.sidecar = Some(record),
Ok(None) => {}
Err(e) => {
let ecosystem = crate::crawlers::Ecosystem::from_purl(package_key)
.map(|eco| eco.cli_name().to_string())
.unwrap_or_else(|| "unknown".to_string());
result.sidecar = Some(SidecarRecord {
purl: package_key.to_string(),
ecosystem,
files: Vec::new(),
advisory: Some(SidecarAdvisory {
code: SidecarAdvisoryCode::SidecarFixupFailed,
severity: SidecarSeverity::Error,
message: format!("sidecar fixup failed (patch still applied): {}", e),
}),
});
}
}
}
result.success = true;
result
}
async fn try_apply_from_archive(
package_entries: Option<&HashMap<String, Vec<u8>>>,
normalized_path: &str,
pkg_path: &Path,
file_name: &str,
file_info: &PatchFileInfo,
) -> bool {
let entries = match package_entries {
Some(e) => e,
None => return false,
};
let bytes = match entries.get(normalized_path) {
Some(b) => b,
None => return false,
};
if compute_git_sha256_from_bytes(bytes) != file_info.after_hash {
return false;
}
apply_file_patch(pkg_path, file_name, bytes, &file_info.after_hash)
.await
.is_ok()
}
async fn try_apply_from_diff(
diff_entries: Option<&HashMap<String, Vec<u8>>>,
normalized_path: &str,
pkg_path: &Path,
file_name: &str,
file_info: &PatchFileInfo,
current_hash: Option<&str>,
) -> bool {
let entries = match diff_entries {
Some(e) => e,
None => return false,
};
let delta = match entries.get(normalized_path) {
Some(d) => d,
None => return false,
};
if file_info.before_hash.is_empty() {
return false;
}
match current_hash {
Some(h) if h == file_info.before_hash => {}
_ => return false,
}
let on_disk_path = pkg_path.join(normalized_path);
let before_bytes = match tokio::fs::read(&on_disk_path).await {
Ok(b) => b,
Err(_) => return false,
};
let patched = match apply_diff(&before_bytes, delta) {
Ok(p) => p,
Err(_) => return false,
};
if compute_git_sha256_from_bytes(&patched) != file_info.after_hash {
return false;
}
apply_file_patch(pkg_path, file_name, &patched, &file_info.after_hash)
.await
.is_ok()
}
async fn load_archive_if_present(
dir: &Path,
uuid: &str,
files: &HashMap<String, PatchFileInfo>,
) -> Option<HashMap<String, Vec<u8>>> {
let archive_path = dir.join(format!("{uuid}.tar.gz"));
if tokio::fs::metadata(&archive_path).await.is_err() {
return None;
}
let archive_path_owned = archive_path.clone();
let files_owned = files.clone();
tokio::task::spawn_blocking(move || read_archive_filtered(&archive_path_owned, &files_owned))
.await
.ok()
.and_then(|r| r.ok())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hash::git_sha256::compute_git_sha256_from_bytes;
#[test]
fn test_normalize_file_path_with_prefix() {
assert_eq!(normalize_file_path("package/lib/server.js"), "lib/server.js");
}
#[test]
fn test_normalize_file_path_without_prefix() {
assert_eq!(normalize_file_path("lib/server.js"), "lib/server.js");
}
#[test]
fn test_normalize_file_path_just_prefix() {
assert_eq!(normalize_file_path("package/"), "");
}
#[test]
fn test_normalize_file_path_package_not_prefix() {
assert_eq!(normalize_file_path("packagefoo/bar.js"), "packagefoo/bar.js");
}
#[tokio::test]
async fn test_verify_file_patch_not_found() {
let dir = tempfile::tempdir().unwrap();
let file_info = PatchFileInfo {
before_hash: "aaa".to_string(),
after_hash: "bbb".to_string(),
};
let result = verify_file_patch(dir.path(), "nonexistent.js", &file_info).await;
assert_eq!(result.status, VerifyStatus::NotFound);
}
#[tokio::test]
async fn test_verify_file_patch_ready() {
let dir = tempfile::tempdir().unwrap();
let content = b"original content";
let before_hash = compute_git_sha256_from_bytes(content);
let after_hash = "bbbbbbbb".to_string();
tokio::fs::write(dir.path().join("index.js"), content)
.await
.unwrap();
let file_info = PatchFileInfo {
before_hash: before_hash.clone(),
after_hash,
};
let result = verify_file_patch(dir.path(), "index.js", &file_info).await;
assert_eq!(result.status, VerifyStatus::Ready);
assert_eq!(result.current_hash.unwrap(), before_hash);
}
#[tokio::test]
async fn test_verify_file_patch_already_patched() {
let dir = tempfile::tempdir().unwrap();
let content = b"patched content";
let after_hash = compute_git_sha256_from_bytes(content);
tokio::fs::write(dir.path().join("index.js"), content)
.await
.unwrap();
let file_info = PatchFileInfo {
before_hash: "aaaa".to_string(),
after_hash: after_hash.clone(),
};
let result = verify_file_patch(dir.path(), "index.js", &file_info).await;
assert_eq!(result.status, VerifyStatus::AlreadyPatched);
}
#[tokio::test]
async fn test_verify_file_patch_hash_mismatch() {
let dir = tempfile::tempdir().unwrap();
tokio::fs::write(dir.path().join("index.js"), b"something else")
.await
.unwrap();
let file_info = PatchFileInfo {
before_hash: "aaaa".to_string(),
after_hash: "bbbb".to_string(),
};
let result = verify_file_patch(dir.path(), "index.js", &file_info).await;
assert_eq!(result.status, VerifyStatus::HashMismatch);
}
#[tokio::test]
async fn test_verify_with_package_prefix() {
let dir = tempfile::tempdir().unwrap();
let content = b"original content";
let before_hash = compute_git_sha256_from_bytes(content);
tokio::fs::create_dir_all(dir.path().join("lib")).await.unwrap();
tokio::fs::write(dir.path().join("lib/server.js"), content)
.await
.unwrap();
let file_info = PatchFileInfo {
before_hash: before_hash.clone(),
after_hash: "bbbb".to_string(),
};
let result = verify_file_patch(dir.path(), "package/lib/server.js", &file_info).await;
assert_eq!(result.status, VerifyStatus::Ready);
}
#[tokio::test]
async fn test_apply_file_patch_success() {
let dir = tempfile::tempdir().unwrap();
let original = b"original";
let patched = b"patched content";
let patched_hash = compute_git_sha256_from_bytes(patched);
tokio::fs::write(dir.path().join("index.js"), original)
.await
.unwrap();
apply_file_patch(dir.path(), "index.js", patched, &patched_hash)
.await
.unwrap();
let written = tokio::fs::read(dir.path().join("index.js")).await.unwrap();
assert_eq!(written, patched);
}
#[tokio::test]
async fn test_apply_file_patch_hash_mismatch() {
let dir = tempfile::tempdir().unwrap();
tokio::fs::write(dir.path().join("index.js"), b"original")
.await
.unwrap();
let result =
apply_file_patch(dir.path(), "index.js", b"patched content", "wrong_hash").await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("Hash verification failed"));
}
#[tokio::test]
async fn test_apply_file_patch_hash_mismatch_leaves_original_intact() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("index.js");
tokio::fs::write(&path, b"original").await.unwrap();
let result = apply_file_patch(dir.path(), "index.js", b"patched", "deadbeef").await;
assert!(result.is_err());
assert_eq!(tokio::fs::read(&path).await.unwrap(), b"original");
let mut entries = tokio::fs::read_dir(dir.path()).await.unwrap();
while let Some(entry) = entries.next_entry().await.unwrap() {
let name = entry.file_name().to_string_lossy().to_string();
assert!(
!name.starts_with(".socket-stage-"),
"stage file leaked into parent dir: {name}"
);
}
}
#[cfg(unix)]
#[tokio::test]
async fn test_apply_file_patch_does_not_propagate_to_hardlinked_sibling() {
let dir = tempfile::tempdir().unwrap();
let project = dir.path().join("project-b").join("foo.js");
let store = dir.path().join("store-a.js");
tokio::fs::create_dir_all(project.parent().unwrap())
.await
.unwrap();
tokio::fs::write(&store, b"original").await.unwrap();
tokio::fs::hard_link(&store, &project).await.unwrap();
let patched = b"patched";
let patched_hash = compute_git_sha256_from_bytes(patched);
apply_file_patch(project.parent().unwrap(), "foo.js", patched, &patched_hash)
.await
.unwrap();
assert_eq!(tokio::fs::read(&project).await.unwrap(), b"patched");
assert_eq!(tokio::fs::read(&store).await.unwrap(), b"original");
}
#[cfg(unix)]
#[tokio::test]
async fn test_apply_file_patch_preserves_readonly_mode() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("index.js");
let original = b"original";
let patched = b"patched content";
let patched_hash = compute_git_sha256_from_bytes(patched);
tokio::fs::write(&path, original).await.unwrap();
tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o444))
.await
.unwrap();
apply_file_patch(dir.path(), "index.js", patched, &patched_hash)
.await
.unwrap();
let written = tokio::fs::read(&path).await.unwrap();
assert_eq!(written, patched);
let mode_after = tokio::fs::metadata(&path).await.unwrap().permissions().mode()
& 0o7777;
assert_eq!(
mode_after, 0o444,
"mode must be restored to the pre-patch value after the write"
);
}
#[cfg(unix)]
#[tokio::test]
async fn test_apply_file_patch_preserves_executable_mode() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bin.sh");
let original = b"#!/bin/sh\necho old\n";
let patched = b"#!/bin/sh\necho new\n";
let patched_hash = compute_git_sha256_from_bytes(patched);
tokio::fs::write(&path, original).await.unwrap();
tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
.await
.unwrap();
apply_file_patch(dir.path(), "bin.sh", patched, &patched_hash)
.await
.unwrap();
let mode_after = tokio::fs::metadata(&path).await.unwrap().permissions().mode()
& 0o7777;
assert_eq!(mode_after, 0o755);
}
#[cfg(unix)]
#[tokio::test]
async fn test_apply_file_patch_new_file_is_readonly_and_inherits_dir_owner() {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let dir = tempfile::tempdir().unwrap();
let nested = "new-dir/new.js";
let patched = b"brand new file content\n";
let patched_hash = compute_git_sha256_from_bytes(patched);
apply_file_patch(dir.path(), nested, patched, &patched_hash)
.await
.unwrap();
let path = dir.path().join(nested);
let mode = tokio::fs::metadata(&path).await.unwrap().permissions().mode()
& 0o7777;
assert_eq!(mode, 0o444, "new files default to read-only");
let parent_meta = tokio::fs::metadata(path.parent().unwrap()).await.unwrap();
let file_meta = tokio::fs::metadata(&path).await.unwrap();
assert_eq!(file_meta.uid(), parent_meta.uid());
assert_eq!(file_meta.gid(), parent_meta.gid());
}
#[cfg(unix)]
#[tokio::test]
async fn test_apply_file_patch_preserves_uid_gid() {
use std::os::unix::fs::MetadataExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("index.js");
let original = b"orig";
let patched = b"new";
let patched_hash = compute_git_sha256_from_bytes(patched);
tokio::fs::write(&path, original).await.unwrap();
let pre = tokio::fs::metadata(&path).await.unwrap();
apply_file_patch(dir.path(), "index.js", patched, &patched_hash)
.await
.unwrap();
let post = tokio::fs::metadata(&path).await.unwrap();
assert_eq!(pre.uid(), post.uid());
assert_eq!(pre.gid(), post.gid());
}
#[tokio::test]
async fn test_apply_package_patch_success() {
let pkg_dir = tempfile::tempdir().unwrap();
let blobs_dir = tempfile::tempdir().unwrap();
let original = b"original content";
let patched = b"patched content";
let before_hash = compute_git_sha256_from_bytes(original);
let after_hash = compute_git_sha256_from_bytes(patched);
tokio::fs::write(pkg_dir.path().join("index.js"), original)
.await
.unwrap();
tokio::fs::write(blobs_dir.path().join(&after_hash), patched)
.await
.unwrap();
let mut files = HashMap::new();
files.insert(
"index.js".to_string(),
PatchFileInfo {
before_hash,
after_hash: after_hash.clone(),
},
);
let result =
apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, &PatchSources::blobs_only(blobs_dir.path()), None, false, false)
.await;
assert!(result.success);
assert_eq!(result.files_patched.len(), 1);
assert!(result.error.is_none());
}
#[tokio::test]
async fn test_apply_package_patch_dry_run() {
let pkg_dir = tempfile::tempdir().unwrap();
let blobs_dir = tempfile::tempdir().unwrap();
let original = b"original content";
let before_hash = compute_git_sha256_from_bytes(original);
tokio::fs::write(pkg_dir.path().join("index.js"), original)
.await
.unwrap();
let mut files = HashMap::new();
files.insert(
"index.js".to_string(),
PatchFileInfo {
before_hash,
after_hash: "bbbb".to_string(),
},
);
let result =
apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, &PatchSources::blobs_only(blobs_dir.path()), None, true, false)
.await;
assert!(result.success);
assert_eq!(result.files_patched.len(), 0);
let content = tokio::fs::read(pkg_dir.path().join("index.js")).await.unwrap();
assert_eq!(content, original);
}
#[tokio::test]
async fn test_apply_package_patch_all_already_patched() {
let pkg_dir = tempfile::tempdir().unwrap();
let blobs_dir = tempfile::tempdir().unwrap();
let patched = b"patched content";
let after_hash = compute_git_sha256_from_bytes(patched);
tokio::fs::write(pkg_dir.path().join("index.js"), patched)
.await
.unwrap();
let mut files = HashMap::new();
files.insert(
"index.js".to_string(),
PatchFileInfo {
before_hash: "aaaa".to_string(),
after_hash,
},
);
let result =
apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, &PatchSources::blobs_only(blobs_dir.path()), None, false, false)
.await;
assert!(result.success);
assert_eq!(result.files_patched.len(), 0);
}
#[tokio::test]
async fn test_apply_package_patch_hash_mismatch_blocks() {
let pkg_dir = tempfile::tempdir().unwrap();
let blobs_dir = tempfile::tempdir().unwrap();
tokio::fs::write(pkg_dir.path().join("index.js"), b"something unexpected")
.await
.unwrap();
let mut files = HashMap::new();
files.insert(
"index.js".to_string(),
PatchFileInfo {
before_hash: "aaaa".to_string(),
after_hash: "bbbb".to_string(),
},
);
let result =
apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, &PatchSources::blobs_only(blobs_dir.path()), None, false, false)
.await;
assert!(!result.success);
assert!(result.error.is_some());
}
#[tokio::test]
async fn test_apply_package_patch_force_hash_mismatch() {
let pkg_dir = tempfile::tempdir().unwrap();
let blobs_dir = tempfile::tempdir().unwrap();
let patched = b"patched content";
let after_hash = compute_git_sha256_from_bytes(patched);
tokio::fs::write(pkg_dir.path().join("index.js"), b"something unexpected")
.await
.unwrap();
tokio::fs::write(blobs_dir.path().join(&after_hash), patched)
.await
.unwrap();
let mut files = HashMap::new();
files.insert(
"index.js".to_string(),
PatchFileInfo {
before_hash: "aaaa".to_string(),
after_hash: after_hash.clone(),
},
);
let result =
apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, &PatchSources::blobs_only(blobs_dir.path()), None, false, false)
.await;
assert!(!result.success);
tokio::fs::write(pkg_dir.path().join("index.js"), b"something unexpected")
.await
.unwrap();
let result =
apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, &PatchSources::blobs_only(blobs_dir.path()), None, false, true)
.await;
assert!(result.success);
assert_eq!(result.files_patched.len(), 1);
let written = tokio::fs::read(pkg_dir.path().join("index.js")).await.unwrap();
assert_eq!(written, patched);
}
#[tokio::test]
async fn test_apply_package_patch_force_not_found_skips() {
let pkg_dir = tempfile::tempdir().unwrap();
let blobs_dir = tempfile::tempdir().unwrap();
let mut files = HashMap::new();
files.insert(
"missing.js".to_string(),
PatchFileInfo {
before_hash: "aaaa".to_string(),
after_hash: "bbbb".to_string(),
},
);
let result =
apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, &PatchSources::blobs_only(blobs_dir.path()), None, false, false)
.await;
assert!(!result.success);
let result =
apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, &PatchSources::blobs_only(blobs_dir.path()), None, false, true)
.await;
assert!(result.success);
assert_eq!(result.files_patched.len(), 0);
}
use flate2::write::GzEncoder;
use flate2::Compression as GzCompression;
use qbsdiff::Bsdiff;
const TEST_UUID: &str = "11111111-1111-4111-8111-111111111111";
fn write_uuid_archive(dir: &Path, uuid: &str, entries: &[(&str, &[u8])]) {
let archive_path = dir.join(format!("{uuid}.tar.gz"));
let file = std::fs::File::create(&archive_path).unwrap();
let gz = GzEncoder::new(file, GzCompression::default());
let mut builder = tar::Builder::new(gz);
for (name, data) in entries {
let mut header = tar::Header::new_gnu();
header.set_size(data.len() as u64);
header.set_mode(0o644);
header.set_cksum();
builder.append_data(&mut header, name, *data).unwrap();
}
builder.into_inner().unwrap().finish().unwrap();
}
fn make_delta(before: &[u8], after: &[u8]) -> Vec<u8> {
let mut delta = Vec::new();
Bsdiff::new(before, after)
.compare(std::io::Cursor::new(&mut delta))
.unwrap();
delta
}
async fn make_fixture() -> (
tempfile::TempDir, // root holding pkg/, blobs/, packages/, diffs/
std::path::PathBuf, // pkg dir
std::path::PathBuf, // blobs dir
std::path::PathBuf, // packages dir
std::path::PathBuf, // diffs dir
HashMap<String, PatchFileInfo>,
Vec<u8>, // original bytes
Vec<u8>, // patched bytes
) {
let root = tempfile::tempdir().unwrap();
let pkg_dir = root.path().join("pkg");
let blobs_dir = root.path().join("blobs");
let packages_dir = root.path().join("packages");
let diffs_dir = root.path().join("diffs");
tokio::fs::create_dir_all(&pkg_dir).await.unwrap();
tokio::fs::create_dir_all(&blobs_dir).await.unwrap();
tokio::fs::create_dir_all(&packages_dir).await.unwrap();
tokio::fs::create_dir_all(&diffs_dir).await.unwrap();
let original: Vec<u8> = b"the original content of the file".to_vec();
let patched: Vec<u8> = b"the PATCHED content of the file!".to_vec();
let before_hash = compute_git_sha256_from_bytes(&original);
let after_hash = compute_git_sha256_from_bytes(&patched);
tokio::fs::write(pkg_dir.join("index.js"), &original)
.await
.unwrap();
tokio::fs::write(blobs_dir.join(&after_hash), &patched)
.await
.unwrap();
write_uuid_archive(&packages_dir, TEST_UUID, &[("index.js", &patched)]);
let delta = make_delta(&original, &patched);
write_uuid_archive(&diffs_dir, TEST_UUID, &[("index.js", &delta)]);
let mut files = HashMap::new();
files.insert(
"index.js".to_string(),
PatchFileInfo {
before_hash,
after_hash,
},
);
(root, pkg_dir, blobs_dir, packages_dir, diffs_dir, files, original, patched)
}
#[tokio::test]
async fn test_apply_via_package_when_archive_present() {
let (_root, pkg_dir, blobs_dir, packages_dir, diffs_dir, files, _orig, patched) =
make_fixture().await;
let sources = PatchSources {
blobs_path: &blobs_dir,
packages_path: Some(&packages_dir),
diffs_path: Some(&diffs_dir),
};
let result = apply_package_patch(
"pkg:npm/x@1.0.0",
&pkg_dir,
&files,
&sources,
Some(TEST_UUID),
false,
false,
)
.await;
assert!(result.success, "expected success: {:?}", result.error);
assert_eq!(result.files_patched, vec!["index.js".to_string()]);
assert_eq!(
result.applied_via.get("index.js"),
Some(&AppliedVia::Package)
);
let written = tokio::fs::read(pkg_dir.join("index.js")).await.unwrap();
assert_eq!(written, patched);
}
#[tokio::test]
async fn test_apply_falls_back_to_diff_when_no_package() {
let (_root, pkg_dir, blobs_dir, packages_dir, diffs_dir, files, _orig, patched) =
make_fixture().await;
tokio::fs::remove_file(packages_dir.join(format!("{TEST_UUID}.tar.gz")))
.await
.unwrap();
let sources = PatchSources {
blobs_path: &blobs_dir,
packages_path: Some(&packages_dir),
diffs_path: Some(&diffs_dir),
};
let result = apply_package_patch(
"pkg:npm/x@1.0.0",
&pkg_dir,
&files,
&sources,
Some(TEST_UUID),
false,
false,
)
.await;
assert!(result.success, "expected success: {:?}", result.error);
assert_eq!(result.applied_via.get("index.js"), Some(&AppliedVia::Diff));
let written = tokio::fs::read(pkg_dir.join("index.js")).await.unwrap();
assert_eq!(written, patched);
}
#[tokio::test]
async fn test_apply_falls_back_to_blob_when_no_archives() {
let (_root, pkg_dir, blobs_dir, packages_dir, diffs_dir, files, _orig, patched) =
make_fixture().await;
tokio::fs::remove_file(packages_dir.join(format!("{TEST_UUID}.tar.gz")))
.await
.unwrap();
tokio::fs::remove_file(diffs_dir.join(format!("{TEST_UUID}.tar.gz")))
.await
.unwrap();
let sources = PatchSources {
blobs_path: &blobs_dir,
packages_path: Some(&packages_dir),
diffs_path: Some(&diffs_dir),
};
let result = apply_package_patch(
"pkg:npm/x@1.0.0",
&pkg_dir,
&files,
&sources,
Some(TEST_UUID),
false,
false,
)
.await;
assert!(result.success);
assert_eq!(result.applied_via.get("index.js"), Some(&AppliedVia::Blob));
let written = tokio::fs::read(pkg_dir.join("index.js")).await.unwrap();
assert_eq!(written, patched);
}
#[tokio::test]
async fn test_apply_uuid_none_disables_alt_sources() {
let (_root, pkg_dir, blobs_dir, packages_dir, diffs_dir, files, _orig, _patched) =
make_fixture().await;
let sources = PatchSources {
blobs_path: &blobs_dir,
packages_path: Some(&packages_dir),
diffs_path: Some(&diffs_dir),
};
let result = apply_package_patch(
"pkg:npm/x@1.0.0",
&pkg_dir,
&files,
&sources,
None,
false,
false,
)
.await;
assert!(result.success);
assert_eq!(result.applied_via.get("index.js"), Some(&AppliedVia::Blob));
}
#[tokio::test]
async fn test_apply_via_diff_falls_through_when_before_hash_mismatch() {
let (_root, pkg_dir, blobs_dir, packages_dir, diffs_dir, files, _orig, patched) =
make_fixture().await;
tokio::fs::remove_file(packages_dir.join(format!("{TEST_UUID}.tar.gz")))
.await
.unwrap();
tokio::fs::write(pkg_dir.join("index.js"), b"garbage")
.await
.unwrap();
let sources = PatchSources {
blobs_path: &blobs_dir,
packages_path: Some(&packages_dir),
diffs_path: Some(&diffs_dir),
};
let result = apply_package_patch(
"pkg:npm/x@1.0.0",
&pkg_dir,
&files,
&sources,
Some(TEST_UUID),
false,
true, )
.await;
assert!(result.success);
assert_eq!(result.applied_via.get("index.js"), Some(&AppliedVia::Blob));
let written = tokio::fs::read(pkg_dir.join("index.js")).await.unwrap();
assert_eq!(written, patched);
}
#[tokio::test]
async fn test_apply_via_package_skips_when_hash_mismatches() {
let (_root, pkg_dir, blobs_dir, packages_dir, diffs_dir, files, _orig, patched) =
make_fixture().await;
tokio::fs::remove_file(packages_dir.join(format!("{TEST_UUID}.tar.gz")))
.await
.unwrap();
write_uuid_archive(
&packages_dir,
TEST_UUID,
&[("index.js", b"corrupt package payload")],
);
let sources = PatchSources {
blobs_path: &blobs_dir,
packages_path: Some(&packages_dir),
diffs_path: Some(&diffs_dir),
};
let result = apply_package_patch(
"pkg:npm/x@1.0.0",
&pkg_dir,
&files,
&sources,
Some(TEST_UUID),
false,
false,
)
.await;
assert!(result.success);
assert_eq!(result.applied_via.get("index.js"), Some(&AppliedVia::Diff));
let written = tokio::fs::read(pkg_dir.join("index.js")).await.unwrap();
assert_eq!(written, patched);
}
#[tokio::test]
async fn test_apply_dry_run_does_not_touch_alternative_sources() {
let (_root, pkg_dir, blobs_dir, packages_dir, diffs_dir, files, original, _patched) =
make_fixture().await;
let sources = PatchSources {
blobs_path: &blobs_dir,
packages_path: Some(&packages_dir),
diffs_path: Some(&diffs_dir),
};
let result = apply_package_patch(
"pkg:npm/x@1.0.0",
&pkg_dir,
&files,
&sources,
Some(TEST_UUID),
true, false,
)
.await;
assert!(result.success);
assert!(result.files_patched.is_empty());
let on_disk = tokio::fs::read(pkg_dir.join("index.js")).await.unwrap();
assert_eq!(on_disk, original);
}
#[test]
fn test_applied_via_as_tag() {
assert_eq!(AppliedVia::Package.as_tag(), "package");
assert_eq!(AppliedVia::Diff.as_tag(), "diff");
assert_eq!(AppliedVia::Blob.as_tag(), "blob");
}
#[test]
fn test_patch_sources_blobs_only_disables_other_strategies() {
let dir = tempfile::tempdir().unwrap();
let sources = PatchSources::blobs_only(dir.path());
assert!(sources.packages_path.is_none());
assert!(sources.diffs_path.is_none());
}
}