diff --git a/shortcuts/apps/html_publish_zip.go b/shortcuts/apps/html_publish_zip.go new file mode 100644 index 000000000..cc0424e17 --- /dev/null +++ b/shortcuts/apps/html_publish_zip.go @@ -0,0 +1,71 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "archive/zip" + "bytes" + "crypto/sha256" + "encoding/hex" + "io" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/extension/fileio" +) + +// htmlPublishArchive is the in-memory zip ready for TOS upload. +type htmlPublishArchive struct { + Body []byte + Size int64 + SHA256 string +} + +func buildHTMLPublishZip(fio fileio.FileIO, candidates []htmlPublishCandidate) (*htmlPublishArchive, error) { + if len(candidates) == 0 { + return nil, appsValidationParamError("--path", "no files to pack") + } + + var buf bytes.Buffer + hasher := sha256.New() + multi := io.MultiWriter(&buf, hasher) + zw := zip.NewWriter(multi) + + for _, c := range candidates { + if err := writeHTMLPublishZipEntry(fio, zw, c); err != nil { + _ = zw.Close() + return nil, err + } + } + + if err := zw.Close(); err != nil { + return nil, appsFileIOError(err, "zip close: %v", err) + } + + return &htmlPublishArchive{ + Body: buf.Bytes(), + Size: int64(buf.Len()), + SHA256: hex.EncodeToString(hasher.Sum(nil)), + }, nil +} + +func writeHTMLPublishZipEntry(fio fileio.FileIO, zw *zip.Writer, c htmlPublishCandidate) error { + if isUnsafeRelPath(c.RelPath) { + return errs.NewInternalError(errs.SubtypeUnknown, "invalid zip entry name %q", c.RelPath) + } + + src, err := fio.Open(c.AbsPath) + if err != nil { + return appsInputPathEntryError(c.AbsPath, err) + } + defer src.Close() + + w, err := zw.Create(c.RelPath) + if err != nil { + return appsFileIOError(err, "create zip entry %s: %v", c.RelPath, err) + } + if _, err := io.Copy(w, src); err != nil { + return appsFileIOError(err, "copy %s: %v", c.RelPath, err) + } + return nil +} diff --git a/shortcuts/apps/html_publish_zip_test.go b/shortcuts/apps/html_publish_zip_test.go new file mode 100644 index 000000000..b948e285a --- /dev/null +++ b/shortcuts/apps/html_publish_zip_test.go @@ -0,0 +1,175 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "archive/zip" + "bytes" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestBuildHTMLPublishZip_RoundTrip(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(""), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "style.css"), []byte("body{}"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + + fio := newTestFIO() + candidates, err := walkHTMLPublishCandidates(fio, dir) + if err != nil { + t.Fatalf("walk: %v", err) + } + archive, err := buildHTMLPublishZip(fio, candidates) + if err != nil { + t.Fatalf("buildHTMLPublishZip: %v", err) + } + + if len(archive.SHA256) != 64 { + t.Fatalf("SHA256 wrong len: %d", len(archive.SHA256)) + } + if archive.Size <= 0 || int64(len(archive.Body)) != archive.Size { + t.Fatalf("size=%d body=%d", archive.Size, len(archive.Body)) + } + + r, err := zip.NewReader(bytes.NewReader(archive.Body), archive.Size) + if err != nil { + t.Fatalf("read zip: %v", err) + } + names := make(map[string]bool) + for _, f := range r.File { + names[f.Name] = true + } + if !names["index.html"] || !names["style.css"] { + t.Errorf("zip entries = %v, want index.html and style.css", names) + } + + // Verify content round-trip for index.html. + for _, f := range r.File { + if f.Name == "index.html" { + rc, err := f.Open() + if err != nil { + t.Fatalf("open entry: %v", err) + } + body, err := io.ReadAll(rc) + rc.Close() + if err != nil || string(body) != "" { + t.Fatalf("body=%q err=%v", body, err) + } + } + } +} + +func TestBuildHTMLPublishZip_EmptyCandidates(t *testing.T) { + if _, err := buildHTMLPublishZip(newTestFIO(), nil); err == nil { + t.Fatalf("expected error") + } +} + +func TestBuildHTMLPublishZip_SHA256Stable(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("

hi

"), 0o644); err != nil { + t.Fatal(err) + } + candidates := []htmlPublishCandidate{ + {RelPath: "index.html", AbsPath: filepath.Join(dir, "index.html"), Size: 11}, + } + fio := newTestFIO() + a1, err := buildHTMLPublishZip(fio, candidates) + if err != nil { + t.Fatal(err) + } + a2, err := buildHTMLPublishZip(fio, candidates) + if err != nil { + t.Fatal(err) + } + if a1.SHA256 != a2.SHA256 { + t.Errorf("SHA256 not stable: %s vs %s", a1.SHA256, a2.SHA256) + } +} + +func TestWriteHTMLPublishZipEntry_OpenFailure(t *testing.T) { + zw := zip.NewWriter(io.Discard) + defer zw.Close() + err := writeHTMLPublishZipEntry(newTestFIO(), zw, htmlPublishCandidate{ + RelPath: "x.html", + AbsPath: "/nonexistent-path-for-test/x.html", + Size: 0, + }) + if err == nil { + t.Fatalf("expected error for nonexistent abs path") + } + if !strings.Contains(err.Error(), "open") { + t.Fatalf("expected open error, got %v", err) + } +} + +func TestWriteHTMLPublishZipEntry_CopyFailure(t *testing.T) { + fio := readFailingFIO{readErr: errors.New("synthetic read failure")} + zw := zip.NewWriter(io.Discard) + defer zw.Close() + + err := writeHTMLPublishZipEntry(fio, zw, htmlPublishCandidate{ + RelPath: "x.html", + AbsPath: "fixtures/x.html", + Size: 7, + }) + if err == nil { + t.Fatalf("expected error when underlying Read fails") + } + if !strings.Contains(err.Error(), "copy") { + t.Fatalf("expected copy-stage error, got %v", err) + } +} + +func TestBuildHTMLPublishZip_EntryWriteFailureReturnsError(t *testing.T) { + candidates := []htmlPublishCandidate{ + {RelPath: "x.html", AbsPath: "/nonexistent-path-for-test/x.html", Size: 0}, + } + + archive, err := buildHTMLPublishZip(newTestFIO(), candidates) + if err == nil { + t.Fatalf("expected error, got archive=%+v", archive) + } + if archive != nil { + t.Fatalf("expected nil archive on error, got %+v", archive) + } +} + +func TestWriteHTMLPublishZipEntry_RejectsPathTraversal(t *testing.T) { + zw := zip.NewWriter(io.Discard) + defer zw.Close() + + cases := []struct { + name string + rel string + }{ + {"parent traversal", "../etc/passwd"}, + {"absolute path", "/etc/passwd"}, + {"embedded traversal", "a/../../etc/passwd"}, + {"null byte", "evil\x00.html"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + err := writeHTMLPublishZipEntry(newTestFIO(), zw, htmlPublishCandidate{ + RelPath: c.rel, + AbsPath: "fixtures/whatever", + Size: 0, + }) + if err == nil { + t.Fatalf("expected error for RelPath=%q", c.rel) + } + if !strings.Contains(err.Error(), "invalid zip entry name") { + t.Fatalf("expected 'invalid zip entry name' error, got %v", err) + } + }) + } +}