diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 48d3517..5e9692c 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -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") { @@ -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 { diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 6cdd018..849c367 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -5,6 +5,7 @@ import ( "bytes" "context" "encoding/json" + "fmt" "io" "net/http" "net/http/httptest" @@ -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("report"), 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("ok"), 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") + } +}