From 7a3f070e2169e04f41c963087ff68ba4a24ebefa Mon Sep 17 00:00:00 2001 From: robert dennis <31261583+robertdrakedennis@users.noreply.github.com> Date: Sat, 30 May 2026 00:19:23 -0400 Subject: [PATCH] empty folders in uploaded artifacts should be preserved --- server/filesystem/compress.go | 13 ++-- server/filesystem/compress_test.go | 97 ++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 4 deletions(-) diff --git a/server/filesystem/compress.go b/server/filesystem/compress.go index f2775cb31..0044365b3 100644 --- a/server/filesystem/compress.go +++ b/server/filesystem/compress.go @@ -258,14 +258,19 @@ func (fs *Filesystem) extractStream(ctx context.Context, opts extractStreamOptio // Decompress and extract archive return ex.Extract(ctx, opts.Reader, func(ctx context.Context, f archives.FileInfo) error { - if f.IsDir() { - return nil - } p := filepath.Join(opts.Directory, f.NameInArchive) - // If it is ignored, just don't do anything with the file and skip over it. + // If it is ignored, just don't do anything with the entry and skip over it. if err := fs.IsIgnored(p); err != nil { return nil } + // Create directories explicitly; an empty one has no file to create it + // implicitly and would otherwise be dropped during extraction. + if f.IsDir() { + if err := fs.unixFS.MkdirAll(p, 0o755); err != nil { + return wrapError(err, opts.FileName) + } + return nil + } r, err := f.Open() if err != nil { return err diff --git a/server/filesystem/compress_test.go b/server/filesystem/compress_test.go index 80cf70800..202cc678d 100644 --- a/server/filesystem/compress_test.go +++ b/server/filesystem/compress_test.go @@ -1,6 +1,10 @@ package filesystem import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" "context" "os" "testing" @@ -52,3 +56,96 @@ func TestFilesystem_DecompressFile(t *testing.T) { }) }) } + +// Empty directories have no file to create them implicitly, so extraction must +// create them explicitly or they are dropped. +func TestFilesystem_DecompressFileEmptyDirectory(t *testing.T) { + g := Goblin(t) + fs, rfs := NewFs() + + g.Describe("Decompress", func() { + archives := []struct { + name string + build func() ([]byte, error) + }{ + {"empty.zip", zipWithEmptyDir}, + {"empty.tar.gz", tarGzWithEmptyDir}, + } + + for _, a := range archives { + g.It("preserves an empty directory in a "+a.name, func() { + content, err := a.build() + g.Assert(err).IsNil() + err = rfs.CreateServerFile("./"+a.name, content) + g.Assert(err).IsNil() + + err = fs.DecompressFile(context.Background(), "/", a.name) + g.Assert(err).IsNil() + + // The empty directory must exist, and the sibling file must still extract. + st, err := rfs.StatServerFile("empty") + g.Assert(err).IsNil() + g.Assert(st.IsDir()).IsTrue() + + _, err = rfs.StatServerFile("outside.txt") + g.Assert(err).IsNil() + }) + } + + g.AfterEach(func() { + _ = fs.TruncateRootDirectory() + }) + }) +} + +// zipWithEmptyDir builds a zip holding one file and an empty directory ("empty/"). +func zipWithEmptyDir() ([]byte, error) { + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + + dh := &zip.FileHeader{Name: "empty/"} + dh.SetMode(os.ModeDir | 0o755) + if _, err := zw.CreateHeader(dh); err != nil { + return nil, err + } + + w, err := zw.Create("outside.txt") + if err != nil { + return nil, err + } + if _, err := w.Write([]byte("hello")); err != nil { + return nil, err + } + + if err := zw.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// tarGzWithEmptyDir builds a tar.gz holding one file and an empty directory ("empty/"). +func tarGzWithEmptyDir() ([]byte, error) { + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + + if err := tw.WriteHeader(&tar.Header{Name: "empty/", Typeflag: tar.TypeDir, Mode: 0o755}); err != nil { + return nil, err + } + + content := []byte("hello") + if err := tw.WriteHeader(&tar.Header{Name: "outside.txt", Typeflag: tar.TypeReg, Mode: 0o644, Size: int64(len(content))}); err != nil { + return nil, err + } + if _, err := tw.Write(content); err != nil { + return nil, err + } + + if err := tw.Close(); err != nil { + return nil, err + } + if err := gw.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +}