99 "os"
1010 "os/exec"
1111 "path/filepath"
12+ "strings"
1213 "testing"
1314
1415 "github.com/stretchr/testify/assert"
@@ -92,6 +93,12 @@ func renderTemplate(t *testing.T, data copyScriptData) string {
9293// createFilesAndDirs creates files, directories, and symlinks from a map
9394// Values: "file", "dir", "symlink"
9495func createFilesAndDirs (t * testing.T , baseDir string , paths map [string ]string ) {
96+ t .Helper ()
97+ createFilesAndDirsWithContent (t , baseDir , paths , "dummy" )
98+ }
99+
100+ // createFilesAndDirsWithContent is createFilesAndDirs with custom file content
101+ func createFilesAndDirsWithContent (t * testing.T , baseDir string , paths map [string ]string , content string ) {
95102 t .Helper ()
96103 for path , entryType := range paths {
97104 fullPath := filepath .Join (baseDir , path )
@@ -103,7 +110,7 @@ func createFilesAndDirs(t *testing.T, baseDir string, paths map[string]string) {
103110 // Ensure parent dir exists
104111 dir := filepath .Dir (fullPath )
105112 require .NoError (t , os .MkdirAll (dir , 0o755 ))
106- require .NoError (t , os .WriteFile (fullPath , []byte ("dummy" ), 0o644 ))
113+ require .NoError (t , os .WriteFile (fullPath , []byte (content ), 0o644 ))
107114 case "symlink" :
108115 // Create symlink target outside the tree
109116 dir := filepath .Dir (fullPath )
@@ -112,6 +119,13 @@ func createFilesAndDirs(t *testing.T, baseDir string, paths map[string]string) {
112119 require .NoError (t , os .WriteFile (targetFile , []byte ("symlink target" ), 0o644 ))
113120 require .NoError (t , os .Symlink (targetFile , fullPath ))
114121 default :
122+ // "symlink:<target>" creates a symlink pointing at the given path
123+ if target , ok := strings .CutPrefix (entryType , "symlink:" ); ok {
124+ require .NoError (t , os .MkdirAll (filepath .Dir (fullPath ), 0o755 ))
125+ require .NoError (t , os .Symlink (target , fullPath ))
126+
127+ continue
128+ }
115129 t .Fatalf ("Unknown entry type: %s" , entryType )
116130 }
117131 }
@@ -132,6 +146,11 @@ func verifyFilesAndDirs(t *testing.T, baseDir string, paths map[string]string) {
132146 info , err := os .Lstat (fullPath )
133147 require .NoError (t , err , "Symlink %s should exist" , path )
134148 assert .Equal (t , os .ModeSymlink , info .Mode ()& os .ModeSymlink , "%s should be a symlink" , path )
149+ case "regular" :
150+ // A regular file, NOT a symlink pointing at one
151+ info , err := os .Lstat (fullPath )
152+ require .NoError (t , err , "File %s should exist" , path )
153+ assert .True (t , info .Mode ().IsRegular (), "%s should be a regular file, got %s" , path , info .Mode ())
135154 default :
136155 t .Fatalf ("Unknown entry type: %s" , entryType )
137156 }
@@ -148,6 +167,10 @@ type testCase struct {
148167 // Example: {"app/": "dir", "app/main.js": "file", "link": "symlink"}
149168 files map [string ]string
150169
170+ // Setup: paths pre-created in the target before the copy runs,
171+ // to simulate copying over an existing filesystem tree
172+ preexistingTargetPaths map [string ]string
173+
151174 // Input: the path within the extracted files to copy from
152175 // Examples: "." (root), "app/" (subdirectory), "src/main.js" (specific file)
153176 copyFrom string
@@ -168,6 +191,15 @@ type testCase struct {
168191
169192 // Verification: what paths to check in the target with their types
170193 expectedPaths map [string ]string
194+
195+ // Verification: paths that must NOT exist in the target
196+ absentPaths []string
197+
198+ // Verification: file contents to check in the target
199+ expectedContents map [string ]string
200+
201+ // Verification: octal permissions to check in the target
202+ expectedPerms map [string ]string
171203}
172204
173205func TestParseCopyArgs (t * testing.T ) {
@@ -623,6 +655,178 @@ func TestCopyScriptBehavior(t *testing.T) { //nolint:paralleltest // no idea why
623655 "deep.txt" : "file" ,
624656 },
625657 },
658+ {
659+ name : "merge_directory_into_existing_tree" ,
660+ description : "COPY rootfs/etc /etc: merge into a target directory that already has content" ,
661+ files : map [string ]string {
662+ "etc/motd" : "file" ,
663+ "etc/profile.d/test-env.sh" : "file" ,
664+ },
665+ copyFrom : "etc/" ,
666+ copyTo : "etc/" ,
667+ preexistingTargetPaths : map [string ]string {
668+ "etc/profile.d/existing.sh" : "file" ,
669+ },
670+ shouldSucceed : true ,
671+ expectedPaths : map [string ]string {
672+ "etc/motd" : "file" ,
673+ "etc/profile.d/test-env.sh" : "file" ,
674+ "etc/profile.d/existing.sh" : "file" ,
675+ },
676+ },
677+ {
678+ name : "merge_root_directory_into_existing_root" ,
679+ description : "COPY rootfs/ /: every top-level dir already exists in the target root" ,
680+ files : map [string ]string {
681+ "rootfs/etc/profile.d/test-env.sh" : "file" ,
682+ "rootfs/usr/local/bin/tool" : "file" ,
683+ },
684+ copyFrom : "rootfs/" ,
685+ copyTo : "." ,
686+ preexistingTargetPaths : map [string ]string {
687+ "etc/profile.d/00-existing.sh" : "file" ,
688+ "usr/local/bin/existing-tool" : "file" ,
689+ },
690+ shouldSucceed : true ,
691+ expectedPaths : map [string ]string {
692+ "etc/profile.d/test-env.sh" : "file" ,
693+ "etc/profile.d/00-existing.sh" : "file" ,
694+ "usr/local/bin/tool" : "file" ,
695+ "usr/local/bin/existing-tool" : "file" ,
696+ },
697+ },
698+ {
699+ name : "overwrite_existing_file_in_target" ,
700+ description : "Files that already exist in the target are overwritten" ,
701+ files : map [string ]string {
702+ "etc/motd" : "file" ,
703+ },
704+ copyFrom : "etc/" ,
705+ copyTo : "etc/" ,
706+ preexistingTargetPaths : map [string ]string {
707+ "etc/motd" : "file" ,
708+ },
709+ shouldSucceed : true ,
710+ expectedPaths : map [string ]string {
711+ "etc/motd" : "file" ,
712+ },
713+ expectedContents : map [string ]string {
714+ "etc/motd" : "dummy" ,
715+ },
716+ },
717+ {
718+ name : "preserve_target_directory_metadata" ,
719+ description : "Only directory contents are copied; the existing target directory keeps its own permissions" ,
720+ files : map [string ]string {
721+ "etc/motd" : "file" ,
722+ },
723+ copyFrom : "etc/" ,
724+ copyTo : "etc/" ,
725+ // 700 must apply to the copied contents, not the existing target dir
726+ permissions : "700" ,
727+ preexistingTargetPaths : map [string ]string {
728+ "etc/" : "dir" ,
729+ },
730+ shouldSucceed : true ,
731+ expectedPaths : map [string ]string {
732+ "etc/motd" : "file" ,
733+ },
734+ expectedPerms : map [string ]string {
735+ "etc" : "755" ,
736+ },
737+ },
738+ {
739+ name : "directory_with_readonly_permissions" ,
740+ description : "Read-only permissions on the copied tree do not break source cleanup" ,
741+ files : map [string ]string {
742+ "app/config.json" : "file" ,
743+ },
744+ copyFrom : "app/" ,
745+ copyTo : "dest/" ,
746+ permissions : "500" ,
747+ shouldSucceed : true ,
748+ expectedPaths : map [string ]string {
749+ "dest/config.json" : "file" ,
750+ },
751+ },
752+ {
753+ name : "replace_target_symlink_to_file" ,
754+ description : "A source file replaces a destination symlink instead of writing through it" ,
755+ files : map [string ]string {
756+ "etc/resolv.conf" : "file" ,
757+ },
758+ copyFrom : "etc/" ,
759+ copyTo : "etc/" ,
760+ preexistingTargetPaths : map [string ]string {
761+ // resolv.conf points outside the target tree (as on Ubuntu images)
762+ "../real/resolv.conf" : "file" ,
763+ "etc/resolv.conf" : "symlink:../../real/resolv.conf" ,
764+ },
765+ shouldSucceed : true ,
766+ expectedPaths : map [string ]string {
767+ "etc/resolv.conf" : "regular" ,
768+ },
769+ expectedContents : map [string ]string {
770+ "etc/resolv.conf" : "dummy" ,
771+ // The symlink's old target must not have been written through
772+ "../real/resolv.conf" : "preexisting" ,
773+ },
774+ },
775+ {
776+ name : "follow_target_symlink_to_directory" ,
777+ description : "A source directory merges through a destination directory symlink (usrmerge layout)" ,
778+ files : map [string ]string {
779+ "lib/mylib.so" : "file" ,
780+ },
781+ copyFrom : "." ,
782+ copyTo : "." ,
783+ preexistingTargetPaths : map [string ]string {
784+ "usr/lib/existing.so" : "file" ,
785+ "lib" : "symlink:usr/lib" ,
786+ },
787+ shouldSucceed : true ,
788+ expectedPaths : map [string ]string {
789+ "lib" : "symlink" ,
790+ "usr/lib/mylib.so" : "file" ,
791+ "usr/lib/existing.so" : "file" ,
792+ },
793+ },
794+ {
795+ name : "copy_directory_that_is_not_first_alphabetically" ,
796+ description : "The entry named by the source path is copied, not the first entry in its parent" ,
797+ files : map [string ]string {
798+ "project/components/Button.tsx" : "file" ,
799+ "project/utils/helpers.ts" : "file" ,
800+ },
801+ copyFrom : "project/utils/" ,
802+ copyTo : "out/" ,
803+ shouldSucceed : true ,
804+ expectedPaths : map [string ]string {
805+ "out/helpers.ts" : "file" ,
806+ },
807+ absentPaths : []string {
808+ "out/Button.tsx" ,
809+ "out/components" ,
810+ },
811+ },
812+ {
813+ name : "copy_file_that_is_not_first_alphabetically" ,
814+ description : "The file named by the source path is copied, not the first entry in its parent" ,
815+ files : map [string ]string {
816+ "assets/logo.png" : "file" ,
817+ "config.json" : "file" ,
818+ },
819+ copyFrom : "config.json" ,
820+ copyTo : "dest/config.json" ,
821+ shouldSucceed : true ,
822+ expectedPaths : map [string ]string {
823+ "dest/config.json" : "file" ,
824+ },
825+ absentPaths : []string {
826+ "dest/logo.png" ,
827+ "dest/config.json/logo.png" ,
828+ },
829+ },
626830 {
627831 name : "deeply_nested_folder" ,
628832 description : "Deeply nested folder should be copied correctly" ,
@@ -656,6 +860,11 @@ func TestCopyScriptBehavior(t *testing.T) { //nolint:paralleltest // no idea why
656860 verifyFilesAndDirs (t , unpackDir , tc .files )
657861 }
658862
863+ // Pre-populate the target to simulate an existing filesystem tree
864+ if len (tc .preexistingTargetPaths ) > 0 {
865+ createFilesAndDirsWithContent (t , targetBaseDir , tc .preexistingTargetPaths , "preexisting" )
866+ }
867+
659868 // Internal: construct SourcePath (sbxUnpackPath + user's copyFrom path)
660869 // This mimics how copy.go constructs the path: filepath.Join(sbxUnpackPath, sourcePath)
661870 sourcePath := filepath .Join (unpackDir , tc .copyFrom )
@@ -701,6 +910,27 @@ func TestCopyScriptBehavior(t *testing.T) { //nolint:paralleltest // no idea why
701910 verifyFilesAndDirs (t , targetBaseDir , tc .expectedPaths )
702911 }
703912
913+ // Verify paths that must not exist in the target
914+ for _ , path := range tc .absentPaths {
915+ assert .NoFileExists (t , filepath .Join (targetBaseDir , path ), "Path %s should not exist" , path )
916+ assert .NoDirExists (t , filepath .Join (targetBaseDir , path ), "Path %s should not exist" , path )
917+ }
918+
919+ // Verify file contents
920+ for path , expectedContent := range tc .expectedContents {
921+ content , err := os .ReadFile (filepath .Join (targetBaseDir , path ))
922+ require .NoError (t , err , "Failed to read file %s" , path )
923+ assert .Equal (t , expectedContent , string (content ), "File %s content mismatch" , path )
924+ }
925+
926+ // Verify permissions of specific paths
927+ for path , permStr := range tc .expectedPerms {
928+ perms := getFilePermissions (t , filepath .Join (targetBaseDir , path ))
929+ expectedPerms := os .FileMode (0 )
930+ fmt .Sscanf (permStr , "%o" , & expectedPerms )
931+ assert .Equal (t , expectedPerms , perms , "Path %s should have %s permissions" , path , permStr )
932+ }
933+
704934 // Special verification for permissions tests
705935 if tc .permissions != "" && tc .shouldSucceed {
706936 for path , entryType := range tc .expectedPaths {
0 commit comments