1use std::collections::HashMap;
2use std::path::Path;
3
4use crate::hash::git_sha256::compute_git_sha256_from_bytes;
5use crate::manifest::schema::PatchFileInfo;
6use crate::patch::cow::break_hardlink_if_needed;
7use crate::patch::diff::apply_diff;
8use crate::patch::file_hash::compute_file_git_sha256;
9use crate::patch::package::read_archive_filtered;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum VerifyStatus {
14 Ready,
16 AlreadyPatched,
18 HashMismatch,
20 NotFound,
22}
23
24#[derive(Debug, Clone)]
26pub struct VerifyResult {
27 pub file: String,
28 pub status: VerifyStatus,
29 pub message: Option<String>,
30 pub current_hash: Option<String>,
31 pub expected_hash: Option<String>,
32 pub target_hash: Option<String>,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum AppliedVia {
38 Package,
40 Diff,
43 Blob,
45}
46
47impl AppliedVia {
48 pub fn as_tag(&self) -> &'static str {
50 match self {
51 AppliedVia::Package => "package",
52 AppliedVia::Diff => "diff",
53 AppliedVia::Blob => "blob",
54 }
55 }
56}
57
58#[derive(Debug, Clone, Copy)]
64pub struct PatchSources<'a> {
65 pub blobs_path: &'a Path,
66 pub packages_path: Option<&'a Path>,
67 pub diffs_path: Option<&'a Path>,
68}
69
70impl<'a> PatchSources<'a> {
71 pub fn blobs_only(blobs_path: &'a Path) -> Self {
75 Self {
76 blobs_path,
77 packages_path: None,
78 diffs_path: None,
79 }
80 }
81}
82
83#[derive(Debug, Clone)]
85pub struct ApplyResult {
86 pub package_key: String,
87 pub package_path: String,
88 pub success: bool,
89 pub files_verified: Vec<VerifyResult>,
90 pub files_patched: Vec<String>,
91 pub applied_via: HashMap<String, AppliedVia>,
94 pub error: Option<String>,
95 pub sidecar: Option<crate::patch::sidecars::SidecarRecord>,
104}
105
106pub fn normalize_file_path(file_name: &str) -> &str {
110 const PACKAGE_PREFIX: &str = "package/";
111 if let Some(stripped) = file_name.strip_prefix(PACKAGE_PREFIX) {
112 stripped
113 } else {
114 file_name
115 }
116}
117
118pub async fn verify_file_patch(
120 pkg_path: &Path,
121 file_name: &str,
122 file_info: &PatchFileInfo,
123) -> VerifyResult {
124 let normalized = normalize_file_path(file_name);
125 let filepath = pkg_path.join(normalized);
126
127 let is_new_file = file_info.before_hash.is_empty();
128
129 if tokio::fs::metadata(&filepath).await.is_err() {
131 if is_new_file {
133 return VerifyResult {
134 file: file_name.to_string(),
135 status: VerifyStatus::Ready,
136 message: None,
137 current_hash: None,
138 expected_hash: None,
139 target_hash: Some(file_info.after_hash.clone()),
140 };
141 }
142 return VerifyResult {
143 file: file_name.to_string(),
144 status: VerifyStatus::NotFound,
145 message: Some("File not found".to_string()),
146 current_hash: None,
147 expected_hash: None,
148 target_hash: None,
149 };
150 }
151
152 let current_hash = match compute_file_git_sha256(&filepath).await {
154 Ok(h) => h,
155 Err(e) => {
156 return VerifyResult {
157 file: file_name.to_string(),
158 status: VerifyStatus::NotFound,
159 message: Some(format!("Failed to hash file: {}", e)),
160 current_hash: None,
161 expected_hash: None,
162 target_hash: None,
163 };
164 }
165 };
166
167 if current_hash == file_info.after_hash {
169 return VerifyResult {
170 file: file_name.to_string(),
171 status: VerifyStatus::AlreadyPatched,
172 message: None,
173 current_hash: Some(current_hash),
174 expected_hash: None,
175 target_hash: None,
176 };
177 }
178
179 if is_new_file {
182 return VerifyResult {
183 file: file_name.to_string(),
184 status: VerifyStatus::Ready,
185 message: None,
186 current_hash: Some(current_hash),
187 expected_hash: None,
188 target_hash: Some(file_info.after_hash.clone()),
189 };
190 }
191
192 if current_hash != file_info.before_hash {
194 return VerifyResult {
195 file: file_name.to_string(),
196 status: VerifyStatus::HashMismatch,
197 message: Some("File hash does not match expected value".to_string()),
198 current_hash: Some(current_hash),
199 expected_hash: Some(file_info.before_hash.clone()),
200 target_hash: Some(file_info.after_hash.clone()),
201 };
202 }
203
204 VerifyResult {
205 file: file_name.to_string(),
206 status: VerifyStatus::Ready,
207 message: None,
208 current_hash: Some(current_hash),
209 expected_hash: None,
210 target_hash: Some(file_info.after_hash.clone()),
211 }
212}
213
214pub async fn apply_file_patch(
237 pkg_path: &Path,
238 file_name: &str,
239 patched_content: &[u8],
240 expected_hash: &str,
241) -> Result<(), std::io::Error> {
242 let normalized = normalize_file_path(file_name);
243 let filepath = pkg_path.join(normalized);
244
245 let content_hash = compute_git_sha256_from_bytes(patched_content);
250 if content_hash != expected_hash {
251 return Err(std::io::Error::new(
252 std::io::ErrorKind::InvalidData,
253 format!(
254 "Hash verification failed before patch. Expected: {}, Got: {}",
255 expected_hash, content_hash
256 ),
257 ));
258 }
259
260 let existing_meta = tokio::fs::metadata(&filepath).await.ok();
266
267 if let Some(parent) = filepath.parent() {
269 tokio::fs::create_dir_all(parent).await?;
270 }
271
272 break_hardlink_if_needed(&filepath).await?;
278
279 write_atomic(&filepath, patched_content).await?;
290
291 restore_file_permissions(&filepath, existing_meta.as_ref()).await?;
296
297 Ok(())
298}
299
300async fn write_atomic(target: &Path, content: &[u8]) -> std::io::Result<()> {
312 let parent = target.parent().unwrap_or_else(|| Path::new("."));
313 let stem = target
314 .file_name()
315 .map(|n| n.to_string_lossy().into_owned())
316 .unwrap_or_else(|| "anon".to_string());
317 let stage = parent.join(format!(
318 ".socket-stage-{}-{}",
319 stem,
320 uuid::Uuid::new_v4()
321 ));
322
323 let mut file = tokio::fs::OpenOptions::new()
324 .write(true)
325 .create_new(true)
326 .open(&stage)
327 .await?;
328
329 use tokio::io::AsyncWriteExt;
330 if let Err(e) = file.write_all(content).await {
331 let _ = tokio::fs::remove_file(&stage).await;
332 return Err(e);
333 }
334 if let Err(e) = file.sync_all().await {
335 let _ = tokio::fs::remove_file(&stage).await;
336 return Err(e);
337 }
338 drop(file);
339
340 if let Err(e) = tokio::fs::rename(&stage, target).await {
341 let _ = tokio::fs::remove_file(&stage).await;
342 return Err(e);
343 }
344 Ok(())
345}
346
347async fn restore_file_permissions(
357 filepath: &Path,
358 pre_patch: Option<&std::fs::Metadata>,
359) -> Result<(), std::io::Error> {
360 #[cfg(unix)]
361 {
362 use std::os::unix::fs::{MetadataExt, PermissionsExt};
363
364 match pre_patch {
365 Some(meta) => {
366 let restored = std::fs::Permissions::from_mode(meta.mode());
368 tokio::fs::set_permissions(filepath, restored).await?;
369 let uid = meta.uid();
370 let gid = meta.gid();
371 chown_blocking(filepath.to_path_buf(), Some(uid), Some(gid)).await?;
372 }
373 None => {
374 if let Some(parent) = filepath.parent() {
376 if let Ok(parent_meta) = tokio::fs::metadata(parent).await {
377 let uid = parent_meta.uid();
378 let gid = parent_meta.gid();
379 chown_blocking(filepath.to_path_buf(), Some(uid), Some(gid))
380 .await?;
381 }
382 }
383 let readonly = std::fs::Permissions::from_mode(0o444);
385 tokio::fs::set_permissions(filepath, readonly).await?;
386 }
387 }
388 }
389
390 #[cfg(windows)]
391 {
392 match pre_patch {
393 Some(meta) => {
394 let perms = meta.permissions();
397 tokio::fs::set_permissions(filepath, perms).await?;
398 }
399 None => {
400 if let Ok(meta) = tokio::fs::metadata(filepath).await {
402 let mut perms = meta.permissions();
403 perms.set_readonly(true);
404 tokio::fs::set_permissions(filepath, perms).await?;
405 }
406 }
407 }
408 }
409
410 let _ = filepath;
411 let _ = pre_patch;
412 Ok(())
413}
414
415#[cfg(unix)]
420async fn chown_blocking(
421 path: std::path::PathBuf,
422 uid: Option<u32>,
423 gid: Option<u32>,
424) -> Result<(), std::io::Error> {
425 tokio::task::spawn_blocking(move || std::os::unix::fs::chown(&path, uid, gid))
426 .await
427 .map_err(|e| std::io::Error::other(e.to_string()))?
428}
429
430pub async fn apply_package_patch(
443 package_key: &str,
444 pkg_path: &Path,
445 files: &HashMap<String, PatchFileInfo>,
446 sources: &PatchSources<'_>,
447 uuid: Option<&str>,
448 dry_run: bool,
449 force: bool,
450) -> ApplyResult {
451 let mut result = ApplyResult {
452 package_key: package_key.to_string(),
453 package_path: pkg_path.display().to_string(),
454 success: false,
455 files_verified: Vec::new(),
456 files_patched: Vec::new(),
457 applied_via: HashMap::new(),
458 error: None,
459 sidecar: None,
460 };
461
462 for (file_name, file_info) in files {
464 let mut verify_result = verify_file_patch(pkg_path, file_name, file_info).await;
465
466 if verify_result.status != VerifyStatus::Ready
467 && verify_result.status != VerifyStatus::AlreadyPatched
468 {
469 if force {
470 match verify_result.status {
471 VerifyStatus::HashMismatch => {
472 verify_result.status = VerifyStatus::Ready;
474 }
475 VerifyStatus::NotFound => {
476 result.files_verified.push(verify_result);
478 continue;
479 }
480 _ => {}
481 }
482 } else {
483 let msg = verify_result
484 .message
485 .clone()
486 .unwrap_or_else(|| format!("{:?}", verify_result.status));
487 result.error = Some(format!(
488 "Cannot apply patch: {} - {}",
489 verify_result.file, msg
490 ));
491 result.files_verified.push(verify_result);
492 return result;
493 }
494 }
495
496 result.files_verified.push(verify_result);
497 }
498
499 let all_already_patched = result
501 .files_verified
502 .iter()
503 .all(|v| v.status == VerifyStatus::AlreadyPatched);
504
505 if all_already_patched {
506 result.success = true;
507 return result;
508 }
509
510 let all_done_or_skipped = result
512 .files_verified
513 .iter()
514 .all(|v| v.status == VerifyStatus::AlreadyPatched || v.status == VerifyStatus::NotFound);
515
516 if all_done_or_skipped {
517 let not_found_count = result.files_verified.iter()
519 .filter(|v| v.status == VerifyStatus::NotFound)
520 .count();
521 result.success = true;
522 result.error = Some(format!(
523 "All patch files were skipped: {} not found on disk (--force)",
524 not_found_count
525 ));
526 return result;
527 }
528
529 if dry_run {
531 result.success = true;
532 return result;
533 }
534
535 let package_entries = match (uuid, sources.packages_path) {
538 (Some(uuid), Some(dir)) => load_archive_if_present(dir, uuid, files).await,
539 _ => None,
540 };
541 let diff_entries = match (uuid, sources.diffs_path) {
542 (Some(uuid), Some(dir)) => load_archive_if_present(dir, uuid, files).await,
543 _ => None,
544 };
545
546 for (file_name, file_info) in files {
549 let verify_result = result.files_verified.iter().find(|v| v.file == *file_name);
550 if let Some(vr) = verify_result {
551 if vr.status == VerifyStatus::AlreadyPatched
552 || vr.status == VerifyStatus::NotFound
553 {
554 continue;
555 }
556 }
557
558 let normalized = normalize_file_path(file_name).to_string();
559
560 if try_apply_from_archive(
562 package_entries.as_ref(),
563 &normalized,
564 pkg_path,
565 file_name,
566 file_info,
567 )
568 .await
569 {
570 result.files_patched.push(file_name.clone());
571 result
572 .applied_via
573 .insert(file_name.clone(), AppliedVia::Package);
574 continue;
575 }
576
577 let current_hash_for_diff = verify_result.and_then(|v| v.current_hash.as_deref());
586 if try_apply_from_diff(
587 diff_entries.as_ref(),
588 &normalized,
589 pkg_path,
590 file_name,
591 file_info,
592 current_hash_for_diff,
593 )
594 .await
595 {
596 result.files_patched.push(file_name.clone());
597 result
598 .applied_via
599 .insert(file_name.clone(), AppliedVia::Diff);
600 continue;
601 }
602
603 let blob_path = sources.blobs_path.join(&file_info.after_hash);
605 let patched_content = match tokio::fs::read(&blob_path).await {
606 Ok(content) => content,
607 Err(e) => {
608 result.error = Some(format!(
609 "Failed to read blob {}: {}",
610 file_info.after_hash, e
611 ));
612 return result;
613 }
614 };
615
616 if let Err(e) =
617 apply_file_patch(pkg_path, file_name, &patched_content, &file_info.after_hash).await
618 {
619 result.error = Some(e.to_string());
620 return result;
621 }
622
623 result.files_patched.push(file_name.clone());
624 result
625 .applied_via
626 .insert(file_name.clone(), AppliedVia::Blob);
627 }
628
629 if !result.files_patched.is_empty() {
637 use crate::patch::sidecars::{
638 dispatch_fixup, SidecarAdvisory, SidecarAdvisoryCode, SidecarRecord, SidecarSeverity,
639 };
640 match dispatch_fixup(package_key, pkg_path, &result.files_patched, files).await {
641 Ok(Some(record)) => result.sidecar = Some(record),
642 Ok(None) => {}
643 Err(e) => {
644 let ecosystem = crate::crawlers::Ecosystem::from_purl(package_key)
645 .map(|eco| eco.cli_name().to_string())
646 .unwrap_or_else(|| "unknown".to_string());
647 result.sidecar = Some(SidecarRecord {
648 purl: package_key.to_string(),
649 ecosystem,
650 files: Vec::new(),
651 advisory: Some(SidecarAdvisory {
652 code: SidecarAdvisoryCode::SidecarFixupFailed,
653 severity: SidecarSeverity::Error,
654 message: format!("sidecar fixup failed (patch still applied): {}", e),
655 }),
656 });
657 }
658 }
659 }
660
661 result.success = true;
662 result
663}
664
665async fn try_apply_from_archive(
668 package_entries: Option<&HashMap<String, Vec<u8>>>,
669 normalized_path: &str,
670 pkg_path: &Path,
671 file_name: &str,
672 file_info: &PatchFileInfo,
673) -> bool {
674 let entries = match package_entries {
675 Some(e) => e,
676 None => return false,
677 };
678 let bytes = match entries.get(normalized_path) {
679 Some(b) => b,
680 None => return false,
681 };
682 if compute_git_sha256_from_bytes(bytes) != file_info.after_hash {
683 return false;
684 }
685 apply_file_patch(pkg_path, file_name, bytes, &file_info.after_hash)
686 .await
687 .is_ok()
688}
689
690async fn try_apply_from_diff(
702 diff_entries: Option<&HashMap<String, Vec<u8>>>,
703 normalized_path: &str,
704 pkg_path: &Path,
705 file_name: &str,
706 file_info: &PatchFileInfo,
707 current_hash: Option<&str>,
708) -> bool {
709 let entries = match diff_entries {
710 Some(e) => e,
711 None => return false,
712 };
713 let delta = match entries.get(normalized_path) {
714 Some(d) => d,
715 None => return false,
716 };
717 if file_info.before_hash.is_empty() {
718 return false;
720 }
721 match current_hash {
727 Some(h) if h == file_info.before_hash => {}
728 _ => return false,
729 }
730
731 let on_disk_path = pkg_path.join(normalized_path);
732 let before_bytes = match tokio::fs::read(&on_disk_path).await {
733 Ok(b) => b,
734 Err(_) => return false,
735 };
736 let patched = match apply_diff(&before_bytes, delta) {
737 Ok(p) => p,
738 Err(_) => return false,
739 };
740 if compute_git_sha256_from_bytes(&patched) != file_info.after_hash {
741 return false;
742 }
743 apply_file_patch(pkg_path, file_name, &patched, &file_info.after_hash)
744 .await
745 .is_ok()
746}
747
748async fn load_archive_if_present(
753 dir: &Path,
754 uuid: &str,
755 files: &HashMap<String, PatchFileInfo>,
756) -> Option<HashMap<String, Vec<u8>>> {
757 let archive_path = dir.join(format!("{uuid}.tar.gz"));
758 if tokio::fs::metadata(&archive_path).await.is_err() {
759 return None;
760 }
761 let archive_path_owned = archive_path.clone();
765 let files_owned = files.clone();
766 tokio::task::spawn_blocking(move || read_archive_filtered(&archive_path_owned, &files_owned))
767 .await
768 .ok()
769 .and_then(|r| r.ok())
770}
771
772#[cfg(test)]
773mod tests {
774 use super::*;
775 use crate::hash::git_sha256::compute_git_sha256_from_bytes;
776
777 #[test]
778 fn test_normalize_file_path_with_prefix() {
779 assert_eq!(normalize_file_path("package/lib/server.js"), "lib/server.js");
780 }
781
782 #[test]
783 fn test_normalize_file_path_without_prefix() {
784 assert_eq!(normalize_file_path("lib/server.js"), "lib/server.js");
785 }
786
787 #[test]
788 fn test_normalize_file_path_just_prefix() {
789 assert_eq!(normalize_file_path("package/"), "");
790 }
791
792 #[test]
793 fn test_normalize_file_path_package_not_prefix() {
794 assert_eq!(normalize_file_path("packagefoo/bar.js"), "packagefoo/bar.js");
796 }
797
798 #[tokio::test]
799 async fn test_verify_file_patch_not_found() {
800 let dir = tempfile::tempdir().unwrap();
801 let file_info = PatchFileInfo {
802 before_hash: "aaa".to_string(),
803 after_hash: "bbb".to_string(),
804 };
805
806 let result = verify_file_patch(dir.path(), "nonexistent.js", &file_info).await;
807 assert_eq!(result.status, VerifyStatus::NotFound);
808 }
809
810 #[tokio::test]
811 async fn test_verify_file_patch_ready() {
812 let dir = tempfile::tempdir().unwrap();
813 let content = b"original content";
814 let before_hash = compute_git_sha256_from_bytes(content);
815 let after_hash = "bbbbbbbb".to_string();
816
817 tokio::fs::write(dir.path().join("index.js"), content)
818 .await
819 .unwrap();
820
821 let file_info = PatchFileInfo {
822 before_hash: before_hash.clone(),
823 after_hash,
824 };
825
826 let result = verify_file_patch(dir.path(), "index.js", &file_info).await;
827 assert_eq!(result.status, VerifyStatus::Ready);
828 assert_eq!(result.current_hash.unwrap(), before_hash);
829 }
830
831 #[tokio::test]
832 async fn test_verify_file_patch_already_patched() {
833 let dir = tempfile::tempdir().unwrap();
834 let content = b"patched content";
835 let after_hash = compute_git_sha256_from_bytes(content);
836
837 tokio::fs::write(dir.path().join("index.js"), content)
838 .await
839 .unwrap();
840
841 let file_info = PatchFileInfo {
842 before_hash: "aaaa".to_string(),
843 after_hash: after_hash.clone(),
844 };
845
846 let result = verify_file_patch(dir.path(), "index.js", &file_info).await;
847 assert_eq!(result.status, VerifyStatus::AlreadyPatched);
848 }
849
850 #[tokio::test]
851 async fn test_verify_file_patch_hash_mismatch() {
852 let dir = tempfile::tempdir().unwrap();
853 tokio::fs::write(dir.path().join("index.js"), b"something else")
854 .await
855 .unwrap();
856
857 let file_info = PatchFileInfo {
858 before_hash: "aaaa".to_string(),
859 after_hash: "bbbb".to_string(),
860 };
861
862 let result = verify_file_patch(dir.path(), "index.js", &file_info).await;
863 assert_eq!(result.status, VerifyStatus::HashMismatch);
864 }
865
866 #[tokio::test]
867 async fn test_verify_with_package_prefix() {
868 let dir = tempfile::tempdir().unwrap();
869 let content = b"original content";
870 let before_hash = compute_git_sha256_from_bytes(content);
871
872 tokio::fs::create_dir_all(dir.path().join("lib")).await.unwrap();
874 tokio::fs::write(dir.path().join("lib/server.js"), content)
875 .await
876 .unwrap();
877
878 let file_info = PatchFileInfo {
879 before_hash: before_hash.clone(),
880 after_hash: "bbbb".to_string(),
881 };
882
883 let result = verify_file_patch(dir.path(), "package/lib/server.js", &file_info).await;
884 assert_eq!(result.status, VerifyStatus::Ready);
885 }
886
887 #[tokio::test]
888 async fn test_apply_file_patch_success() {
889 let dir = tempfile::tempdir().unwrap();
890 let original = b"original";
891 let patched = b"patched content";
892 let patched_hash = compute_git_sha256_from_bytes(patched);
893
894 tokio::fs::write(dir.path().join("index.js"), original)
895 .await
896 .unwrap();
897
898 apply_file_patch(dir.path(), "index.js", patched, &patched_hash)
899 .await
900 .unwrap();
901
902 let written = tokio::fs::read(dir.path().join("index.js")).await.unwrap();
903 assert_eq!(written, patched);
904 }
905
906 #[tokio::test]
907 async fn test_apply_file_patch_hash_mismatch() {
908 let dir = tempfile::tempdir().unwrap();
909 tokio::fs::write(dir.path().join("index.js"), b"original")
910 .await
911 .unwrap();
912
913 let result =
914 apply_file_patch(dir.path(), "index.js", b"patched content", "wrong_hash").await;
915 assert!(result.is_err());
916 let err = result.unwrap_err();
917 assert!(err.to_string().contains("Hash verification failed"));
918 }
919
920 #[tokio::test]
925 async fn test_apply_file_patch_hash_mismatch_leaves_original_intact() {
926 let dir = tempfile::tempdir().unwrap();
927 let path = dir.path().join("index.js");
928 tokio::fs::write(&path, b"original").await.unwrap();
929
930 let result = apply_file_patch(dir.path(), "index.js", b"patched", "deadbeef").await;
931 assert!(result.is_err());
932
933 assert_eq!(tokio::fs::read(&path).await.unwrap(), b"original");
935
936 let mut entries = tokio::fs::read_dir(dir.path()).await.unwrap();
938 while let Some(entry) = entries.next_entry().await.unwrap() {
939 let name = entry.file_name().to_string_lossy().to_string();
940 assert!(
941 !name.starts_with(".socket-stage-"),
942 "stage file leaked into parent dir: {name}"
943 );
944 }
945 }
946
947 #[cfg(unix)]
952 #[tokio::test]
953 async fn test_apply_file_patch_does_not_propagate_to_hardlinked_sibling() {
954 let dir = tempfile::tempdir().unwrap();
955 let project = dir.path().join("project-b").join("foo.js");
956 let store = dir.path().join("store-a.js");
957 tokio::fs::create_dir_all(project.parent().unwrap())
958 .await
959 .unwrap();
960
961 tokio::fs::write(&store, b"original").await.unwrap();
965 tokio::fs::hard_link(&store, &project).await.unwrap();
966
967 let patched = b"patched";
968 let patched_hash = compute_git_sha256_from_bytes(patched);
969 apply_file_patch(project.parent().unwrap(), "foo.js", patched, &patched_hash)
970 .await
971 .unwrap();
972
973 assert_eq!(tokio::fs::read(&project).await.unwrap(), b"patched");
975 assert_eq!(tokio::fs::read(&store).await.unwrap(), b"original");
977 }
978
979 #[cfg(unix)]
983 #[tokio::test]
984 async fn test_apply_file_patch_preserves_readonly_mode() {
985 use std::os::unix::fs::PermissionsExt;
986
987 let dir = tempfile::tempdir().unwrap();
988 let path = dir.path().join("index.js");
989 let original = b"original";
990 let patched = b"patched content";
991 let patched_hash = compute_git_sha256_from_bytes(patched);
992
993 tokio::fs::write(&path, original).await.unwrap();
994 tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o444))
996 .await
997 .unwrap();
998
999 apply_file_patch(dir.path(), "index.js", patched, &patched_hash)
1000 .await
1001 .unwrap();
1002
1003 let written = tokio::fs::read(&path).await.unwrap();
1005 assert_eq!(written, patched);
1006 let mode_after = tokio::fs::metadata(&path).await.unwrap().permissions().mode()
1008 & 0o7777;
1009 assert_eq!(
1010 mode_after, 0o444,
1011 "mode must be restored to the pre-patch value after the write"
1012 );
1013 }
1014
1015 #[cfg(unix)]
1018 #[tokio::test]
1019 async fn test_apply_file_patch_preserves_executable_mode() {
1020 use std::os::unix::fs::PermissionsExt;
1021
1022 let dir = tempfile::tempdir().unwrap();
1023 let path = dir.path().join("bin.sh");
1024 let original = b"#!/bin/sh\necho old\n";
1025 let patched = b"#!/bin/sh\necho new\n";
1026 let patched_hash = compute_git_sha256_from_bytes(patched);
1027
1028 tokio::fs::write(&path, original).await.unwrap();
1029 tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
1030 .await
1031 .unwrap();
1032
1033 apply_file_patch(dir.path(), "bin.sh", patched, &patched_hash)
1034 .await
1035 .unwrap();
1036
1037 let mode_after = tokio::fs::metadata(&path).await.unwrap().permissions().mode()
1038 & 0o7777;
1039 assert_eq!(mode_after, 0o755);
1040 }
1041
1042 #[cfg(unix)]
1049 #[tokio::test]
1050 async fn test_apply_file_patch_new_file_is_readonly_and_inherits_dir_owner() {
1051 use std::os::unix::fs::{MetadataExt, PermissionsExt};
1052
1053 let dir = tempfile::tempdir().unwrap();
1054 let nested = "new-dir/new.js";
1055 let patched = b"brand new file content\n";
1056 let patched_hash = compute_git_sha256_from_bytes(patched);
1057
1058 apply_file_patch(dir.path(), nested, patched, &patched_hash)
1060 .await
1061 .unwrap();
1062
1063 let path = dir.path().join(nested);
1064 let mode = tokio::fs::metadata(&path).await.unwrap().permissions().mode()
1066 & 0o7777;
1067 assert_eq!(mode, 0o444, "new files default to read-only");
1068
1069 let parent_meta = tokio::fs::metadata(path.parent().unwrap()).await.unwrap();
1071 let file_meta = tokio::fs::metadata(&path).await.unwrap();
1072 assert_eq!(file_meta.uid(), parent_meta.uid());
1073 assert_eq!(file_meta.gid(), parent_meta.gid());
1074 }
1075
1076 #[cfg(unix)]
1080 #[tokio::test]
1081 async fn test_apply_file_patch_preserves_uid_gid() {
1082 use std::os::unix::fs::MetadataExt;
1083
1084 let dir = tempfile::tempdir().unwrap();
1085 let path = dir.path().join("index.js");
1086 let original = b"orig";
1087 let patched = b"new";
1088 let patched_hash = compute_git_sha256_from_bytes(patched);
1089
1090 tokio::fs::write(&path, original).await.unwrap();
1091 let pre = tokio::fs::metadata(&path).await.unwrap();
1092
1093 apply_file_patch(dir.path(), "index.js", patched, &patched_hash)
1094 .await
1095 .unwrap();
1096
1097 let post = tokio::fs::metadata(&path).await.unwrap();
1098 assert_eq!(pre.uid(), post.uid());
1099 assert_eq!(pre.gid(), post.gid());
1100 }
1101
1102 #[tokio::test]
1103 async fn test_apply_package_patch_success() {
1104 let pkg_dir = tempfile::tempdir().unwrap();
1105 let blobs_dir = tempfile::tempdir().unwrap();
1106
1107 let original = b"original content";
1108 let patched = b"patched content";
1109 let before_hash = compute_git_sha256_from_bytes(original);
1110 let after_hash = compute_git_sha256_from_bytes(patched);
1111
1112 tokio::fs::write(pkg_dir.path().join("index.js"), original)
1114 .await
1115 .unwrap();
1116
1117 tokio::fs::write(blobs_dir.path().join(&after_hash), patched)
1119 .await
1120 .unwrap();
1121
1122 let mut files = HashMap::new();
1123 files.insert(
1124 "index.js".to_string(),
1125 PatchFileInfo {
1126 before_hash,
1127 after_hash: after_hash.clone(),
1128 },
1129 );
1130
1131 let result =
1132 apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, &PatchSources::blobs_only(blobs_dir.path()), None, false, false)
1133 .await;
1134
1135 assert!(result.success);
1136 assert_eq!(result.files_patched.len(), 1);
1137 assert!(result.error.is_none());
1138 }
1139
1140 #[tokio::test]
1141 async fn test_apply_package_patch_dry_run() {
1142 let pkg_dir = tempfile::tempdir().unwrap();
1143 let blobs_dir = tempfile::tempdir().unwrap();
1144
1145 let original = b"original content";
1146 let before_hash = compute_git_sha256_from_bytes(original);
1147
1148 tokio::fs::write(pkg_dir.path().join("index.js"), original)
1149 .await
1150 .unwrap();
1151
1152 let mut files = HashMap::new();
1153 files.insert(
1154 "index.js".to_string(),
1155 PatchFileInfo {
1156 before_hash,
1157 after_hash: "bbbb".to_string(),
1158 },
1159 );
1160
1161 let result =
1162 apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, &PatchSources::blobs_only(blobs_dir.path()), None, true, false)
1163 .await;
1164
1165 assert!(result.success);
1166 assert_eq!(result.files_patched.len(), 0); let content = tokio::fs::read(pkg_dir.path().join("index.js")).await.unwrap();
1170 assert_eq!(content, original);
1171 }
1172
1173 #[tokio::test]
1174 async fn test_apply_package_patch_all_already_patched() {
1175 let pkg_dir = tempfile::tempdir().unwrap();
1176 let blobs_dir = tempfile::tempdir().unwrap();
1177
1178 let patched = b"patched content";
1179 let after_hash = compute_git_sha256_from_bytes(patched);
1180
1181 tokio::fs::write(pkg_dir.path().join("index.js"), patched)
1182 .await
1183 .unwrap();
1184
1185 let mut files = HashMap::new();
1186 files.insert(
1187 "index.js".to_string(),
1188 PatchFileInfo {
1189 before_hash: "aaaa".to_string(),
1190 after_hash,
1191 },
1192 );
1193
1194 let result =
1195 apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, &PatchSources::blobs_only(blobs_dir.path()), None, false, false)
1196 .await;
1197
1198 assert!(result.success);
1199 assert_eq!(result.files_patched.len(), 0);
1200 }
1201
1202 #[tokio::test]
1203 async fn test_apply_package_patch_hash_mismatch_blocks() {
1204 let pkg_dir = tempfile::tempdir().unwrap();
1205 let blobs_dir = tempfile::tempdir().unwrap();
1206
1207 tokio::fs::write(pkg_dir.path().join("index.js"), b"something unexpected")
1208 .await
1209 .unwrap();
1210
1211 let mut files = HashMap::new();
1212 files.insert(
1213 "index.js".to_string(),
1214 PatchFileInfo {
1215 before_hash: "aaaa".to_string(),
1216 after_hash: "bbbb".to_string(),
1217 },
1218 );
1219
1220 let result =
1221 apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, &PatchSources::blobs_only(blobs_dir.path()), None, false, false)
1222 .await;
1223
1224 assert!(!result.success);
1225 assert!(result.error.is_some());
1226 }
1227
1228 #[tokio::test]
1229 async fn test_apply_package_patch_force_hash_mismatch() {
1230 let pkg_dir = tempfile::tempdir().unwrap();
1231 let blobs_dir = tempfile::tempdir().unwrap();
1232
1233 let patched = b"patched content";
1234 let after_hash = compute_git_sha256_from_bytes(patched);
1235
1236 tokio::fs::write(pkg_dir.path().join("index.js"), b"something unexpected")
1238 .await
1239 .unwrap();
1240
1241 tokio::fs::write(blobs_dir.path().join(&after_hash), patched)
1243 .await
1244 .unwrap();
1245
1246 let mut files = HashMap::new();
1247 files.insert(
1248 "index.js".to_string(),
1249 PatchFileInfo {
1250 before_hash: "aaaa".to_string(),
1251 after_hash: after_hash.clone(),
1252 },
1253 );
1254
1255 let result =
1257 apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, &PatchSources::blobs_only(blobs_dir.path()), None, false, false)
1258 .await;
1259 assert!(!result.success);
1260
1261 tokio::fs::write(pkg_dir.path().join("index.js"), b"something unexpected")
1263 .await
1264 .unwrap();
1265
1266 let result =
1268 apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, &PatchSources::blobs_only(blobs_dir.path()), None, false, true)
1269 .await;
1270 assert!(result.success);
1271 assert_eq!(result.files_patched.len(), 1);
1272
1273 let written = tokio::fs::read(pkg_dir.path().join("index.js")).await.unwrap();
1274 assert_eq!(written, patched);
1275 }
1276
1277 #[tokio::test]
1278 async fn test_apply_package_patch_force_not_found_skips() {
1279 let pkg_dir = tempfile::tempdir().unwrap();
1280 let blobs_dir = tempfile::tempdir().unwrap();
1281
1282 let mut files = HashMap::new();
1283 files.insert(
1284 "missing.js".to_string(),
1285 PatchFileInfo {
1286 before_hash: "aaaa".to_string(),
1287 after_hash: "bbbb".to_string(),
1288 },
1289 );
1290
1291 let result =
1293 apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, &PatchSources::blobs_only(blobs_dir.path()), None, false, false)
1294 .await;
1295 assert!(!result.success);
1296
1297 let result =
1299 apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, &PatchSources::blobs_only(blobs_dir.path()), None, false, true)
1300 .await;
1301 assert!(result.success);
1302 assert_eq!(result.files_patched.len(), 0);
1303 }
1304
1305 use flate2::write::GzEncoder;
1313 use flate2::Compression as GzCompression;
1314 use qbsdiff::Bsdiff;
1315
1316 const TEST_UUID: &str = "11111111-1111-4111-8111-111111111111";
1317
1318 fn write_uuid_archive(dir: &Path, uuid: &str, entries: &[(&str, &[u8])]) {
1321 let archive_path = dir.join(format!("{uuid}.tar.gz"));
1322 let file = std::fs::File::create(&archive_path).unwrap();
1323 let gz = GzEncoder::new(file, GzCompression::default());
1324 let mut builder = tar::Builder::new(gz);
1325 for (name, data) in entries {
1326 let mut header = tar::Header::new_gnu();
1327 header.set_size(data.len() as u64);
1328 header.set_mode(0o644);
1329 header.set_cksum();
1330 builder.append_data(&mut header, name, *data).unwrap();
1331 }
1332 builder.into_inner().unwrap().finish().unwrap();
1333 }
1334
1335 fn make_delta(before: &[u8], after: &[u8]) -> Vec<u8> {
1336 let mut delta = Vec::new();
1337 Bsdiff::new(before, after)
1338 .compare(std::io::Cursor::new(&mut delta))
1339 .unwrap();
1340 delta
1341 }
1342
1343 async fn make_fixture() -> (
1347 tempfile::TempDir, std::path::PathBuf, std::path::PathBuf, std::path::PathBuf, std::path::PathBuf, HashMap<String, PatchFileInfo>,
1353 Vec<u8>, Vec<u8>, ) {
1356 let root = tempfile::tempdir().unwrap();
1357 let pkg_dir = root.path().join("pkg");
1358 let blobs_dir = root.path().join("blobs");
1359 let packages_dir = root.path().join("packages");
1360 let diffs_dir = root.path().join("diffs");
1361 tokio::fs::create_dir_all(&pkg_dir).await.unwrap();
1362 tokio::fs::create_dir_all(&blobs_dir).await.unwrap();
1363 tokio::fs::create_dir_all(&packages_dir).await.unwrap();
1364 tokio::fs::create_dir_all(&diffs_dir).await.unwrap();
1365
1366 let original: Vec<u8> = b"the original content of the file".to_vec();
1367 let patched: Vec<u8> = b"the PATCHED content of the file!".to_vec();
1368 let before_hash = compute_git_sha256_from_bytes(&original);
1369 let after_hash = compute_git_sha256_from_bytes(&patched);
1370
1371 tokio::fs::write(pkg_dir.join("index.js"), &original)
1373 .await
1374 .unwrap();
1375
1376 tokio::fs::write(blobs_dir.join(&after_hash), &patched)
1378 .await
1379 .unwrap();
1380
1381 write_uuid_archive(&packages_dir, TEST_UUID, &[("index.js", &patched)]);
1383
1384 let delta = make_delta(&original, &patched);
1386 write_uuid_archive(&diffs_dir, TEST_UUID, &[("index.js", &delta)]);
1387
1388 let mut files = HashMap::new();
1389 files.insert(
1390 "index.js".to_string(),
1391 PatchFileInfo {
1392 before_hash,
1393 after_hash,
1394 },
1395 );
1396
1397 (root, pkg_dir, blobs_dir, packages_dir, diffs_dir, files, original, patched)
1398 }
1399
1400 #[tokio::test]
1401 async fn test_apply_via_package_when_archive_present() {
1402 let (_root, pkg_dir, blobs_dir, packages_dir, diffs_dir, files, _orig, patched) =
1403 make_fixture().await;
1404
1405 let sources = PatchSources {
1406 blobs_path: &blobs_dir,
1407 packages_path: Some(&packages_dir),
1408 diffs_path: Some(&diffs_dir),
1409 };
1410 let result = apply_package_patch(
1411 "pkg:npm/x@1.0.0",
1412 &pkg_dir,
1413 &files,
1414 &sources,
1415 Some(TEST_UUID),
1416 false,
1417 false,
1418 )
1419 .await;
1420
1421 assert!(result.success, "expected success: {:?}", result.error);
1422 assert_eq!(result.files_patched, vec!["index.js".to_string()]);
1423 assert_eq!(
1424 result.applied_via.get("index.js"),
1425 Some(&AppliedVia::Package)
1426 );
1427 let written = tokio::fs::read(pkg_dir.join("index.js")).await.unwrap();
1428 assert_eq!(written, patched);
1429 }
1430
1431 #[tokio::test]
1432 async fn test_apply_falls_back_to_diff_when_no_package() {
1433 let (_root, pkg_dir, blobs_dir, packages_dir, diffs_dir, files, _orig, patched) =
1434 make_fixture().await;
1435 tokio::fs::remove_file(packages_dir.join(format!("{TEST_UUID}.tar.gz")))
1437 .await
1438 .unwrap();
1439
1440 let sources = PatchSources {
1441 blobs_path: &blobs_dir,
1442 packages_path: Some(&packages_dir),
1443 diffs_path: Some(&diffs_dir),
1444 };
1445 let result = apply_package_patch(
1446 "pkg:npm/x@1.0.0",
1447 &pkg_dir,
1448 &files,
1449 &sources,
1450 Some(TEST_UUID),
1451 false,
1452 false,
1453 )
1454 .await;
1455
1456 assert!(result.success, "expected success: {:?}", result.error);
1457 assert_eq!(result.applied_via.get("index.js"), Some(&AppliedVia::Diff));
1458 let written = tokio::fs::read(pkg_dir.join("index.js")).await.unwrap();
1459 assert_eq!(written, patched);
1460 }
1461
1462 #[tokio::test]
1463 async fn test_apply_falls_back_to_blob_when_no_archives() {
1464 let (_root, pkg_dir, blobs_dir, packages_dir, diffs_dir, files, _orig, patched) =
1465 make_fixture().await;
1466 tokio::fs::remove_file(packages_dir.join(format!("{TEST_UUID}.tar.gz")))
1468 .await
1469 .unwrap();
1470 tokio::fs::remove_file(diffs_dir.join(format!("{TEST_UUID}.tar.gz")))
1471 .await
1472 .unwrap();
1473
1474 let sources = PatchSources {
1475 blobs_path: &blobs_dir,
1476 packages_path: Some(&packages_dir),
1477 diffs_path: Some(&diffs_dir),
1478 };
1479 let result = apply_package_patch(
1480 "pkg:npm/x@1.0.0",
1481 &pkg_dir,
1482 &files,
1483 &sources,
1484 Some(TEST_UUID),
1485 false,
1486 false,
1487 )
1488 .await;
1489
1490 assert!(result.success);
1491 assert_eq!(result.applied_via.get("index.js"), Some(&AppliedVia::Blob));
1492 let written = tokio::fs::read(pkg_dir.join("index.js")).await.unwrap();
1493 assert_eq!(written, patched);
1494 }
1495
1496 #[tokio::test]
1497 async fn test_apply_uuid_none_disables_alt_sources() {
1498 let (_root, pkg_dir, blobs_dir, packages_dir, diffs_dir, files, _orig, _patched) =
1501 make_fixture().await;
1502
1503 let sources = PatchSources {
1504 blobs_path: &blobs_dir,
1505 packages_path: Some(&packages_dir),
1506 diffs_path: Some(&diffs_dir),
1507 };
1508 let result = apply_package_patch(
1509 "pkg:npm/x@1.0.0",
1510 &pkg_dir,
1511 &files,
1512 &sources,
1513 None,
1514 false,
1515 false,
1516 )
1517 .await;
1518
1519 assert!(result.success);
1520 assert_eq!(result.applied_via.get("index.js"), Some(&AppliedVia::Blob));
1521 }
1522
1523 #[tokio::test]
1524 async fn test_apply_via_diff_falls_through_when_before_hash_mismatch() {
1525 let (_root, pkg_dir, blobs_dir, packages_dir, diffs_dir, files, _orig, patched) =
1529 make_fixture().await;
1530 tokio::fs::remove_file(packages_dir.join(format!("{TEST_UUID}.tar.gz")))
1531 .await
1532 .unwrap();
1533 tokio::fs::write(pkg_dir.join("index.js"), b"garbage")
1537 .await
1538 .unwrap();
1539
1540 let sources = PatchSources {
1541 blobs_path: &blobs_dir,
1542 packages_path: Some(&packages_dir),
1543 diffs_path: Some(&diffs_dir),
1544 };
1545 let result = apply_package_patch(
1546 "pkg:npm/x@1.0.0",
1547 &pkg_dir,
1548 &files,
1549 &sources,
1550 Some(TEST_UUID),
1551 false,
1552 true, )
1554 .await;
1555
1556 assert!(result.success);
1557 assert_eq!(result.applied_via.get("index.js"), Some(&AppliedVia::Blob));
1559 let written = tokio::fs::read(pkg_dir.join("index.js")).await.unwrap();
1560 assert_eq!(written, patched);
1561 }
1562
1563 #[tokio::test]
1564 async fn test_apply_via_package_skips_when_hash_mismatches() {
1565 let (_root, pkg_dir, blobs_dir, packages_dir, diffs_dir, files, _orig, patched) =
1569 make_fixture().await;
1570 tokio::fs::remove_file(packages_dir.join(format!("{TEST_UUID}.tar.gz")))
1572 .await
1573 .unwrap();
1574 write_uuid_archive(
1575 &packages_dir,
1576 TEST_UUID,
1577 &[("index.js", b"corrupt package payload")],
1578 );
1579
1580 let sources = PatchSources {
1581 blobs_path: &blobs_dir,
1582 packages_path: Some(&packages_dir),
1583 diffs_path: Some(&diffs_dir),
1584 };
1585 let result = apply_package_patch(
1586 "pkg:npm/x@1.0.0",
1587 &pkg_dir,
1588 &files,
1589 &sources,
1590 Some(TEST_UUID),
1591 false,
1592 false,
1593 )
1594 .await;
1595
1596 assert!(result.success);
1597 assert_eq!(result.applied_via.get("index.js"), Some(&AppliedVia::Diff));
1599 let written = tokio::fs::read(pkg_dir.join("index.js")).await.unwrap();
1600 assert_eq!(written, patched);
1601 }
1602
1603 #[tokio::test]
1604 async fn test_apply_dry_run_does_not_touch_alternative_sources() {
1605 let (_root, pkg_dir, blobs_dir, packages_dir, diffs_dir, files, original, _patched) =
1608 make_fixture().await;
1609
1610 let sources = PatchSources {
1611 blobs_path: &blobs_dir,
1612 packages_path: Some(&packages_dir),
1613 diffs_path: Some(&diffs_dir),
1614 };
1615 let result = apply_package_patch(
1616 "pkg:npm/x@1.0.0",
1617 &pkg_dir,
1618 &files,
1619 &sources,
1620 Some(TEST_UUID),
1621 true, false,
1623 )
1624 .await;
1625
1626 assert!(result.success);
1627 assert!(result.files_patched.is_empty());
1628 let on_disk = tokio::fs::read(pkg_dir.join("index.js")).await.unwrap();
1629 assert_eq!(on_disk, original);
1630 }
1631
1632 #[test]
1633 fn test_applied_via_as_tag() {
1634 assert_eq!(AppliedVia::Package.as_tag(), "package");
1635 assert_eq!(AppliedVia::Diff.as_tag(), "diff");
1636 assert_eq!(AppliedVia::Blob.as_tag(), "blob");
1637 }
1638
1639 #[test]
1640 fn test_patch_sources_blobs_only_disables_other_strategies() {
1641 let dir = tempfile::tempdir().unwrap();
1642 let sources = PatchSources::blobs_only(dir.path());
1643 assert!(sources.packages_path.is_none());
1644 assert!(sources.diffs_path.is_none());
1645 }
1646}