Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 67 additions & 6 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,72 @@ func newUploadRequestBody(entryFile string, tags []string, category, project str

func writeZIPArchive(rootDir string, target io.Writer) error {
archive := zip.NewWriter(target)

entries, readErr := os.ReadDir(rootDir)
singleFile := readErr == nil && isSingleFileDir(entries)

var err error
if singleFile {
err = writeSingleFileEntry(archive, rootDir, entries)
} else {
err = writeDirEntries(archive, rootDir)
}

if err != nil {
_ = archive.Close()
return err
}
return archive.Close()
}

// isSingleFileDir returns true only when the directory contains exactly one
// non-hidden web asset file and no non-hidden subdirectories. In all other
// cases we fall back to the full WalkDir to avoid silently dropping resources.
func isSingleFileDir(entries []os.DirEntry) bool {
webCount := 0
for _, e := range entries {
if isHiddenName(e.Name()) {
continue
}
if e.IsDir() {
return false
}
if e.Type().IsRegular() && isWebAsset(e.Name()) {
webCount++
}
}
return webCount == 1
}

func isHiddenName(name string) bool {
return strings.HasPrefix(name, ".") && name != "." && name != ".."
}

func writeSingleFileEntry(archive *zip.Writer, dir string, entries []os.DirEntry) error {
for _, e := range entries {
if isHiddenName(e.Name()) || e.IsDir() || !e.Type().IsRegular() || !isWebAsset(e.Name()) {
continue
}
sourceFile, err := os.Open(filepath.Join(dir, e.Name()))
if err != nil {
return err
}
w, err := archive.Create(e.Name())
if err != nil {
_ = sourceFile.Close()
return err
}
_, copyErr := io.Copy(w, sourceFile)
closeErr := sourceFile.Close()
if copyErr != nil {
return copyErr
}
return closeErr
}
return fmt.Errorf("no web asset found in directory")
}

func writeDirEntries(archive *zip.Writer, rootDir string) error {
err := filepath.WalkDir(rootDir, func(filePath string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
if errors.Is(walkErr, os.ErrPermission) || strings.Contains(walkErr.Error(), "permission denied") {
Expand Down Expand Up @@ -350,12 +416,7 @@ func writeZIPArchive(rootDir string, target io.Writer) error {

return closeErr
})
if err != nil {
_ = archive.Close()
return err
}

return archive.Close()
return err
}

func isHiddenPath(path string) bool {
Expand Down
105 changes: 105 additions & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
Expand Down Expand Up @@ -403,3 +404,107 @@ func TestWriteZIPArchiveSkipsPermissionDenied(t *testing.T) {
t.Fatalf("expected index.html, got %q", zipReader.File[0].Name)
}
}

func TestSingleFileSkipsWalkOnLargeDirectory(t *testing.T) {
t.Parallel()

rootDir := t.TempDir()
entryFile := filepath.Join(rootDir, "report.html")
if err := os.WriteFile(entryFile, []byte("<!doctype html><title>report</title>"), 0o644); err != nil {
t.Fatal(err)
}

// Create many non-web sibling files to simulate a large directory (e.g. home dir)
for i := 0; i < 200; i++ {
f := filepath.Join(rootDir, fmt.Sprintf("data-%d.log", i))
if err := os.WriteFile(f, []byte("log data"), 0o644); err != nil {
t.Fatal(err)
}
}

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reader, err := r.MultipartReader()
if err != nil {
t.Fatal(err)
}
archiveEntries := map[string]string{}
for {
part, err := reader.NextPart()
if err == io.EOF {
break
}
if err != nil {
t.Fatal(err)
}
if part.FormName() == "archive" {
archiveBytes, _ := io.ReadAll(part)
zr, _ := zip.NewReader(bytes.NewReader(archiveBytes), int64(len(archiveBytes)))
for _, f := range zr.File {
fr, _ := f.Open()
c, _ := io.ReadAll(fr)
fr.Close()
archiveEntries[f.Name] = string(c)
}
}
}

if len(archiveEntries) != 1 {
t.Fatalf("expected 1 file in archive, got %d: %v", len(archiveEntries), archiveEntries)
}
if archiveEntries["report.html"] == "" {
t.Fatal("archive missing report.html")
}

w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"url": "http://example.test/s/session/"})
}))
defer srv.Close()

var stdout bytes.Buffer
var stderr bytes.Buffer
if err := Run([]string{"send", entryFile, "--tag", "test", "--category", "cat", "--project", "proj", "--server", srv.URL}, &stdout, &stderr); err != nil {
t.Fatal(err)
}
}

func TestSubdirectoryWithAssetsStillWalks(t *testing.T) {
t.Parallel()

rootDir := t.TempDir()
entryFile := filepath.Join(rootDir, "index.html")
if err := os.WriteFile(entryFile, []byte("<!doctype html><title>ok</title>"), 0o644); err != nil {
t.Fatal(err)
}

// Non-standard subdirectory name that isWebAssetDir won't recognize
cssDir := filepath.Join(rootDir, "src", "components")
if err := os.MkdirAll(cssDir, 0o755); err != nil {
t.Fatal(err)
}
cssFile := filepath.Join(cssDir, "style.css")
if err := os.WriteFile(cssFile, []byte("body{}"), 0o644); err != nil {
t.Fatal(err)
}

var buf bytes.Buffer
if err := writeZIPArchive(rootDir, &buf); err != nil {
t.Fatalf("writeZIPArchive failed: %v", err)
}

zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
if err != nil {
t.Fatalf("failed to read zip: %v", err)
}

names := map[string]bool{}
for _, f := range zr.File {
names[f.Name] = true
}

if !names["index.html"] {
t.Fatal("archive missing index.html")
}
if !names["src/components/style.css"] {
t.Fatal("archive missing src/components/style.css — subdirectory assets should be included")
}
}
Loading