1use std::collections::HashMap;
2use std::path::Path;
3#[cfg(unix)]
4use std::path::PathBuf;
5
6use crate::hash::git_sha256::compute_git_sha256_from_bytes;
7use crate::manifest::schema::PatchFileInfo;
8use crate::patch::cow::break_hardlink_if_needed;
9use crate::patch::diff::apply_diff;
10use crate::patch::file_hash::compute_file_git_sha256;
11use crate::patch::package::read_archive_filtered;
12
13#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum VerifyStatus {
16 Ready,
18 AlreadyPatched,
20 HashMismatch,
22 NotFound,
24}
25
26#[derive(Debug, Clone)]
28pub struct VerifyResult {
29 pub file: String,
30 pub status: VerifyStatus,
31 pub message: Option<String>,
32 pub current_hash: Option<String>,
33 pub expected_hash: Option<String>,
34 pub target_hash: Option<String>,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum AppliedVia {
40 Package,
42 Diff,
45 Blob,
47}
48
49impl AppliedVia {
50 pub fn as_tag(&self) -> &'static str {
52 match self {
53 AppliedVia::Package => "package",
54 AppliedVia::Diff => "diff",
55 AppliedVia::Blob => "blob",
56 }
57 }
58}
59
60#[derive(Debug, Clone, Copy)]
66pub struct PatchSources<'a> {
67 pub blobs_path: &'a Path,
68 pub packages_path: Option<&'a Path>,
69 pub diffs_path: Option<&'a Path>,
70}
71
72impl<'a> PatchSources<'a> {
73 pub fn blobs_only(blobs_path: &'a Path) -> Self {
77 Self {
78 blobs_path,
79 packages_path: None,
80 diffs_path: None,
81 }
82 }
83}
84
85#[derive(Debug, Clone)]
87pub struct ApplyResult {
88 pub package_key: String,
89 pub package_path: String,
90 pub success: bool,
91 pub files_verified: Vec<VerifyResult>,
92 pub files_patched: Vec<String>,
93 pub applied_via: HashMap<String, AppliedVia>,
96 pub error: Option<String>,
97 pub sidecar: Option<crate::patch::sidecars::SidecarRecord>,
106}
107
108pub fn normalize_file_path(file_name: &str) -> &str {
112 const PACKAGE_PREFIX: &str = "package/";
113 if let Some(stripped) = file_name.strip_prefix(PACKAGE_PREFIX) {
114 stripped
115 } else {
116 file_name
117 }
118}
119
120pub async fn verify_file_patch(
122 pkg_path: &Path,
123 file_name: &str,
124 file_info: &PatchFileInfo,
125) -> VerifyResult {
126 let normalized = normalize_file_path(file_name);
127 let filepath = pkg_path.join(normalized);
128
129 let is_new_file = file_info.before_hash.is_empty();
130
131 if tokio::fs::metadata(&filepath).await.is_err() {
133 if is_new_file {
135 return VerifyResult {
136 file: file_name.to_string(),
137 status: VerifyStatus::Ready,
138 message: None,
139 current_hash: None,
140 expected_hash: None,
141 target_hash: Some(file_info.after_hash.clone()),
142 };
143 }
144 return VerifyResult {
145 file: file_name.to_string(),
146 status: VerifyStatus::NotFound,
147 message: Some("File not found".to_string()),
148 current_hash: None,
149 expected_hash: None,
150 target_hash: None,
151 };
152 }
153
154 let current_hash = match compute_file_git_sha256(&filepath).await {
156 Ok(h) => h,
157 Err(e) => {
158 return VerifyResult {
159 file: file_name.to_string(),
160 status: VerifyStatus::NotFound,
161 message: Some(format!("Failed to hash file: {}", e)),
162 current_hash: None,
163 expected_hash: None,
164 target_hash: None,
165 };
166 }
167 };
168
169 if current_hash == file_info.after_hash {
171 return VerifyResult {
172 file: file_name.to_string(),
173 status: VerifyStatus::AlreadyPatched,
174 message: None,
175 current_hash: Some(current_hash),
176 expected_hash: None,
177 target_hash: None,
178 };
179 }
180
181 if is_new_file {
184 return VerifyResult {
185 file: file_name.to_string(),
186 status: VerifyStatus::Ready,
187 message: None,
188 current_hash: Some(current_hash),
189 expected_hash: None,
190 target_hash: Some(file_info.after_hash.clone()),
191 };
192 }
193
194 if current_hash != file_info.before_hash {
196 return VerifyResult {
197 file: file_name.to_string(),
198 status: VerifyStatus::HashMismatch,
199 message: Some("File hash does not match expected value".to_string()),
200 current_hash: Some(current_hash),
201 expected_hash: Some(file_info.before_hash.clone()),
202 target_hash: Some(file_info.after_hash.clone()),
203 };
204 }
205
206 VerifyResult {
207 file: file_name.to_string(),
208 status: VerifyStatus::Ready,
209 message: None,
210 current_hash: Some(current_hash),
211 expected_hash: None,
212 target_hash: Some(file_info.after_hash.clone()),
213 }
214}
215
216pub async fn select_installed_variants(
250 pkg_path: &Path,
251 variants: &[(&str, &HashMap<String, PatchFileInfo>)],
252) -> Vec<usize> {
253 let mut matched = Vec::new();
254 for (idx, (_key, files)) in variants.iter().enumerate() {
255 let Some((file_name, file_info)) = files.iter().next() else {
257 matched.push(idx);
258 continue;
259 };
260 let verify = verify_file_patch(pkg_path, file_name, file_info).await;
261 if matches!(
262 verify.status,
263 VerifyStatus::Ready | VerifyStatus::AlreadyPatched
264 ) {
265 matched.push(idx);
266 }
267 }
268 matched
269}
270
271pub async fn apply_file_patch(
294 pkg_path: &Path,
295 file_name: &str,
296 patched_content: &[u8],
297 expected_hash: &str,
298) -> Result<(), std::io::Error> {
299 let normalized = normalize_file_path(file_name);
300 let filepath = pkg_path.join(normalized);
301
302 let content_hash = compute_git_sha256_from_bytes(patched_content);
307 if content_hash != expected_hash {
308 return Err(std::io::Error::new(
309 std::io::ErrorKind::InvalidData,
310 format!(
311 "Hash verification failed before patch. Expected: {}, Got: {}",
312 expected_hash, content_hash
313 ),
314 ));
315 }
316
317 let existing_meta = tokio::fs::metadata(&filepath).await.ok();
323
324 if let Some(parent) = filepath.parent() {
326 tokio::fs::create_dir_all(parent).await?;
327 }
328
329 let dir_guard = DirWriteGuard::acquire(filepath.parent()).await;
338
339 let write_result = async {
359 break_hardlink_if_needed(&filepath).await?;
360 write_atomic(&filepath, patched_content).await
361 }
362 .await;
363 dir_guard.restore().await;
364 write_result?;
365
366 restore_file_permissions(&filepath, existing_meta.as_ref()).await?;
371
372 Ok(())
373}
374
375pub(crate) struct DirWriteGuard {
388 #[cfg(unix)]
389 relock: Option<(PathBuf, u32)>,
390}
391
392impl DirWriteGuard {
393 pub(crate) async fn acquire(dir: Option<&Path>) -> Self {
394 #[cfg(unix)]
395 {
396 use std::os::unix::fs::PermissionsExt;
397 if let Some(dir) = dir {
398 if let Ok(meta) = tokio::fs::metadata(dir).await {
399 let mode = meta.permissions().mode();
400 if mode & 0o200 == 0 {
403 let mut perms = meta.permissions();
404 perms.set_mode(mode | 0o200);
405 if tokio::fs::set_permissions(dir, perms).await.is_ok() {
406 return Self {
407 relock: Some((dir.to_path_buf(), mode)),
408 };
409 }
410 }
411 }
412 }
413 Self { relock: None }
414 }
415 #[cfg(not(unix))]
416 {
417 let _ = dir;
418 Self {}
419 }
420 }
421
422 pub(crate) async fn restore(self) {
423 #[cfg(unix)]
424 {
425 use std::os::unix::fs::PermissionsExt;
426 if let Some((dir, mode)) = self.relock {
427 let _ =
428 tokio::fs::set_permissions(&dir, std::fs::Permissions::from_mode(mode)).await;
429 }
430 }
431 }
432}
433
434async fn write_atomic(target: &Path, content: &[u8]) -> std::io::Result<()> {
446 let parent = target.parent().unwrap_or_else(|| Path::new("."));
447 let stem = target
448 .file_name()
449 .map(|n| n.to_string_lossy().into_owned())
450 .unwrap_or_else(|| "anon".to_string());
451 let stage = parent.join(format!(".socket-stage-{}-{}", stem, uuid::Uuid::new_v4()));
452
453 let mut file = tokio::fs::OpenOptions::new()
454 .write(true)
455 .create_new(true)
456 .open(&stage)
457 .await?;
458
459 use tokio::io::AsyncWriteExt;
460 if let Err(e) = file.write_all(content).await {
461 let _ = tokio::fs::remove_file(&stage).await;
462 return Err(e);
463 }
464 if let Err(e) = file.sync_all().await {
465 let _ = tokio::fs::remove_file(&stage).await;
466 return Err(e);
467 }
468 drop(file);
469
470 if let Err(e) = tokio::fs::rename(&stage, target).await {
471 let _ = tokio::fs::remove_file(&stage).await;
472 return Err(e);
473 }
474
475 #[cfg(unix)]
482 {
483 if let Ok(dir) = tokio::fs::File::open(parent).await {
484 let _ = dir.sync_all().await;
485 }
486 }
487
488 Ok(())
489}
490
491async fn restore_file_permissions(
501 filepath: &Path,
502 pre_patch: Option<&std::fs::Metadata>,
503) -> Result<(), std::io::Error> {
504 #[cfg(unix)]
505 {
506 use std::os::unix::fs::{MetadataExt, PermissionsExt};
507
508 match pre_patch {
509 Some(meta) => {
510 let uid = meta.uid();
516 let gid = meta.gid();
517 chown_blocking(filepath.to_path_buf(), Some(uid), Some(gid)).await?;
518 let restored = std::fs::Permissions::from_mode(meta.mode());
519 tokio::fs::set_permissions(filepath, restored).await?;
520 }
521 None => {
522 if let Some(parent) = filepath.parent() {
524 if let Ok(parent_meta) = tokio::fs::metadata(parent).await {
525 let uid = parent_meta.uid();
526 let gid = parent_meta.gid();
527 chown_blocking(filepath.to_path_buf(), Some(uid), Some(gid)).await?;
528 }
529 }
530 let readonly = std::fs::Permissions::from_mode(0o444);
532 tokio::fs::set_permissions(filepath, readonly).await?;
533 }
534 }
535 }
536
537 #[cfg(windows)]
538 {
539 match pre_patch {
540 Some(meta) => {
541 let perms = meta.permissions();
544 tokio::fs::set_permissions(filepath, perms).await?;
545 }
546 None => {
547 if let Ok(meta) = tokio::fs::metadata(filepath).await {
549 let mut perms = meta.permissions();
550 perms.set_readonly(true);
551 tokio::fs::set_permissions(filepath, perms).await?;
552 }
553 }
554 }
555 }
556
557 let _ = filepath;
558 let _ = pre_patch;
559 Ok(())
560}
561
562#[cfg(unix)]
567async fn chown_blocking(
568 path: std::path::PathBuf,
569 uid: Option<u32>,
570 gid: Option<u32>,
571) -> Result<(), std::io::Error> {
572 tokio::task::spawn_blocking(move || std::os::unix::fs::chown(&path, uid, gid))
573 .await
574 .map_err(|e| std::io::Error::other(e.to_string()))?
575}
576
577pub async fn apply_package_patch(
590 package_key: &str,
591 pkg_path: &Path,
592 files: &HashMap<String, PatchFileInfo>,
593 sources: &PatchSources<'_>,
594 uuid: Option<&str>,
595 dry_run: bool,
596 force: bool,
597) -> ApplyResult {
598 let mut result = ApplyResult {
599 package_key: package_key.to_string(),
600 package_path: pkg_path.display().to_string(),
601 success: false,
602 files_verified: Vec::new(),
603 files_patched: Vec::new(),
604 applied_via: HashMap::new(),
605 error: None,
606 sidecar: None,
607 };
608
609 for (file_name, file_info) in files {
611 let mut verify_result = verify_file_patch(pkg_path, file_name, file_info).await;
612
613 if verify_result.status != VerifyStatus::Ready
614 && verify_result.status != VerifyStatus::AlreadyPatched
615 {
616 if force {
617 match verify_result.status {
618 VerifyStatus::HashMismatch => {
619 verify_result.status = VerifyStatus::Ready;
621 }
622 VerifyStatus::NotFound => {
623 result.files_verified.push(verify_result);
625 continue;
626 }
627 _ => {}
628 }
629 } else {
630 let msg = verify_result
631 .message
632 .clone()
633 .unwrap_or_else(|| format!("{:?}", verify_result.status));
634 result.error = Some(format!(
635 "Cannot apply patch: {} - {}",
636 verify_result.file, msg
637 ));
638 result.files_verified.push(verify_result);
639 return result;
640 }
641 }
642
643 result.files_verified.push(verify_result);
644 }
645
646 let all_already_patched = result
648 .files_verified
649 .iter()
650 .all(|v| v.status == VerifyStatus::AlreadyPatched);
651
652 if all_already_patched {
653 result.success = true;
654 return result;
655 }
656
657 let all_done_or_skipped = result
659 .files_verified
660 .iter()
661 .all(|v| v.status == VerifyStatus::AlreadyPatched || v.status == VerifyStatus::NotFound);
662
663 if all_done_or_skipped {
664 let not_found_count = result
666 .files_verified
667 .iter()
668 .filter(|v| v.status == VerifyStatus::NotFound)
669 .count();
670 result.success = true;
671 result.error = Some(format!(
672 "All patch files were skipped: {} not found on disk (--force)",
673 not_found_count
674 ));
675 return result;
676 }
677
678 if dry_run {
680 result.success = true;
681 return result;
682 }
683
684 let package_entries = match (uuid, sources.packages_path) {
687 (Some(uuid), Some(dir)) => load_archive_if_present(dir, uuid, files).await,
688 _ => None,
689 };
690 let diff_entries = match (uuid, sources.diffs_path) {
691 (Some(uuid), Some(dir)) => load_archive_if_present(dir, uuid, files).await,
692 _ => None,
693 };
694
695 for (file_name, file_info) in files {
698 let verify_result = result.files_verified.iter().find(|v| v.file == *file_name);
699 if let Some(vr) = verify_result {
700 if vr.status == VerifyStatus::AlreadyPatched || vr.status == VerifyStatus::NotFound {
701 continue;
702 }
703 }
704
705 let normalized = normalize_file_path(file_name).to_string();
706
707 if try_apply_from_archive(
709 package_entries.as_ref(),
710 &normalized,
711 pkg_path,
712 file_name,
713 file_info,
714 )
715 .await
716 {
717 result.files_patched.push(file_name.clone());
718 result
719 .applied_via
720 .insert(file_name.clone(), AppliedVia::Package);
721 continue;
722 }
723
724 let current_hash_for_diff = verify_result.and_then(|v| v.current_hash.as_deref());
733 if try_apply_from_diff(
734 diff_entries.as_ref(),
735 &normalized,
736 pkg_path,
737 file_name,
738 file_info,
739 current_hash_for_diff,
740 )
741 .await
742 {
743 result.files_patched.push(file_name.clone());
744 result
745 .applied_via
746 .insert(file_name.clone(), AppliedVia::Diff);
747 continue;
748 }
749
750 let blob_path = sources.blobs_path.join(&file_info.after_hash);
752 let patched_content = match tokio::fs::read(&blob_path).await {
753 Ok(content) => content,
754 Err(e) => {
755 result.error = Some(format!(
756 "Failed to read blob {}: {}",
757 file_info.after_hash, e
758 ));
759 return result;
760 }
761 };
762
763 if let Err(e) =
764 apply_file_patch(pkg_path, file_name, &patched_content, &file_info.after_hash).await
765 {
766 result.error = Some(e.to_string());
767 return result;
768 }
769
770 result.files_patched.push(file_name.clone());
771 result
772 .applied_via
773 .insert(file_name.clone(), AppliedVia::Blob);
774 }
775
776 if !result.files_patched.is_empty() {
784 use crate::patch::sidecars::{
785 dispatch_fixup, SidecarAdvisory, SidecarAdvisoryCode, SidecarRecord, SidecarSeverity,
786 };
787 match dispatch_fixup(package_key, pkg_path, &result.files_patched, files).await {
788 Ok(Some(record)) => result.sidecar = Some(record),
789 Ok(None) => {}
790 Err(e) => {
791 let ecosystem = crate::crawlers::Ecosystem::from_purl(package_key)
792 .map(|eco| eco.cli_name().to_string())
793 .unwrap_or_else(|| "unknown".to_string());
794 result.sidecar = Some(SidecarRecord {
795 purl: package_key.to_string(),
796 ecosystem,
797 files: Vec::new(),
798 advisory: Some(SidecarAdvisory {
799 code: SidecarAdvisoryCode::SidecarFixupFailed,
800 severity: SidecarSeverity::Error,
801 message: format!("sidecar fixup failed (patch still applied): {}", e),
802 }),
803 });
804 }
805 }
806 }
807
808 result.success = true;
809 result
810}
811
812async fn try_apply_from_archive(
815 package_entries: Option<&HashMap<String, Vec<u8>>>,
816 normalized_path: &str,
817 pkg_path: &Path,
818 file_name: &str,
819 file_info: &PatchFileInfo,
820) -> bool {
821 let entries = match package_entries {
822 Some(e) => e,
823 None => return false,
824 };
825 let bytes = match entries.get(normalized_path) {
826 Some(b) => b,
827 None => return false,
828 };
829 if compute_git_sha256_from_bytes(bytes) != file_info.after_hash {
830 return false;
831 }
832 apply_file_patch(pkg_path, file_name, bytes, &file_info.after_hash)
833 .await
834 .is_ok()
835}
836
837async fn try_apply_from_diff(
849 diff_entries: Option<&HashMap<String, Vec<u8>>>,
850 normalized_path: &str,
851 pkg_path: &Path,
852 file_name: &str,
853 file_info: &PatchFileInfo,
854 current_hash: Option<&str>,
855) -> bool {
856 let entries = match diff_entries {
857 Some(e) => e,
858 None => return false,
859 };
860 let delta = match entries.get(normalized_path) {
861 Some(d) => d,
862 None => return false,
863 };
864 if file_info.before_hash.is_empty() {
865 return false;
867 }
868 match current_hash {
874 Some(h) if h == file_info.before_hash => {}
875 _ => return false,
876 }
877
878 let on_disk_path = pkg_path.join(normalized_path);
879 let before_bytes = match tokio::fs::read(&on_disk_path).await {
880 Ok(b) => b,
881 Err(_) => return false,
882 };
883 let patched = match apply_diff(&before_bytes, delta) {
884 Ok(p) => p,
885 Err(_) => return false,
886 };
887 if compute_git_sha256_from_bytes(&patched) != file_info.after_hash {
888 return false;
889 }
890 apply_file_patch(pkg_path, file_name, &patched, &file_info.after_hash)
891 .await
892 .is_ok()
893}
894
895async fn load_archive_if_present(
900 dir: &Path,
901 uuid: &str,
902 files: &HashMap<String, PatchFileInfo>,
903) -> Option<HashMap<String, Vec<u8>>> {
904 let archive_path = dir.join(format!("{uuid}.tar.gz"));
905 if tokio::fs::metadata(&archive_path).await.is_err() {
906 return None;
907 }
908 let archive_path_owned = archive_path.clone();
912 let files_owned = files.clone();
913 tokio::task::spawn_blocking(move || read_archive_filtered(&archive_path_owned, &files_owned))
914 .await
915 .ok()
916 .and_then(|r| r.ok())
917}
918
919#[cfg(test)]
920mod tests {
921 use super::*;
922 use crate::hash::git_sha256::compute_git_sha256_from_bytes;
923
924 #[test]
925 fn test_normalize_file_path_with_prefix() {
926 assert_eq!(
927 normalize_file_path("package/lib/server.js"),
928 "lib/server.js"
929 );
930 }
931
932 #[test]
933 fn test_normalize_file_path_without_prefix() {
934 assert_eq!(normalize_file_path("lib/server.js"), "lib/server.js");
935 }
936
937 #[test]
938 fn test_normalize_file_path_just_prefix() {
939 assert_eq!(normalize_file_path("package/"), "");
940 }
941
942 #[test]
943 fn test_normalize_file_path_package_not_prefix() {
944 assert_eq!(
946 normalize_file_path("packagefoo/bar.js"),
947 "packagefoo/bar.js"
948 );
949 }
950
951 #[tokio::test]
952 async fn test_verify_file_patch_not_found() {
953 let dir = tempfile::tempdir().unwrap();
954 let file_info = PatchFileInfo {
955 before_hash: "aaa".to_string(),
956 after_hash: "bbb".to_string(),
957 };
958
959 let result = verify_file_patch(dir.path(), "nonexistent.js", &file_info).await;
960 assert_eq!(result.status, VerifyStatus::NotFound);
961 }
962
963 #[tokio::test]
964 async fn test_verify_file_patch_ready() {
965 let dir = tempfile::tempdir().unwrap();
966 let content = b"original content";
967 let before_hash = compute_git_sha256_from_bytes(content);
968 let after_hash = "bbbbbbbb".to_string();
969
970 tokio::fs::write(dir.path().join("index.js"), content)
971 .await
972 .unwrap();
973
974 let file_info = PatchFileInfo {
975 before_hash: before_hash.clone(),
976 after_hash,
977 };
978
979 let result = verify_file_patch(dir.path(), "index.js", &file_info).await;
980 assert_eq!(result.status, VerifyStatus::Ready);
981 assert_eq!(result.current_hash.unwrap(), before_hash);
982 }
983
984 #[tokio::test]
985 async fn test_verify_file_patch_already_patched() {
986 let dir = tempfile::tempdir().unwrap();
987 let content = b"patched content";
988 let after_hash = compute_git_sha256_from_bytes(content);
989
990 tokio::fs::write(dir.path().join("index.js"), content)
991 .await
992 .unwrap();
993
994 let file_info = PatchFileInfo {
995 before_hash: "aaaa".to_string(),
996 after_hash: after_hash.clone(),
997 };
998
999 let result = verify_file_patch(dir.path(), "index.js", &file_info).await;
1000 assert_eq!(result.status, VerifyStatus::AlreadyPatched);
1001 }
1002
1003 #[tokio::test]
1004 async fn test_verify_file_patch_hash_mismatch() {
1005 let dir = tempfile::tempdir().unwrap();
1006 tokio::fs::write(dir.path().join("index.js"), b"something else")
1007 .await
1008 .unwrap();
1009
1010 let file_info = PatchFileInfo {
1011 before_hash: "aaaa".to_string(),
1012 after_hash: "bbbb".to_string(),
1013 };
1014
1015 let result = verify_file_patch(dir.path(), "index.js", &file_info).await;
1016 assert_eq!(result.status, VerifyStatus::HashMismatch);
1017 }
1018
1019 #[tokio::test]
1020 async fn test_verify_with_package_prefix() {
1021 let dir = tempfile::tempdir().unwrap();
1022 let content = b"original content";
1023 let before_hash = compute_git_sha256_from_bytes(content);
1024
1025 tokio::fs::create_dir_all(dir.path().join("lib"))
1027 .await
1028 .unwrap();
1029 tokio::fs::write(dir.path().join("lib/server.js"), content)
1030 .await
1031 .unwrap();
1032
1033 let file_info = PatchFileInfo {
1034 before_hash: before_hash.clone(),
1035 after_hash: "bbbb".to_string(),
1036 };
1037
1038 let result = verify_file_patch(dir.path(), "package/lib/server.js", &file_info).await;
1039 assert_eq!(result.status, VerifyStatus::Ready);
1040 }
1041
1042 #[tokio::test]
1043 async fn test_apply_file_patch_success() {
1044 let dir = tempfile::tempdir().unwrap();
1045 let original = b"original";
1046 let patched = b"patched content";
1047 let patched_hash = compute_git_sha256_from_bytes(patched);
1048
1049 tokio::fs::write(dir.path().join("index.js"), original)
1050 .await
1051 .unwrap();
1052
1053 apply_file_patch(dir.path(), "index.js", patched, &patched_hash)
1054 .await
1055 .unwrap();
1056
1057 let written = tokio::fs::read(dir.path().join("index.js")).await.unwrap();
1058 assert_eq!(written, patched);
1059 }
1060
1061 #[tokio::test]
1062 async fn test_apply_file_patch_hash_mismatch() {
1063 let dir = tempfile::tempdir().unwrap();
1064 tokio::fs::write(dir.path().join("index.js"), b"original")
1065 .await
1066 .unwrap();
1067
1068 let result =
1069 apply_file_patch(dir.path(), "index.js", b"patched content", "wrong_hash").await;
1070 assert!(result.is_err());
1071 let err = result.unwrap_err();
1072 assert!(err.to_string().contains("Hash verification failed"));
1073 }
1074
1075 #[tokio::test]
1080 async fn test_apply_file_patch_hash_mismatch_leaves_original_intact() {
1081 let dir = tempfile::tempdir().unwrap();
1082 let path = dir.path().join("index.js");
1083 tokio::fs::write(&path, b"original").await.unwrap();
1084
1085 let result = apply_file_patch(dir.path(), "index.js", b"patched", "deadbeef").await;
1086 assert!(result.is_err());
1087
1088 assert_eq!(tokio::fs::read(&path).await.unwrap(), b"original");
1090
1091 let mut entries = tokio::fs::read_dir(dir.path()).await.unwrap();
1093 while let Some(entry) = entries.next_entry().await.unwrap() {
1094 let name = entry.file_name().to_string_lossy().to_string();
1095 assert!(
1096 !name.starts_with(".socket-stage-"),
1097 "stage file leaked into parent dir: {name}"
1098 );
1099 }
1100 }
1101
1102 #[cfg(unix)]
1107 #[tokio::test]
1108 async fn test_apply_file_patch_does_not_propagate_to_hardlinked_sibling() {
1109 let dir = tempfile::tempdir().unwrap();
1110 let project = dir.path().join("project-b").join("foo.js");
1111 let store = dir.path().join("store-a.js");
1112 tokio::fs::create_dir_all(project.parent().unwrap())
1113 .await
1114 .unwrap();
1115
1116 tokio::fs::write(&store, b"original").await.unwrap();
1120 tokio::fs::hard_link(&store, &project).await.unwrap();
1121
1122 let patched = b"patched";
1123 let patched_hash = compute_git_sha256_from_bytes(patched);
1124 apply_file_patch(project.parent().unwrap(), "foo.js", patched, &patched_hash)
1125 .await
1126 .unwrap();
1127
1128 assert_eq!(tokio::fs::read(&project).await.unwrap(), b"patched");
1130 assert_eq!(tokio::fs::read(&store).await.unwrap(), b"original");
1132 }
1133
1134 #[cfg(unix)]
1138 #[tokio::test]
1139 async fn test_apply_file_patch_preserves_readonly_mode() {
1140 use std::os::unix::fs::PermissionsExt;
1141
1142 let dir = tempfile::tempdir().unwrap();
1143 let path = dir.path().join("index.js");
1144 let original = b"original";
1145 let patched = b"patched content";
1146 let patched_hash = compute_git_sha256_from_bytes(patched);
1147
1148 tokio::fs::write(&path, original).await.unwrap();
1149 tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o444))
1151 .await
1152 .unwrap();
1153
1154 apply_file_patch(dir.path(), "index.js", patched, &patched_hash)
1155 .await
1156 .unwrap();
1157
1158 let written = tokio::fs::read(&path).await.unwrap();
1160 assert_eq!(written, patched);
1161 let mode_after = tokio::fs::metadata(&path)
1163 .await
1164 .unwrap()
1165 .permissions()
1166 .mode()
1167 & 0o7777;
1168 assert_eq!(
1169 mode_after, 0o444,
1170 "mode must be restored to the pre-patch value after the write"
1171 );
1172 }
1173
1174 #[cfg(unix)]
1177 #[tokio::test]
1178 async fn test_apply_file_patch_preserves_executable_mode() {
1179 use std::os::unix::fs::PermissionsExt;
1180
1181 let dir = tempfile::tempdir().unwrap();
1182 let path = dir.path().join("bin.sh");
1183 let original = b"#!/bin/sh\necho old\n";
1184 let patched = b"#!/bin/sh\necho new\n";
1185 let patched_hash = compute_git_sha256_from_bytes(patched);
1186
1187 tokio::fs::write(&path, original).await.unwrap();
1188 tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
1189 .await
1190 .unwrap();
1191
1192 apply_file_patch(dir.path(), "bin.sh", patched, &patched_hash)
1193 .await
1194 .unwrap();
1195
1196 let mode_after = tokio::fs::metadata(&path)
1197 .await
1198 .unwrap()
1199 .permissions()
1200 .mode()
1201 & 0o7777;
1202 assert_eq!(mode_after, 0o755);
1203 }
1204
1205 #[cfg(unix)]
1212 #[tokio::test]
1213 async fn test_apply_file_patch_new_file_is_readonly_and_inherits_dir_owner() {
1214 use std::os::unix::fs::{MetadataExt, PermissionsExt};
1215
1216 let dir = tempfile::tempdir().unwrap();
1217 let nested = "new-dir/new.js";
1218 let patched = b"brand new file content\n";
1219 let patched_hash = compute_git_sha256_from_bytes(patched);
1220
1221 apply_file_patch(dir.path(), nested, patched, &patched_hash)
1223 .await
1224 .unwrap();
1225
1226 let path = dir.path().join(nested);
1227 let mode = tokio::fs::metadata(&path)
1229 .await
1230 .unwrap()
1231 .permissions()
1232 .mode()
1233 & 0o7777;
1234 assert_eq!(mode, 0o444, "new files default to read-only");
1235
1236 let parent_meta = tokio::fs::metadata(path.parent().unwrap()).await.unwrap();
1238 let file_meta = tokio::fs::metadata(&path).await.unwrap();
1239 assert_eq!(file_meta.uid(), parent_meta.uid());
1240 assert_eq!(file_meta.gid(), parent_meta.gid());
1241 }
1242
1243 #[cfg(unix)]
1247 #[tokio::test]
1248 async fn test_apply_file_patch_preserves_uid_gid() {
1249 use std::os::unix::fs::MetadataExt;
1250
1251 let dir = tempfile::tempdir().unwrap();
1252 let path = dir.path().join("index.js");
1253 let original = b"orig";
1254 let patched = b"new";
1255 let patched_hash = compute_git_sha256_from_bytes(patched);
1256
1257 tokio::fs::write(&path, original).await.unwrap();
1258 let pre = tokio::fs::metadata(&path).await.unwrap();
1259
1260 apply_file_patch(dir.path(), "index.js", patched, &patched_hash)
1261 .await
1262 .unwrap();
1263
1264 let post = tokio::fs::metadata(&path).await.unwrap();
1265 assert_eq!(pre.uid(), post.uid());
1266 assert_eq!(pre.gid(), post.gid());
1267 }
1268
1269 #[cfg(unix)]
1276 #[tokio::test]
1277 async fn test_apply_file_patch_in_readonly_dir() {
1278 use std::os::unix::fs::PermissionsExt;
1279
1280 let dir = tempfile::tempdir().unwrap();
1281 let path = dir.path().join("index.js");
1282 let original = b"original";
1283 let patched = b"patched content";
1284 let patched_hash = compute_git_sha256_from_bytes(patched);
1285
1286 tokio::fs::write(&path, original).await.unwrap();
1287 tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o444))
1289 .await
1290 .unwrap();
1291 tokio::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o555))
1292 .await
1293 .unwrap();
1294
1295 apply_file_patch(dir.path(), "index.js", patched, &patched_hash)
1296 .await
1297 .expect("apply must succeed even inside a read-only directory");
1298
1299 assert_eq!(tokio::fs::read(&path).await.unwrap(), patched);
1301 assert_eq!(
1303 tokio::fs::metadata(&path)
1304 .await
1305 .unwrap()
1306 .permissions()
1307 .mode()
1308 & 0o7777,
1309 0o444
1310 );
1311 assert_eq!(
1313 tokio::fs::metadata(dir.path())
1314 .await
1315 .unwrap()
1316 .permissions()
1317 .mode()
1318 & 0o7777,
1319 0o555,
1320 "directory mode must be restored after the write"
1321 );
1322 let mut entries = tokio::fs::read_dir(dir.path()).await.unwrap();
1324 while let Some(entry) = entries.next_entry().await.unwrap() {
1325 let name = entry.file_name().to_string_lossy().to_string();
1326 assert!(!name.starts_with(".socket-stage-"), "stage leaked: {name}");
1327 }
1328
1329 tokio::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755))
1331 .await
1332 .unwrap();
1333 }
1334
1335 #[cfg(unix)]
1339 #[tokio::test]
1340 async fn test_apply_file_patch_new_file_in_readonly_dir() {
1341 use std::os::unix::fs::PermissionsExt;
1342
1343 let dir = tempfile::tempdir().unwrap();
1344 let patched = b"brand new\n";
1345 let patched_hash = compute_git_sha256_from_bytes(patched);
1346
1347 tokio::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o555))
1348 .await
1349 .unwrap();
1350
1351 apply_file_patch(dir.path(), "new.js", patched, &patched_hash)
1352 .await
1353 .expect("new-file apply must succeed inside a read-only directory");
1354
1355 let path = dir.path().join("new.js");
1356 assert_eq!(tokio::fs::read(&path).await.unwrap(), patched);
1357 assert_eq!(
1358 tokio::fs::metadata(&path)
1359 .await
1360 .unwrap()
1361 .permissions()
1362 .mode()
1363 & 0o7777,
1364 0o444
1365 );
1366 assert_eq!(
1368 tokio::fs::metadata(dir.path())
1369 .await
1370 .unwrap()
1371 .permissions()
1372 .mode()
1373 & 0o7777,
1374 0o555
1375 );
1376
1377 tokio::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755))
1378 .await
1379 .unwrap();
1380 }
1381
1382 #[cfg(unix)]
1387 #[tokio::test]
1388 async fn test_apply_file_patch_preserves_setuid_bit() {
1389 use std::os::unix::fs::PermissionsExt;
1390
1391 let dir = tempfile::tempdir().unwrap();
1392 let path = dir.path().join("suid-bin");
1393 let patched = b"new payload";
1394 let patched_hash = compute_git_sha256_from_bytes(patched);
1395
1396 tokio::fs::write(&path, b"old payload").await.unwrap();
1397 tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o4755))
1399 .await
1400 .unwrap();
1401 let pre = tokio::fs::metadata(&path)
1405 .await
1406 .unwrap()
1407 .permissions()
1408 .mode()
1409 & 0o7777;
1410 if pre != 0o4755 {
1411 return;
1412 }
1413
1414 apply_file_patch(dir.path(), "suid-bin", patched, &patched_hash)
1415 .await
1416 .unwrap();
1417
1418 let mode_after = tokio::fs::metadata(&path)
1419 .await
1420 .unwrap()
1421 .permissions()
1422 .mode()
1423 & 0o7777;
1424 assert_eq!(
1425 mode_after, 0o4755,
1426 "setuid bit must survive the patch (chown must run before chmod)"
1427 );
1428 }
1429
1430 #[cfg(unix)]
1432 #[tokio::test]
1433 async fn test_apply_package_patch_in_readonly_dir() {
1434 use std::os::unix::fs::PermissionsExt;
1435
1436 let pkg_dir = tempfile::tempdir().unwrap();
1437 let blobs_dir = tempfile::tempdir().unwrap();
1438
1439 let original = b"original content";
1440 let patched = b"patched content";
1441 let before_hash = compute_git_sha256_from_bytes(original);
1442 let after_hash = compute_git_sha256_from_bytes(patched);
1443
1444 tokio::fs::write(pkg_dir.path().join("index.js"), original)
1445 .await
1446 .unwrap();
1447 tokio::fs::write(blobs_dir.path().join(&after_hash), patched)
1448 .await
1449 .unwrap();
1450 tokio::fs::set_permissions(
1452 pkg_dir.path().join("index.js"),
1453 std::fs::Permissions::from_mode(0o444),
1454 )
1455 .await
1456 .unwrap();
1457 tokio::fs::set_permissions(pkg_dir.path(), std::fs::Permissions::from_mode(0o555))
1458 .await
1459 .unwrap();
1460
1461 let mut files = HashMap::new();
1462 files.insert(
1463 "index.js".to_string(),
1464 PatchFileInfo {
1465 before_hash,
1466 after_hash: after_hash.clone(),
1467 },
1468 );
1469
1470 let result = apply_package_patch(
1471 "pkg:golang/example.com/x@1.0.0",
1472 pkg_dir.path(),
1473 &files,
1474 &PatchSources::blobs_only(blobs_dir.path()),
1475 None,
1476 false,
1477 false,
1478 )
1479 .await;
1480
1481 assert!(result.success, "expected success: {:?}", result.error);
1482 assert_eq!(result.files_patched.len(), 1);
1483 let written = tokio::fs::read(pkg_dir.path().join("index.js"))
1484 .await
1485 .unwrap();
1486 assert_eq!(written, patched);
1487
1488 tokio::fs::set_permissions(pkg_dir.path(), std::fs::Permissions::from_mode(0o755))
1489 .await
1490 .unwrap();
1491 }
1492
1493 #[tokio::test]
1494 async fn test_apply_package_patch_success() {
1495 let pkg_dir = tempfile::tempdir().unwrap();
1496 let blobs_dir = tempfile::tempdir().unwrap();
1497
1498 let original = b"original content";
1499 let patched = b"patched content";
1500 let before_hash = compute_git_sha256_from_bytes(original);
1501 let after_hash = compute_git_sha256_from_bytes(patched);
1502
1503 tokio::fs::write(pkg_dir.path().join("index.js"), original)
1505 .await
1506 .unwrap();
1507
1508 tokio::fs::write(blobs_dir.path().join(&after_hash), patched)
1510 .await
1511 .unwrap();
1512
1513 let mut files = HashMap::new();
1514 files.insert(
1515 "index.js".to_string(),
1516 PatchFileInfo {
1517 before_hash,
1518 after_hash: after_hash.clone(),
1519 },
1520 );
1521
1522 let result = apply_package_patch(
1523 "pkg:npm/test@1.0.0",
1524 pkg_dir.path(),
1525 &files,
1526 &PatchSources::blobs_only(blobs_dir.path()),
1527 None,
1528 false,
1529 false,
1530 )
1531 .await;
1532
1533 assert!(result.success);
1534 assert_eq!(result.files_patched.len(), 1);
1535 assert!(result.error.is_none());
1536 }
1537
1538 #[tokio::test]
1539 async fn test_apply_package_patch_dry_run() {
1540 let pkg_dir = tempfile::tempdir().unwrap();
1541 let blobs_dir = tempfile::tempdir().unwrap();
1542
1543 let original = b"original content";
1544 let before_hash = compute_git_sha256_from_bytes(original);
1545
1546 tokio::fs::write(pkg_dir.path().join("index.js"), original)
1547 .await
1548 .unwrap();
1549
1550 let mut files = HashMap::new();
1551 files.insert(
1552 "index.js".to_string(),
1553 PatchFileInfo {
1554 before_hash,
1555 after_hash: "bbbb".to_string(),
1556 },
1557 );
1558
1559 let result = apply_package_patch(
1560 "pkg:npm/test@1.0.0",
1561 pkg_dir.path(),
1562 &files,
1563 &PatchSources::blobs_only(blobs_dir.path()),
1564 None,
1565 true,
1566 false,
1567 )
1568 .await;
1569
1570 assert!(result.success);
1571 assert_eq!(result.files_patched.len(), 0); let content = tokio::fs::read(pkg_dir.path().join("index.js"))
1575 .await
1576 .unwrap();
1577 assert_eq!(content, original);
1578 }
1579
1580 #[tokio::test]
1581 async fn test_apply_package_patch_all_already_patched() {
1582 let pkg_dir = tempfile::tempdir().unwrap();
1583 let blobs_dir = tempfile::tempdir().unwrap();
1584
1585 let patched = b"patched content";
1586 let after_hash = compute_git_sha256_from_bytes(patched);
1587
1588 tokio::fs::write(pkg_dir.path().join("index.js"), patched)
1589 .await
1590 .unwrap();
1591
1592 let mut files = HashMap::new();
1593 files.insert(
1594 "index.js".to_string(),
1595 PatchFileInfo {
1596 before_hash: "aaaa".to_string(),
1597 after_hash,
1598 },
1599 );
1600
1601 let result = apply_package_patch(
1602 "pkg:npm/test@1.0.0",
1603 pkg_dir.path(),
1604 &files,
1605 &PatchSources::blobs_only(blobs_dir.path()),
1606 None,
1607 false,
1608 false,
1609 )
1610 .await;
1611
1612 assert!(result.success);
1613 assert_eq!(result.files_patched.len(), 0);
1614 }
1615
1616 #[tokio::test]
1617 async fn test_apply_package_patch_hash_mismatch_blocks() {
1618 let pkg_dir = tempfile::tempdir().unwrap();
1619 let blobs_dir = tempfile::tempdir().unwrap();
1620
1621 tokio::fs::write(pkg_dir.path().join("index.js"), b"something unexpected")
1622 .await
1623 .unwrap();
1624
1625 let mut files = HashMap::new();
1626 files.insert(
1627 "index.js".to_string(),
1628 PatchFileInfo {
1629 before_hash: "aaaa".to_string(),
1630 after_hash: "bbbb".to_string(),
1631 },
1632 );
1633
1634 let result = apply_package_patch(
1635 "pkg:npm/test@1.0.0",
1636 pkg_dir.path(),
1637 &files,
1638 &PatchSources::blobs_only(blobs_dir.path()),
1639 None,
1640 false,
1641 false,
1642 )
1643 .await;
1644
1645 assert!(!result.success);
1646 assert!(result.error.is_some());
1647 }
1648
1649 #[tokio::test]
1650 async fn test_apply_package_patch_force_hash_mismatch() {
1651 let pkg_dir = tempfile::tempdir().unwrap();
1652 let blobs_dir = tempfile::tempdir().unwrap();
1653
1654 let patched = b"patched content";
1655 let after_hash = compute_git_sha256_from_bytes(patched);
1656
1657 tokio::fs::write(pkg_dir.path().join("index.js"), b"something unexpected")
1659 .await
1660 .unwrap();
1661
1662 tokio::fs::write(blobs_dir.path().join(&after_hash), patched)
1664 .await
1665 .unwrap();
1666
1667 let mut files = HashMap::new();
1668 files.insert(
1669 "index.js".to_string(),
1670 PatchFileInfo {
1671 before_hash: "aaaa".to_string(),
1672 after_hash: after_hash.clone(),
1673 },
1674 );
1675
1676 let result = apply_package_patch(
1678 "pkg:npm/test@1.0.0",
1679 pkg_dir.path(),
1680 &files,
1681 &PatchSources::blobs_only(blobs_dir.path()),
1682 None,
1683 false,
1684 false,
1685 )
1686 .await;
1687 assert!(!result.success);
1688
1689 tokio::fs::write(pkg_dir.path().join("index.js"), b"something unexpected")
1691 .await
1692 .unwrap();
1693
1694 let result = apply_package_patch(
1696 "pkg:npm/test@1.0.0",
1697 pkg_dir.path(),
1698 &files,
1699 &PatchSources::blobs_only(blobs_dir.path()),
1700 None,
1701 false,
1702 true,
1703 )
1704 .await;
1705 assert!(result.success);
1706 assert_eq!(result.files_patched.len(), 1);
1707
1708 let written = tokio::fs::read(pkg_dir.path().join("index.js"))
1709 .await
1710 .unwrap();
1711 assert_eq!(written, patched);
1712 }
1713
1714 #[tokio::test]
1715 async fn test_apply_package_patch_force_not_found_skips() {
1716 let pkg_dir = tempfile::tempdir().unwrap();
1717 let blobs_dir = tempfile::tempdir().unwrap();
1718
1719 let mut files = HashMap::new();
1720 files.insert(
1721 "missing.js".to_string(),
1722 PatchFileInfo {
1723 before_hash: "aaaa".to_string(),
1724 after_hash: "bbbb".to_string(),
1725 },
1726 );
1727
1728 let result = apply_package_patch(
1730 "pkg:npm/test@1.0.0",
1731 pkg_dir.path(),
1732 &files,
1733 &PatchSources::blobs_only(blobs_dir.path()),
1734 None,
1735 false,
1736 false,
1737 )
1738 .await;
1739 assert!(!result.success);
1740
1741 let result = apply_package_patch(
1743 "pkg:npm/test@1.0.0",
1744 pkg_dir.path(),
1745 &files,
1746 &PatchSources::blobs_only(blobs_dir.path()),
1747 None,
1748 false,
1749 true,
1750 )
1751 .await;
1752 assert!(result.success);
1753 assert_eq!(result.files_patched.len(), 0);
1754 }
1755
1756 use flate2::write::GzEncoder;
1764 use flate2::Compression as GzCompression;
1765 use qbsdiff::Bsdiff;
1766
1767 const TEST_UUID: &str = "11111111-1111-4111-8111-111111111111";
1768
1769 fn write_uuid_archive(dir: &Path, uuid: &str, entries: &[(&str, &[u8])]) {
1772 let archive_path = dir.join(format!("{uuid}.tar.gz"));
1773 let file = std::fs::File::create(&archive_path).unwrap();
1774 let gz = GzEncoder::new(file, GzCompression::default());
1775 let mut builder = tar::Builder::new(gz);
1776 for (name, data) in entries {
1777 let mut header = tar::Header::new_gnu();
1778 header.set_size(data.len() as u64);
1779 header.set_mode(0o644);
1780 header.set_cksum();
1781 builder.append_data(&mut header, name, *data).unwrap();
1782 }
1783 builder.into_inner().unwrap().finish().unwrap();
1784 }
1785
1786 fn make_delta(before: &[u8], after: &[u8]) -> Vec<u8> {
1787 let mut delta = Vec::new();
1788 Bsdiff::new(before, after)
1789 .compare(std::io::Cursor::new(&mut delta))
1790 .unwrap();
1791 delta
1792 }
1793
1794 async fn make_fixture() -> (
1798 tempfile::TempDir, std::path::PathBuf, std::path::PathBuf, std::path::PathBuf, std::path::PathBuf, HashMap<String, PatchFileInfo>,
1804 Vec<u8>, Vec<u8>, ) {
1807 let root = tempfile::tempdir().unwrap();
1808 let pkg_dir = root.path().join("pkg");
1809 let blobs_dir = root.path().join("blobs");
1810 let packages_dir = root.path().join("packages");
1811 let diffs_dir = root.path().join("diffs");
1812 tokio::fs::create_dir_all(&pkg_dir).await.unwrap();
1813 tokio::fs::create_dir_all(&blobs_dir).await.unwrap();
1814 tokio::fs::create_dir_all(&packages_dir).await.unwrap();
1815 tokio::fs::create_dir_all(&diffs_dir).await.unwrap();
1816
1817 let original: Vec<u8> = b"the original content of the file".to_vec();
1818 let patched: Vec<u8> = b"the PATCHED content of the file!".to_vec();
1819 let before_hash = compute_git_sha256_from_bytes(&original);
1820 let after_hash = compute_git_sha256_from_bytes(&patched);
1821
1822 tokio::fs::write(pkg_dir.join("index.js"), &original)
1824 .await
1825 .unwrap();
1826
1827 tokio::fs::write(blobs_dir.join(&after_hash), &patched)
1829 .await
1830 .unwrap();
1831
1832 write_uuid_archive(&packages_dir, TEST_UUID, &[("index.js", &patched)]);
1834
1835 let delta = make_delta(&original, &patched);
1837 write_uuid_archive(&diffs_dir, TEST_UUID, &[("index.js", &delta)]);
1838
1839 let mut files = HashMap::new();
1840 files.insert(
1841 "index.js".to_string(),
1842 PatchFileInfo {
1843 before_hash,
1844 after_hash,
1845 },
1846 );
1847
1848 (
1849 root,
1850 pkg_dir,
1851 blobs_dir,
1852 packages_dir,
1853 diffs_dir,
1854 files,
1855 original,
1856 patched,
1857 )
1858 }
1859
1860 #[tokio::test]
1861 async fn test_apply_via_package_when_archive_present() {
1862 let (_root, pkg_dir, blobs_dir, packages_dir, diffs_dir, files, _orig, patched) =
1863 make_fixture().await;
1864
1865 let sources = PatchSources {
1866 blobs_path: &blobs_dir,
1867 packages_path: Some(&packages_dir),
1868 diffs_path: Some(&diffs_dir),
1869 };
1870 let result = apply_package_patch(
1871 "pkg:npm/x@1.0.0",
1872 &pkg_dir,
1873 &files,
1874 &sources,
1875 Some(TEST_UUID),
1876 false,
1877 false,
1878 )
1879 .await;
1880
1881 assert!(result.success, "expected success: {:?}", result.error);
1882 assert_eq!(result.files_patched, vec!["index.js".to_string()]);
1883 assert_eq!(
1884 result.applied_via.get("index.js"),
1885 Some(&AppliedVia::Package)
1886 );
1887 let written = tokio::fs::read(pkg_dir.join("index.js")).await.unwrap();
1888 assert_eq!(written, patched);
1889 }
1890
1891 #[tokio::test]
1892 async fn test_apply_falls_back_to_diff_when_no_package() {
1893 let (_root, pkg_dir, blobs_dir, packages_dir, diffs_dir, files, _orig, patched) =
1894 make_fixture().await;
1895 tokio::fs::remove_file(packages_dir.join(format!("{TEST_UUID}.tar.gz")))
1897 .await
1898 .unwrap();
1899
1900 let sources = PatchSources {
1901 blobs_path: &blobs_dir,
1902 packages_path: Some(&packages_dir),
1903 diffs_path: Some(&diffs_dir),
1904 };
1905 let result = apply_package_patch(
1906 "pkg:npm/x@1.0.0",
1907 &pkg_dir,
1908 &files,
1909 &sources,
1910 Some(TEST_UUID),
1911 false,
1912 false,
1913 )
1914 .await;
1915
1916 assert!(result.success, "expected success: {:?}", result.error);
1917 assert_eq!(result.applied_via.get("index.js"), Some(&AppliedVia::Diff));
1918 let written = tokio::fs::read(pkg_dir.join("index.js")).await.unwrap();
1919 assert_eq!(written, patched);
1920 }
1921
1922 #[tokio::test]
1923 async fn test_apply_falls_back_to_blob_when_no_archives() {
1924 let (_root, pkg_dir, blobs_dir, packages_dir, diffs_dir, files, _orig, patched) =
1925 make_fixture().await;
1926 tokio::fs::remove_file(packages_dir.join(format!("{TEST_UUID}.tar.gz")))
1928 .await
1929 .unwrap();
1930 tokio::fs::remove_file(diffs_dir.join(format!("{TEST_UUID}.tar.gz")))
1931 .await
1932 .unwrap();
1933
1934 let sources = PatchSources {
1935 blobs_path: &blobs_dir,
1936 packages_path: Some(&packages_dir),
1937 diffs_path: Some(&diffs_dir),
1938 };
1939 let result = apply_package_patch(
1940 "pkg:npm/x@1.0.0",
1941 &pkg_dir,
1942 &files,
1943 &sources,
1944 Some(TEST_UUID),
1945 false,
1946 false,
1947 )
1948 .await;
1949
1950 assert!(result.success);
1951 assert_eq!(result.applied_via.get("index.js"), Some(&AppliedVia::Blob));
1952 let written = tokio::fs::read(pkg_dir.join("index.js")).await.unwrap();
1953 assert_eq!(written, patched);
1954 }
1955
1956 #[tokio::test]
1957 async fn test_apply_uuid_none_disables_alt_sources() {
1958 let (_root, pkg_dir, blobs_dir, packages_dir, diffs_dir, files, _orig, _patched) =
1961 make_fixture().await;
1962
1963 let sources = PatchSources {
1964 blobs_path: &blobs_dir,
1965 packages_path: Some(&packages_dir),
1966 diffs_path: Some(&diffs_dir),
1967 };
1968 let result = apply_package_patch(
1969 "pkg:npm/x@1.0.0",
1970 &pkg_dir,
1971 &files,
1972 &sources,
1973 None,
1974 false,
1975 false,
1976 )
1977 .await;
1978
1979 assert!(result.success);
1980 assert_eq!(result.applied_via.get("index.js"), Some(&AppliedVia::Blob));
1981 }
1982
1983 #[tokio::test]
1984 async fn test_apply_via_diff_falls_through_when_before_hash_mismatch() {
1985 let (_root, pkg_dir, blobs_dir, packages_dir, diffs_dir, files, _orig, patched) =
1989 make_fixture().await;
1990 tokio::fs::remove_file(packages_dir.join(format!("{TEST_UUID}.tar.gz")))
1991 .await
1992 .unwrap();
1993 tokio::fs::write(pkg_dir.join("index.js"), b"garbage")
1997 .await
1998 .unwrap();
1999
2000 let sources = PatchSources {
2001 blobs_path: &blobs_dir,
2002 packages_path: Some(&packages_dir),
2003 diffs_path: Some(&diffs_dir),
2004 };
2005 let result = apply_package_patch(
2006 "pkg:npm/x@1.0.0",
2007 &pkg_dir,
2008 &files,
2009 &sources,
2010 Some(TEST_UUID),
2011 false,
2012 true, )
2014 .await;
2015
2016 assert!(result.success);
2017 assert_eq!(result.applied_via.get("index.js"), Some(&AppliedVia::Blob));
2019 let written = tokio::fs::read(pkg_dir.join("index.js")).await.unwrap();
2020 assert_eq!(written, patched);
2021 }
2022
2023 #[tokio::test]
2024 async fn test_apply_via_package_skips_when_hash_mismatches() {
2025 let (_root, pkg_dir, blobs_dir, packages_dir, diffs_dir, files, _orig, patched) =
2029 make_fixture().await;
2030 tokio::fs::remove_file(packages_dir.join(format!("{TEST_UUID}.tar.gz")))
2032 .await
2033 .unwrap();
2034 write_uuid_archive(
2035 &packages_dir,
2036 TEST_UUID,
2037 &[("index.js", b"corrupt package payload")],
2038 );
2039
2040 let sources = PatchSources {
2041 blobs_path: &blobs_dir,
2042 packages_path: Some(&packages_dir),
2043 diffs_path: Some(&diffs_dir),
2044 };
2045 let result = apply_package_patch(
2046 "pkg:npm/x@1.0.0",
2047 &pkg_dir,
2048 &files,
2049 &sources,
2050 Some(TEST_UUID),
2051 false,
2052 false,
2053 )
2054 .await;
2055
2056 assert!(result.success);
2057 assert_eq!(result.applied_via.get("index.js"), Some(&AppliedVia::Diff));
2059 let written = tokio::fs::read(pkg_dir.join("index.js")).await.unwrap();
2060 assert_eq!(written, patched);
2061 }
2062
2063 #[tokio::test]
2064 async fn test_apply_dry_run_does_not_touch_alternative_sources() {
2065 let (_root, pkg_dir, blobs_dir, packages_dir, diffs_dir, files, original, _patched) =
2068 make_fixture().await;
2069
2070 let sources = PatchSources {
2071 blobs_path: &blobs_dir,
2072 packages_path: Some(&packages_dir),
2073 diffs_path: Some(&diffs_dir),
2074 };
2075 let result = apply_package_patch(
2076 "pkg:npm/x@1.0.0",
2077 &pkg_dir,
2078 &files,
2079 &sources,
2080 Some(TEST_UUID),
2081 true, false,
2083 )
2084 .await;
2085
2086 assert!(result.success);
2087 assert!(result.files_patched.is_empty());
2088 let on_disk = tokio::fs::read(pkg_dir.join("index.js")).await.unwrap();
2089 assert_eq!(on_disk, original);
2090 }
2091
2092 #[test]
2093 fn test_applied_via_as_tag() {
2094 assert_eq!(AppliedVia::Package.as_tag(), "package");
2095 assert_eq!(AppliedVia::Diff.as_tag(), "diff");
2096 assert_eq!(AppliedVia::Blob.as_tag(), "blob");
2097 }
2098
2099 #[test]
2100 fn test_patch_sources_blobs_only_disables_other_strategies() {
2101 let dir = tempfile::tempdir().unwrap();
2102 let sources = PatchSources::blobs_only(dir.path());
2103 assert!(sources.packages_path.is_none());
2104 assert!(sources.diffs_path.is_none());
2105 }
2106}