mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
feat(apps): add zip packaging for arch_type=4 html publish path
This commit is contained in:
71
shortcuts/apps/html_publish_zip.go
Normal file
71
shortcuts/apps/html_publish_zip.go
Normal file
@@ -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
|
||||
}
|
||||
175
shortcuts/apps/html_publish_zip_test.go
Normal file
175
shortcuts/apps/html_publish_zip_test.go
Normal file
@@ -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("<html></html>"), 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) != "<html></html>" {
|
||||
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("<h1>hi</h1>"), 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user