diff --git a/docs/changelog/index.md b/docs/changelog/index.md index a8f206d5..3b81d44c 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -22,6 +22,8 @@ sidebar: false ### 修复 +- 修复 Pages 上传或节点同步时报 `pages file size out of bounds`:允许 ZIP 包内的 0 字节文件,并兼容未声明解压大小的 ZIP 条目。 + - 修复节点详情 OpenResty 连接数与吞吐显示为「—」:节点可观测 API 将 OpenResty 观测数据合并进 `metric_snapshots`;指标文案改为「请求/分钟」(近 60 秒窗口),连接数为 0 时正常显示 0。 - 修复仪表盘「24 小时请求趋势」摘要误显示当前小时请求量/错误量:改为汇总近 24 小时总量。 diff --git a/internal/apps/agent/sync/pages.go b/internal/apps/agent/sync/pages.go index a6e18cee..88cb8f84 100644 --- a/internal/apps/agent/sync/pages.go +++ b/internal/apps/agent/sync/pages.go @@ -205,11 +205,19 @@ func extractPagesPackage(packageBytes []byte, releaseDir string, deployment page return os.Rename(tmpDir, releaseDir) } -func pagesZipEntryCopyLimit(size uint64) (int64, error) { - if size == 0 || size > pagesMaxExtractedFileBytes || size > uint64(math.MaxInt64) { +func copyPagesZipEntryContent(dst io.Writer, src io.Reader, declaredSize uint64) (int64, error) { + if declaredSize > pagesMaxExtractedFileBytes || declaredSize > uint64(math.MaxInt64) { return 0, errors.New("pages file size out of bounds") } - return int64(size), nil //nolint:gosec // size is bounded to math.MaxInt64 above + if declaredSize > 0 { + return io.CopyN(dst, src, int64(declaredSize)) //nolint:gosec // declaredSize is bounded to math.MaxInt64 above + } + limited := io.LimitReader(src, pagesMaxExtractedFileBytes+1) + written, err := io.Copy(dst, limited) + if written > pagesMaxExtractedFileBytes { + return written, errors.New("pages file size out of bounds") + } + return written, err } func extractPagesFile(item *zip.File, targetPath string) error { @@ -226,12 +234,11 @@ func extractPagesFile(item *zip.File, targetPath string) error { return err } defer func() { _ = target.Close() }() - limit, err := pagesZipEntryCopyLimit(item.UncompressedSize64) + _, err = copyPagesZipEntryContent(target, source, item.UncompressedSize64) if err != nil { return fmt.Errorf("%s: %w", item.Name, err) } - _, err = io.CopyN(target, source, limit) - return err + return nil } func switchPagesCurrentDir(baseDir string, deploymentID uint, releaseDir string) error { diff --git a/internal/apps/agent/sync/service_test.go b/internal/apps/agent/sync/service_test.go index 337e753f..94d2dbee 100644 --- a/internal/apps/agent/sync/service_test.go +++ b/internal/apps/agent/sync/service_test.go @@ -237,6 +237,49 @@ func TestSyncOnceDownloadsPagesDeploymentBeforeApply(t *testing.T) { } } +func TestSyncOnceExtractsPagesPackageWithZeroByteFiles(t *testing.T) { + packageBytes := testPagesPackage(t, map[string]string{ + "index.html": "hello", + ".gitkeep": "", + }) + checksum := testBytesChecksum(packageBytes) + client := &fakeClient{ + config: protocol.ActiveConfigResponse{ + Version: "20260309-103", + Checksum: "pages-config-checksum", + SourceConfigJSON: testPagesSourceConfigJSON(9, checksum), + CreatedAt: time.Now().Format(time.RFC3339), + }, + pagesPackages: map[uint][]byte{9: packageBytes}, + } + stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json")) + nodeID, err := stateStore.EnsureNodeID() + if err != nil { + t.Fatalf("EnsureNodeID failed: %v", err) + } + snapshot, _ := stateStore.Load() + snapshot.NodeID = nodeID + if err = stateStore.Save(snapshot); err != nil { + t.Fatalf("save state failed: %v", err) + } + manager := &fakeManager{currentChecksum: "old-checksum"} + service := New(client, manager, stateStore) + pagesDir := t.TempDir() + service.SetPagesDir(pagesDir) + + if err = service.SyncOnce(context.Background(), &protocol.ActiveConfigMeta{Version: "20260309-103", Checksum: "pages-config-checksum"}); err != nil { + t.Fatalf("SyncOnce failed: %v", err) + } + gitkeepPath := filepath.Join(pagesDir, "deployments", "9", "current", ".gitkeep") + info, err := os.Stat(gitkeepPath) + if err != nil { + t.Fatalf("expected zero-byte Pages file to be extracted: %v", err) + } + if info.Size() != 0 { + t.Fatalf("expected zero-byte Pages file, got %d bytes", info.Size()) + } +} + func TestSyncOnceRejectsPagesZipSlipBeforeApply(t *testing.T) { packageBytes := testPagesPackage(t, map[string]string{"../escape.html": "bad", "index.html": "ok"}) checksum := testBytesChecksum(packageBytes) diff --git a/internal/apps/openflare/pages/helpers.go b/internal/apps/openflare/pages/helpers.go index 2f96f72d..5306c0b9 100644 --- a/internal/apps/openflare/pages/helpers.go +++ b/internal/apps/openflare/pages/helpers.go @@ -29,13 +29,13 @@ const ( pagesLegacyArtifactCandidateCapacity = 8 pagesLegacyArtifactRootCapacity = 4 pagesMaxDeploymentFiles = 1000 - pagesMaxDeploymentBytes = 100 * 1024 * 1024 - defaultPagesEntryFile = "index.html" - defaultPagesFallbackPath = "/index.html" - pagesDeploymentUploadType = "openflare_pages_deployment" - mimeTypeApplicationZip = "application/zip" - pagesMaxPathLength = 512 - bytesPerKiB = 1024 + pagesMaxDeploymentBytes = 100 * 1024 * 1024 + defaultPagesEntryFile = "index.html" + defaultPagesFallbackPath = "/index.html" + pagesDeploymentUploadType = "openflare_pages_deployment" + mimeTypeApplicationZip = "application/zip" + pagesMaxPathLength = 512 + bytesPerKiB = 1024 ) var pagesSlugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,126}[a-z0-9]$|^[a-z0-9]$`) @@ -397,20 +397,20 @@ func inspectPagesZip(zipPath string, rootDir string, entryFile string) (*deploym if manifest.FileCount > pagesMaxDeploymentFiles { return nil, fmt.Errorf("pages 部署文件数不能超过 %d", pagesMaxDeploymentFiles) } - manifest.TotalSize += int64(item.UncompressedSize64) + checksum, fileSize, err := checksumZipFile(item) + if err != nil { + return nil, fmt.Errorf("%s: %w", normalizedPath, err) + } + manifest.TotalSize += fileSize if manifest.TotalSize > pagesMaxDeploymentBytes { return nil, fmt.Errorf("pages 部署展开后不能超过 %d MiB", pagesMaxDeploymentBytes/bytesPerKiB/bytesPerKiB) } - checksum, err := checksumZipFile(item) - if err != nil { - return nil, err - } if normalizedPath == targetEntryPath { entrySeen = true } manifest.Files = append(manifest.Files, model.PagesDeploymentFile{ Path: normalizedPath, - Size: int64(item.UncompressedSize64), + Size: fileSize, Checksum: checksum, }) } @@ -444,26 +444,31 @@ func normalizePagesZipPath(raw string) (string, bool, error) { return cleaned, false, nil } -func pagesZipEntryCopyLimit(size uint64) (int64, error) { - if size == 0 || size > pagesMaxDeploymentBytes || size > uint64(math.MaxInt64) { +func copyPagesZipEntryContent(dst io.Writer, src io.Reader, declaredSize uint64) (int64, error) { + if declaredSize > pagesMaxDeploymentBytes || declaredSize > uint64(math.MaxInt64) { return 0, errors.New("pages file size out of bounds") } - return int64(size), nil //nolint:gosec // size is bounded to math.MaxInt64 above + if declaredSize > 0 { + return io.CopyN(dst, src, int64(declaredSize)) //nolint:gosec // declaredSize is bounded to math.MaxInt64 above + } + limited := io.LimitReader(src, pagesMaxDeploymentBytes+1) + written, err := io.Copy(dst, limited) + if written > pagesMaxDeploymentBytes { + return written, errors.New("pages file size out of bounds") + } + return written, err } -func checksumZipFile(item *zip.File) (string, error) { +func checksumZipFile(item *zip.File) (string, int64, error) { file, err := item.Open() if err != nil { - return "", err + return "", 0, err } defer func() { _ = file.Close() }() hash := sha256.New() - limit, err := pagesZipEntryCopyLimit(item.UncompressedSize64) + written, err := copyPagesZipEntryContent(hash, file, item.UncompressedSize64) if err != nil { - return "", err + return "", written, err } - if _, err = io.CopyN(hash, file, limit); err != nil { - return "", err - } - return hex.EncodeToString(hash.Sum(nil)), nil + return hex.EncodeToString(hash.Sum(nil)), written, nil } diff --git a/internal/apps/openflare/pages/logics_test.go b/internal/apps/openflare/pages/logics_test.go index 729185db..3d8f96ba 100644 --- a/internal/apps/openflare/pages/logics_test.go +++ b/internal/apps/openflare/pages/logics_test.go @@ -142,6 +142,28 @@ func TestCreateProjectRejectsUnsafeFallbackPath(t *testing.T) { assert.Contains(t, err.Error(), "回退路径") } +func TestUploadDeploymentAcceptsZeroByteFiles(t *testing.T) { + cleanup := setupPagesTestDB(t) + defer cleanup() + _, disableStorage := setupPagesStorageMock(t) + defer disableStorage() + ctx := context.Background() + + project, err := CreateProject(ctx, Input{ + Name: "Zero Byte Site", + Slug: "zero-byte-site", + Enabled: true, + }) + require.NoError(t, err) + + deployment, err := UploadDeployment(ctx, project.ID, testPagesMultipartFile(t, "site.zip", testPagesZip(t, map[string]string{ + "index.html": "ok", + ".gitkeep": "", + })), "root") + require.NoError(t, err) + assert.Equal(t, 2, deployment.FileCount) +} + func TestUploadDeploymentStoresPackageInUploadFramework(t *testing.T) { cleanup := setupPagesTestDB(t) defer cleanup() @@ -336,4 +358,4 @@ func testPagesMultipartFile(t *testing.T, fileName string, content []byte) *mult require.NoError(t, err) file.Close() return header -} \ No newline at end of file +}