diff --git a/docs/differences/Directories.md b/docs/differences/Directories.md deleted file mode 100644 index f76c9c6..0000000 --- a/docs/differences/Directories.md +++ /dev/null @@ -1,10 +0,0 @@ -Directories -=== - -deterministic-zip DOES NOT include empty folders, like zip does. - -Because the tool is intended to be used mainly for building its simply not necessary to include this feature. - -This also reduces complexity and risk. - -If you feel like this should be implemented, please create a issue. diff --git a/pkg/cli/configuration.go b/pkg/cli/configuration.go index 32f24f9..4cd35cf 100644 --- a/pkg/cli/configuration.go +++ b/pkg/cli/configuration.go @@ -22,6 +22,9 @@ type Configuration struct { // Recursive includes all child folders automatically Recursive bool + // Directories includes directory entries in the zip file + Directories bool + // Exclude file patterns from the archive Exclude []string @@ -59,6 +62,7 @@ func (conf *Configuration) addStringFlag(field *string, long string, short strin func (conf *Configuration) defineFlags() { conf.addBoolFlag(&conf.Verbose, "verbose", "v", false, "Verbose mode or print diagnostic version info.") + conf.addBoolFlag(&conf.Directories, "directories", "D", false, "Include directories in the zip file.") conf.addBoolFlag(&conf.Recursive, "recurse-paths", "r", false, "Include all files verbose") conf.addBoolFlag(&conf.Quiet, "quiet", "q", false, "Quiet mode; eliminate informational messages") conf.addStringsFlag(&conf.Exclude, "exclude", "x", []string{}, "Exclude specific file patterns") diff --git a/pkg/features/conditions/contains_key.go b/pkg/features/conditions/contains_key.go new file mode 100644 index 0000000..5d8e3ef --- /dev/null +++ b/pkg/features/conditions/contains_key.go @@ -0,0 +1,8 @@ +package conditions + +func ContainsKey(haystack *map[string]string, needle string) bool { + if _, ok := (*haystack)[needle]; ok { + return ok + } + return false +} diff --git a/pkg/features/conditions/contains_key_test.go b/pkg/features/conditions/contains_key_test.go new file mode 100644 index 0000000..2c5aa32 --- /dev/null +++ b/pkg/features/conditions/contains_key_test.go @@ -0,0 +1,19 @@ +package conditions + +import ( + "testing" +) + +func TestContainsKey(t *testing.T) { + val := map[string]string{ + "zzzzz": "zzzzz", + "element": "element", + } + + if !ContainsKey(&val, "element") { + t.Fatal("Contains check not working") + } + if ContainsKey(&val, "missing element") { + t.Fatal("Contains check found element not in the slice") + } +} diff --git a/pkg/features/fileset/directories.go b/pkg/features/fileset/directories.go new file mode 100644 index 0000000..d0f0046 --- /dev/null +++ b/pkg/features/fileset/directories.go @@ -0,0 +1,70 @@ +package fileset + +import ( + "github.com/timo-reymann/deterministic-zip/pkg/cli" + "github.com/timo-reymann/deterministic-zip/pkg/features/conditions" + "github.com/timo-reymann/deterministic-zip/pkg/file" + "os" + "path/filepath" + "sort" +) + +// Directories adds all children folders recursively +type Directories struct{} + +// DebugName prints the debuggable name +func (r Directories) DebugName() string { + return "Directories" +} + +// IsEnabled checks if recursive adding was requested +func (r Directories) IsEnabled(c *cli.Configuration) bool { + return conditions.OnFlag(c.Directories) +} + +// Execute and read all directories recursively and add them back to source files +func (r Directories) Execute(c *cli.Configuration) error { + files := make(map[string]string, 0) + sort.Strings(c.SourceFiles) + + for _, f := range c.SourceFiles { + isDir, err := file.IsDir(f) + if err != nil { + return err + } + + cf := filepath.Clean(f) + if cf != "." { + if isDir { + cf += string(os.PathSeparator) + } + files[cf] = f + } + includeParentDirs(&files, f) + } + + c.SourceFiles = sortedKeySlice(&files) + + return nil +} + +func includeParentDirs(m *map[string]string, path string) { + parent := filepath.Dir(path) + + for parent != "." { + if !conditions.ContainsKey(m, parent) { + suffixed := parent + string(os.PathSeparator) + (*m)[suffixed] = parent + } + parent = filepath.Dir(parent) + } +} + +func sortedKeySlice(m *map[string]string) []string { + keys := make([]string, 0, len(*m)) + for k := range *m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/pkg/features/fileset/directories_test.go b/pkg/features/fileset/directories_test.go new file mode 100644 index 0000000..1519ffc --- /dev/null +++ b/pkg/features/fileset/directories_test.go @@ -0,0 +1,89 @@ +package fileset + +import ( + "github.com/timo-reymann/deterministic-zip/pkg/cli" + "reflect" + "testing" +) + +func TestDirectories_IsEnabled(t *testing.T) { + c := cli.Configuration{Directories: true} + directories := Directories{} + if !directories.IsEnabled(&c) { + t.Fatal("Execution for directories fallback not working") + } +} + +func TestDirectories_Execute(t *testing.T) { + directories := Directories{} + testCases := []struct { + sources []string + err *error + sourcesAfter []string + }{ + { + sources: []string{ + "testdata/recursive", + }, + err: nil, + sourcesAfter: []string{ + "testdata/", + "testdata/recursive/", + }, + }, + { + sources: []string{ + "testdata/recursive/folder/file.txt", + }, + err: nil, + sourcesAfter: []string{ + "testdata/", + "testdata/recursive/", + "testdata/recursive/folder/", + "testdata/recursive/folder/file.txt", + }, + }, + { + sources: []string{ + "testdata/recursive/folder/subfolder/", + }, + err: nil, + sourcesAfter: []string{ + "testdata/", + "testdata/recursive/", + "testdata/recursive/folder/", + "testdata/recursive/folder/subfolder/", + }, + }, + { + sources: []string{ + "nonExistent", + }, + err: mockErr("stat nonExistent: no such file or directory"), + sourcesAfter: []string{}, + }, + } + + for _, tc := range testCases { + c := cli.Configuration{ + Directories: true, + SourceFiles: tc.sources, + } + err := directories.Execute(&c) + if tc.err == nil && err != nil { + t.Fatalf("Expected no error but got %v", err) + } else if err != nil { + if (*tc.err).Error() != err.Error() { + t.Fatalf("Expected error %v, but got %v", *tc.err, err) + } else { + // Skip checking -> error thrown + continue + } + } + + if !reflect.DeepEqual(tc.sourcesAfter, c.SourceFiles) { + t.Fatalf("Expected %v, but got %v", tc.sourcesAfter, c.SourceFiles) + } + } + +} diff --git a/pkg/features/main.go b/pkg/features/main.go index aab62a2..a6428ec 100644 --- a/pkg/features/main.go +++ b/pkg/features/main.go @@ -40,4 +40,6 @@ func init() { register(fileset.Recursive{}) register(filter.Exclude{}) register(filter.Include{}) + // Directories must always be processed after the Recursive, Exclude, Include modules + register(fileset.Directories{}) } diff --git a/pkg/zip/main.go b/pkg/zip/main.go index b7d1c74..074ab70 100644 --- a/pkg/zip/main.go +++ b/pkg/zip/main.go @@ -4,6 +4,7 @@ import ( "archive/zip" "compress/flate" "github.com/timo-reymann/deterministic-zip/pkg/cli" + "github.com/timo-reymann/deterministic-zip/pkg/features/conditions" "github.com/timo-reymann/deterministic-zip/pkg/output" "io" "os" @@ -42,7 +43,7 @@ func Create(c *cli.Configuration, compression uint16) error { registerCompressors(zipWriter) for _, srcFile := range c.SourceFiles { - if err := appendFile(srcFile, zipWriter, compression); err != nil { + if err := appendFile(srcFile, zipWriter, compression, conditions.OnFlag(c.Directories)); err != nil { return err } } @@ -56,10 +57,17 @@ func registerCompressors(zipWriter *zip.Writer) { }) } -func appendFile(srcFile string, zipWriter *zip.Writer, compression uint16) error { +func appendFile(srcFile string, zipWriter *zip.Writer, compression uint16, includeDirs bool) error { output.Infof("Adding file %s", srcFile) f, err := os.Open(srcFile) + // Ensure the open file is always closed, ignoring any errors during the close + defer func(f *os.File) { + if f != nil { + _ = f.Close() + } + }(f) + if err != nil { return err } @@ -69,8 +77,7 @@ func appendFile(srcFile string, zipWriter *zip.Writer, compression uint16) error return err } - // Directories are currently not supported. - if stat.IsDir() { + if !includeDirs && stat.IsDir() { return nil } @@ -83,6 +90,13 @@ func appendFile(srcFile string, zipWriter *zip.Writer, compression uint16) error h.Name = srcFile h.Extra = extra + // If dealing with a directory, we must ensure the h.Name field ends with a `/` + if stat.IsDir() { + if !strings.HasSuffix(h.Name, "/") { + h.Name += "/" + } + } + fw, err := zipWriter.CreateHeader(h) if err != nil { return err @@ -93,7 +107,8 @@ func appendFile(srcFile string, zipWriter *zip.Writer, compression uint16) error return err } } - zipWriter.Flush() + // explicitly ignore any errors during the flush + _ = zipWriter.Flush() return nil } diff --git a/pkg/zip/main_test.go b/pkg/zip/main_test.go index 561ef44..67983f8 100644 --- a/pkg/zip/main_test.go +++ b/pkg/zip/main_test.go @@ -54,13 +54,18 @@ func TestCreate(t *testing.T) { }, { config: cli.Configuration{ + Directories: true, SourceFiles: []string{ "testdata/folder", }, }, - sha256: "8739c76e681f900923b900c9df0ef75cf421d39cabb54650c4b9ad19b6a76d85", + sha256: "7839c2a3939b278e9b24e02621e6bdb07f4c32f79e111661ee9948f7516009c3", compression: zip.Store, - zipFiles: []expectedFile{}, + zipFiles: []expectedFile{ + { + name: "testdata/folder/", + }, + }, }, { config: cli.Configuration{ @@ -114,6 +119,33 @@ func TestCreate(t *testing.T) { }, }, }, + { + config: cli.Configuration{ + Directories: true, + SourceFiles: []string{ + "testdata", + "testdata/file.txt", + "testdata/folder", + "testdata/folder/file.txt", + }, + }, + sha256: "e2431c807ee3f202e84f66ed3756ae736eb890916cf1737420708bed2181c5e0", + compression: zip.Store, + zipFiles: []expectedFile{ + { + name: "testdata/", + }, + { + name: "testdata/file.txt", + }, + { + name: "testdata/folder/", + }, + { + name: "testdata/folder/file.txt", + }, + }, + }, { config: cli.Configuration{ SourceFiles: []string{ @@ -134,6 +166,33 @@ func TestCreate(t *testing.T) { }, }, }, + { + config: cli.Configuration{ + Directories: true, + SourceFiles: []string{ + "testdata", + "testdata/file.txt", + "testdata/folder", + "testdata/folder/file.txt", + }, + }, + sha256: "9501f16697415f9de62aab8e28925111abfac435842b455ea1bd36852a5b6adc", + compression: zip.Deflate, + zipFiles: []expectedFile{ + { + name: "testdata/", + }, + { + name: "testdata/file.txt", + }, + { + name: "testdata/folder/", + }, + { + name: "testdata/folder/file.txt", + }, + }, + }, { config: cli.Configuration{ SourceFiles: []string{