fix(pages): 收紧部署包与 Agent 同步边界

完成 V2 Phase 0 安全与一致性前置:统一真实归档限额、流式拉取、候选裁剪、保留上传删除语义及 Pages 路由引用锁。
This commit is contained in:
deqiying
2026-07-19 16:42:45 +08:00
parent f386674464
commit 4e8ec23264
47 changed files with 4005 additions and 759 deletions
+565 -94
View File
@@ -1,3 +1,6 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package sync applies control-plane configuration to the local agent runtime.
package sync
@@ -20,9 +23,13 @@ import (
)
const (
pagesDirPerm = 0o755
pagesFilePerm = 0o644
pagesManifestFilePerm = 0o644
pagesDirPerm = 0o755
pagesFilePerm = 0o644
pagesManifestFilePerm = 0o644
agentPagesMaxPackageBytes = int64(2 * 1024 * 1024 * 1024)
agentPagesMaxFiles = 1000
agentPagesMaxFileBytes = int64(8 * 1024 * 1024 * 1024)
agentPagesMaxTotalBytes = int64(8 * 1024 * 1024 * 1024)
// pagesLatestPullAttempts covers a race where the active deployment changes
// between the hash probe and the package download.
pagesLatestPullAttempts = 2
@@ -45,6 +52,11 @@ type pagesProjectRef struct {
Checksum string
}
type pagesPackageLimits struct {
PackageBytes int64
Extraction pagesarchive.Limits
}
type pagesDeploymentMarker struct {
ProjectID uint `json:"project_id"`
DeploymentID uint `json:"deployment_id,omitempty"`
@@ -191,10 +203,11 @@ func (s *Service) ensurePagesProject(ctx context.Context, snapshot *state.Snapsh
if err != nil {
return fmt.Errorf("fetch Pages project %d latest hash: %w", projectID, err)
}
hash := strings.TrimSpace(latest.Hash)
if hash == "" {
return fmt.Errorf("pages project %d latest hash is empty", projectID)
limits, err := validatePagesPackageMetadata(projectID, latest)
if err != nil {
return err
}
hash := strings.TrimSpace(latest.Hash)
effective := pagesProjectRef{
ProjectID: projectID,
DeploymentID: latest.DeploymentID,
@@ -211,44 +224,65 @@ func (s *Service) ensurePagesProject(ctx context.Context, snapshot *state.Snapsh
return nil
}
packageBytes, err := s.client.DownloadPagesProjectLatestPackage(ctx, projectID)
packagePath, got, err := s.downloadPagesProjectPackage(ctx, projectID, latest, limits.PackageBytes)
if err != nil {
return fmt.Errorf("download Pages project %d latest package: %w", projectID, err)
}
got := checksumBytes(packageBytes)
// Re-probe latest after download to detect activation races.
// Accept the package only when its content hash still matches latest.
// A deployment-id-only change is still a latest-pointer race even when
// deduplication makes both deployments share the same package hash.
verify, err := s.client.GetPagesProjectLatestHash(ctx, projectID)
if err != nil {
_ = os.Remove(packagePath)
return fmt.Errorf("re-fetch Pages project %d latest hash: %w", projectID, err)
}
verifyHash := strings.TrimSpace(verify.Hash)
if verifyHash == "" {
return fmt.Errorf("pages project %d latest hash is empty", projectID)
if _, err := validatePagesPackageMetadata(projectID, verify); err != nil {
_ = os.Remove(packagePath)
return err
}
if got != verifyHash {
if !samePagesPackageMetadata(latest, verify) {
_ = os.Remove(packagePath)
lastErr = fmt.Errorf(
"pages project %d package/hash race: downloaded %s, latest now %s (attempt %d/%d)",
projectID, got, verifyHash, attempt+1, pagesLatestPullAttempts,
"pages project %d latest metadata changed during download: deployment %d/%s -> %d/%s (attempt %d/%d)",
projectID,
latest.DeploymentID,
strings.TrimSpace(latest.Hash),
verify.DeploymentID,
strings.TrimSpace(verify.Hash),
attempt+1,
pagesLatestPullAttempts,
)
slog.Warn("pages latest package race, retrying",
slog.Warn("pages latest metadata race, retrying",
"project_id", projectID,
"before_deployment_id", latest.DeploymentID,
"before_hash", strings.TrimSpace(latest.Hash),
"after_deployment_id", verify.DeploymentID,
"after_hash", strings.TrimSpace(verify.Hash),
"attempt", attempt+1,
)
continue
}
if got != hash {
_ = os.Remove(packagePath)
lastErr = fmt.Errorf(
"pages project %d package hash mismatch: downloaded %s, expected %s (attempt %d/%d)",
projectID, got, hash, attempt+1, pagesLatestPullAttempts,
)
slog.Warn("pages latest package hash mismatch, retrying",
"project_id", projectID,
"downloaded_hash", got,
"latest_hash", verifyHash,
"expected_hash", hash,
"attempt", attempt+1,
)
continue
}
effective = pagesProjectRef{
ProjectID: projectID,
DeploymentID: verify.DeploymentID,
Checksum: got,
}
releaseDir = pagesProjectReleaseDir(s.pagesDir, projectID, got)
if err := extractPagesPackage(packageBytes, releaseDir, effective); err != nil {
return err
extractErr := extractPagesPackageFile(packagePath, releaseDir, effective, limits.Extraction, latest)
_ = os.Remove(packagePath)
if extractErr != nil {
return extractErr
}
if err := switchPagesProjectCurrentDir(s.pagesDir, projectID, releaseDir); err != nil {
return err
@@ -264,6 +298,145 @@ func (s *Service) ensurePagesProject(ctx context.Context, snapshot *state.Snapsh
return fmt.Errorf("pages project %d latest pull failed", projectID)
}
func validatePagesPackageMetadata(
projectID uint,
metadata *protocol.PagesProjectLatestHashResponse,
) (pagesPackageLimits, error) {
if metadata == nil {
return pagesPackageLimits{}, fmt.Errorf("pages project %d latest metadata is missing", projectID)
}
if metadata.ProjectID != projectID {
return pagesPackageLimits{}, fmt.Errorf(
"pages project %d latest metadata has project id %d",
projectID,
metadata.ProjectID,
)
}
if metadata.DeploymentID == 0 {
return pagesPackageLimits{}, fmt.Errorf("pages project %d latest deployment id is missing", projectID)
}
if strings.TrimSpace(metadata.Hash) == "" {
return pagesPackageLimits{}, fmt.Errorf("pages project %d latest hash is empty", projectID)
}
if metadata.PackageSize <= 0 {
return pagesPackageLimits{}, fmt.Errorf("pages project %d package size must be positive", projectID)
}
if metadata.PackageSize > agentPagesMaxPackageBytes {
return pagesPackageLimits{}, fmt.Errorf(
"pages project %d package size %d exceeds agent limit %d",
projectID,
metadata.PackageSize,
agentPagesMaxPackageBytes,
)
}
if metadata.FileCount <= 0 {
return pagesPackageLimits{}, fmt.Errorf("pages project %d file count must be positive", projectID)
}
if metadata.FileCount > agentPagesMaxFiles {
return pagesPackageLimits{}, fmt.Errorf(
"pages project %d file count %d exceeds agent limit %d",
projectID,
metadata.FileCount,
agentPagesMaxFiles,
)
}
if metadata.TotalSize < 0 {
return pagesPackageLimits{}, fmt.Errorf("pages project %d total size cannot be negative", projectID)
}
if metadata.TotalSize > agentPagesMaxTotalBytes {
return pagesPackageLimits{}, fmt.Errorf(
"pages project %d total size %d exceeds agent limit %d",
projectID,
metadata.TotalSize,
agentPagesMaxTotalBytes,
)
}
// pagesarchive treats zero limits as defaults. A one-byte extraction guard
// plus the exact post-extraction manifest check below preserves the valid
// case of one or more zero-byte files while still enforcing total_size=0.
extractedBytes := metadata.TotalSize
if extractedBytes == 0 {
extractedBytes = 1
}
maxFileBytes := extractedBytes
if maxFileBytes > agentPagesMaxFileBytes {
maxFileBytes = agentPagesMaxFileBytes
}
return pagesPackageLimits{
PackageBytes: metadata.PackageSize,
Extraction: pagesarchive.Limits{
MaxFiles: metadata.FileCount,
MaxFileBytes: maxFileBytes,
MaxTotalBytes: extractedBytes,
},
}, nil
}
func samePagesPackageMetadata(
before *protocol.PagesProjectLatestHashResponse,
after *protocol.PagesProjectLatestHashResponse,
) bool {
if before == nil || after == nil {
return false
}
return before.ProjectID == after.ProjectID &&
before.DeploymentID == after.DeploymentID &&
strings.TrimSpace(before.Hash) == strings.TrimSpace(after.Hash) &&
before.PackageSize == after.PackageSize &&
before.FileCount == after.FileCount &&
before.TotalSize == after.TotalSize
}
func (s *Service) downloadPagesProjectPackage(
ctx context.Context,
projectID uint,
metadata *protocol.PagesProjectLatestHashResponse,
maxBytes int64,
) (packagePath string, hash string, err error) {
releasesRoot := filepath.Join(s.pagesDir, "projects", fmt.Sprintf("%d", projectID), "releases")
if err := os.MkdirAll(releasesRoot, pagesDirPerm); err != nil {
return "", "", err
}
packageFile, err := os.CreateTemp(releasesRoot, ".package-*.tmp")
if err != nil {
return "", "", err
}
packagePath = packageFile.Name()
keep := false
defer func() {
if closeErr := packageFile.Close(); err == nil && closeErr != nil {
err = closeErr
}
if !keep || err != nil {
_ = os.Remove(packagePath)
packagePath = ""
}
}()
hasher := sha256.New()
written, err := s.client.DownloadPagesProjectLatestPackage(
ctx,
projectID,
io.MultiWriter(packageFile, hasher),
maxBytes,
)
if err != nil {
return "", "", err
}
if written != metadata.PackageSize {
return "", "", fmt.Errorf(
"pages project %d package size %d does not match metadata %d",
projectID,
written,
metadata.PackageSize,
)
}
keep = true
return packagePath, hex.EncodeToString(hasher.Sum(nil)), nil
}
// cleanupPagesProjectStaleReleases keeps only keepHash under projects/{id}/releases.
// Must be called only after the keepHash release is ready and current points at it.
func cleanupPagesProjectStaleReleases(baseDir string, projectID uint, keepHash string) error {
@@ -372,40 +545,258 @@ type pagesDeploymentSource struct {
Checksum string `json:"checksum"`
}
func extractPagesPackage(packageBytes []byte, releaseDir string, project pagesProjectRef) error {
tmpDir := releaseDir + ".tmp"
_ = os.RemoveAll(tmpDir)
if err := os.MkdirAll(tmpDir, pagesDirPerm); err != nil {
func extractPagesPackageFile(
packagePath string,
releaseDir string,
project pagesProjectRef,
limits pagesarchive.Limits,
expected *protocol.PagesProjectLatestHashResponse,
) error {
if err := os.MkdirAll(filepath.Dir(releaseDir), pagesDirPerm); err != nil {
return err
}
format, err := pagesarchive.DetectFormat("", packageBytes)
stagingDir, err := os.MkdirTemp(
filepath.Dir(releaseDir),
"."+filepath.Base(releaseDir)+"-*.tmp",
)
if err != nil {
_ = os.RemoveAll(tmpDir)
return fmt.Errorf("detect Pages package format: %w", err)
return err
}
// Control plane already inspected and accepted this package.
if err := pagesarchive.ExtractBytes(packageBytes, format, tmpDir, pagesarchive.ExtractOptions{
cleanupStaging := true
defer func() {
if cleanupStaging {
removePagesStagingUnlessCurrent(stagingDir, pagesCurrentDirFromRelease(releaseDir))
}
}()
if err := pagesarchive.ExtractFile(packagePath, "", stagingDir, pagesarchive.ExtractOptions{
StripCommonRoot: true,
EnforceLimits: false,
EnforceLimits: true,
Limits: limits,
}); err != nil {
_ = os.RemoveAll(tmpDir)
return fmt.Errorf("extract Pages package: %w", err)
}
if err := writePagesMarker(tmpDir, project); err != nil {
_ = os.RemoveAll(tmpDir)
if err := validateExtractedPagesMetadata(stagingDir, expected); err != nil {
return err
}
_ = os.RemoveAll(releaseDir)
return os.Rename(tmpDir, releaseDir)
if err := writePagesMarker(stagingDir, project); err != nil {
return err
}
if err := promotePagesRelease(stagingDir, releaseDir, project); err != nil {
return err
}
cleanupStaging = false
return nil
}
func switchPagesProjectCurrentDir(baseDir string, projectID uint, releaseDir string) error {
currentDir := pagesProjectCurrentDir(baseDir, projectID)
previousDir := currentDir + ".previous"
_ = os.RemoveAll(previousDir)
func validateExtractedPagesMetadata(
dir string,
expected *protocol.PagesProjectLatestHashResponse,
) error {
if expected == nil {
return nil
}
fileCount := 0
totalSize := int64(0)
err := filepath.WalkDir(dir, func(path string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() {
return nil
}
info, err := entry.Info()
if err != nil {
return err
}
if !info.Mode().IsRegular() {
return fmt.Errorf("pages extracted entry is not a regular file: %s", path)
}
fileCount++
if fileCount > agentPagesMaxFiles {
return fmt.Errorf("pages extracted file count exceeds agent limit %d", agentPagesMaxFiles)
}
if info.Size() < 0 || info.Size() > agentPagesMaxTotalBytes-totalSize {
return fmt.Errorf("pages extracted size exceeds agent limit %d", agentPagesMaxTotalBytes)
}
totalSize += info.Size()
return nil
})
if err != nil {
return fmt.Errorf("validate extracted Pages package: %w", err)
}
if fileCount != expected.FileCount || totalSize != expected.TotalSize {
return fmt.Errorf(
"pages extracted metadata mismatch: got %d files/%d bytes, expected %d files/%d bytes",
fileCount,
totalSize,
expected.FileCount,
expected.TotalSize,
)
}
return nil
}
func promotePagesRelease(stagingDir string, releaseDir string, project pagesProjectRef) error {
return promotePagesReleaseWithCopy(stagingDir, releaseDir, project, copyPagesDir)
}
func promotePagesReleaseWithCopy(
stagingDir string,
releaseDir string,
project pagesProjectRef,
copyDir func(string, string) error,
) error {
currentDir := pagesCurrentDirFromRelease(releaseDir)
defer removePagesStagingUnlessCurrent(stagingDir, currentDir)
currentUsesRelease, err := pagesCurrentTargetsRelease(currentDir, releaseDir)
if err != nil {
return err
}
if !currentUsesRelease {
if err := os.RemoveAll(releaseDir); err != nil {
return err
}
return os.Rename(stagingDir, releaseDir)
}
// A same-hash repair cannot remove releaseDir while current still resolves
// through it. Keep traffic on the fully validated staging tree, rebuild the
// canonical release, then atomically point current back to the canonical path.
if err := switchPagesCurrentDir(currentDir, stagingDir, os.Rename); err != nil {
return fmt.Errorf("switch Pages current to repair staging: %w", err)
}
backupDir := stagingDir + ".previous"
if err := os.Rename(releaseDir, backupDir); err != nil {
restoreErr := switchPagesCurrentDir(currentDir, releaseDir, os.Rename)
return errors.Join(
fmt.Errorf("move previous Pages release aside: %w", err),
restoreErr,
)
}
rollback := func(cause error) error {
var rollbackErrors []error
rollbackErrors = append(rollbackErrors, cause)
if err := os.RemoveAll(releaseDir); err != nil {
rollbackErrors = append(rollbackErrors, fmt.Errorf("remove failed Pages release repair: %w", err))
}
if err := os.Rename(backupDir, releaseDir); err != nil {
rollbackErrors = append(rollbackErrors, fmt.Errorf("restore previous Pages release: %w", err))
return errors.Join(rollbackErrors...)
}
if err := switchPagesCurrentDir(currentDir, releaseDir, os.Rename); err != nil {
rollbackErrors = append(rollbackErrors, fmt.Errorf("restore previous Pages current target: %w", err))
}
return errors.Join(rollbackErrors...)
}
if err := copyDir(stagingDir, releaseDir); err != nil {
return rollback(fmt.Errorf("copy repaired Pages release: %w", err))
}
if !pagesProjectReleaseReady(releaseDir, project) {
return rollback(errors.New("repaired Pages release is not ready"))
}
if err := switchPagesCurrentDir(currentDir, releaseDir, os.Rename); err != nil {
return rollback(fmt.Errorf("switch Pages current to repaired release: %w", err))
}
if err := os.RemoveAll(backupDir); err != nil {
slog.Warn("failed to remove previous Pages release", "path", backupDir, "error", err)
}
if err := os.RemoveAll(stagingDir); err != nil {
slog.Warn("failed to remove Pages repair staging", "path", stagingDir, "error", err)
}
return nil
}
func pagesCurrentDirFromRelease(releaseDir string) string {
return filepath.Join(filepath.Dir(filepath.Dir(releaseDir)), "current")
}
func pagesCurrentTargetsRelease(currentDir string, releaseDir string) (bool, error) {
if _, err := os.Lstat(currentDir); err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
currentInfo, err := os.Stat(currentDir)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, fmt.Errorf("stat Pages current target: %w", err)
}
releaseInfo, err := os.Stat(releaseDir)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
return os.SameFile(currentInfo, releaseInfo), nil
}
func removePagesStagingUnlessCurrent(stagingDir string, currentDir string) {
currentUsesStaging, err := pagesCurrentTargetsRelease(currentDir, stagingDir)
if err == nil && currentUsesStaging {
slog.Error("preserving Pages staging because current still references it", "path", stagingDir)
return
}
if removeErr := os.RemoveAll(stagingDir); removeErr != nil {
slog.Warn("failed to remove Pages staging", "path", stagingDir, "error", removeErr)
}
}
func verifyPagesCurrentTarget(currentDir string, releaseDir string) error {
currentInfo, err := os.Stat(currentDir)
if err != nil {
return fmt.Errorf("stat Pages current target: %w", err)
}
releaseInfo, err := os.Stat(releaseDir)
if err != nil {
return fmt.Errorf("stat Pages release target: %w", err)
}
if !os.SameFile(currentInfo, releaseInfo) {
return fmt.Errorf("pages current target does not resolve to release %s", releaseDir)
}
return nil
}
func switchPagesCurrentDir(
currentDir string,
releaseDir string,
rename func(string, string) error,
) error {
return switchPagesCurrentDirWithOps(currentDir, releaseDir, rename, os.Symlink)
}
func switchPagesCurrentDirWithOps(
currentDir string,
releaseDir string,
rename func(string, string) error,
symlink func(string, string) error,
) error {
if err := os.MkdirAll(filepath.Dir(currentDir), pagesDirPerm); err != nil {
return err
}
currentInfo, currentErr := os.Lstat(currentDir)
if currentErr != nil && !os.IsNotExist(currentErr) {
return currentErr
}
if currentErr == nil && currentInfo.Mode()&os.ModeSymlink == 0 {
return fallbackCopyPagesCurrentDir(currentDir, releaseDir, rename)
}
previousTarget := ""
hadPrevious := currentErr == nil
if hadPrevious {
var err error
previousTarget, err = os.Readlink(currentDir)
if err != nil {
return err
}
}
relTarget, err := filepath.Rel(filepath.Dir(currentDir), releaseDir)
if err != nil {
@@ -413,44 +804,123 @@ func switchPagesProjectCurrentDir(baseDir string, projectID uint, releaseDir str
}
tmpSymlink := currentDir + ".tmp"
_ = os.Remove(tmpSymlink)
symlinkErr := os.Symlink(relTarget, tmpSymlink)
if symlinkErr != nil {
return fallbackCopyPagesCurrentDir(currentDir, previousDir, releaseDir)
}
_ = os.Remove(tmpSymlink)
if _, err := os.Lstat(currentDir); err == nil {
if err := os.Rename(currentDir, previousDir); err != nil {
return err
}
}
if err := os.Symlink(relTarget, currentDir); err != nil {
if _, restoreErr := os.Lstat(previousDir); restoreErr == nil {
_ = os.Rename(previousDir, currentDir)
}
if err := os.Remove(tmpSymlink); err != nil && !os.IsNotExist(err) {
return err
}
_ = os.RemoveAll(previousDir)
if err := symlink(relTarget, tmpSymlink); err != nil {
_ = os.Remove(tmpSymlink)
return fallbackCopyPagesCurrentDir(currentDir, releaseDir, rename)
}
defer func() { _ = os.Remove(tmpSymlink) }()
if err := verifyPagesCurrentTarget(tmpSymlink, releaseDir); err != nil {
return err
}
if err := rename(tmpSymlink, currentDir); err != nil {
return err
}
if err := verifyPagesCurrentTarget(currentDir, releaseDir); err != nil {
rollbackErr := rollbackPagesCurrentSymlink(
currentDir,
previousTarget,
hadPrevious,
rename,
)
return errors.Join(err, rollbackErr)
}
return nil
}
func fallbackCopyPagesCurrentDir(currentDir, previousDir, releaseDir string) error {
if _, err := os.Lstat(currentDir); err == nil {
if err := os.Rename(currentDir, previousDir); err != nil {
return err
}
}
if err := copyPagesDir(releaseDir, currentDir); err != nil {
_ = os.RemoveAll(currentDir)
if _, restoreErr := os.Lstat(previousDir); restoreErr == nil {
_ = os.Rename(previousDir, currentDir)
}
func fallbackCopyPagesCurrentDir(
currentDir string,
releaseDir string,
rename func(string, string) error,
) error {
stagingDir := currentDir + ".copy.tmp"
previousDir := currentDir + ".previous"
if err := os.RemoveAll(stagingDir); err != nil {
return err
}
_ = os.RemoveAll(previousDir)
if err := copyPagesDir(releaseDir, stagingDir); err != nil {
_ = os.RemoveAll(stagingDir)
return err
}
if err := os.RemoveAll(previousDir); err != nil {
_ = os.RemoveAll(stagingDir)
return err
}
hadPrevious := false
if _, err := os.Lstat(currentDir); err == nil {
if err := rename(currentDir, previousDir); err != nil {
_ = os.RemoveAll(stagingDir)
return err
}
hadPrevious = true
} else if !os.IsNotExist(err) {
_ = os.RemoveAll(stagingDir)
return err
}
if err := rename(stagingDir, currentDir); err != nil {
var restoreErr error
if hadPrevious {
restoreErr = rename(previousDir, currentDir)
}
_ = os.RemoveAll(stagingDir)
return errors.Join(err, restoreErr)
}
if err := os.RemoveAll(previousDir); err != nil {
slog.Warn("failed to remove previous Pages current directory", "path", previousDir, "error", err)
}
return nil
}
func switchPagesProjectCurrentDir(baseDir string, projectID uint, releaseDir string) error {
return switchPagesProjectCurrentDirWithRename(baseDir, projectID, releaseDir, os.Rename)
}
func switchPagesProjectCurrentDirWithRename(
baseDir string,
projectID uint,
releaseDir string,
rename func(string, string) error,
) error {
return switchPagesCurrentDir(pagesProjectCurrentDir(baseDir, projectID), releaseDir, rename)
}
func rollbackPagesCurrentSymlink(
currentDir string,
previousTarget string,
hadPrevious bool,
rename func(string, string) error,
) error {
if !hadPrevious {
if err := os.Remove(currentDir); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove unverified Pages current symlink: %w", err)
}
return nil
}
rollbackSymlink := currentDir + ".rollback.tmp"
if err := os.Remove(rollbackSymlink); err != nil && !os.IsNotExist(err) {
return err
}
if err := os.Symlink(previousTarget, rollbackSymlink); err != nil {
return err
}
defer func() { _ = os.Remove(rollbackSymlink) }()
if err := rename(rollbackSymlink, currentDir); err != nil {
return fmt.Errorf("restore previous Pages current symlink: %w", err)
}
gotTarget, err := os.Readlink(currentDir)
if err != nil {
return fmt.Errorf("verify restored Pages current symlink: %w", err)
}
if gotTarget != previousTarget {
return fmt.Errorf(
"restored Pages current symlink target %q does not match %q",
gotTarget,
previousTarget,
)
}
return nil
}
@@ -467,24 +937,30 @@ func copyPagesDir(sourceDir string, targetDir string) error {
if entry.IsDir() {
return os.MkdirAll(targetPath, pagesDirPerm)
}
input, err := os.Open(sourcePath) //nolint:gosec // sourcePath is under managed PagesDir walk root
if err != nil {
return err
}
defer func() { _ = input.Close() }()
if err := os.MkdirAll(filepath.Dir(targetPath), pagesDirPerm); err != nil {
return err
}
output, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, pagesFilePerm) //nolint:gosec // targetPath is under managed PagesDir walk root
if err != nil {
return err
}
defer func() { _ = output.Close() }()
_, err = io.Copy(output, input)
return err
return copyPagesFile(sourcePath, targetPath)
})
}
func copyPagesFile(sourcePath string, targetPath string) error {
input, err := os.Open(sourcePath) //nolint:gosec // sourcePath is under managed PagesDir walk root
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(targetPath), pagesDirPerm); err != nil {
_ = input.Close()
return err
}
output, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, pagesFilePerm) //nolint:gosec // targetPath is under managed PagesDir walk root
if err != nil {
_ = input.Close()
return err
}
_, copyErr := io.Copy(output, input)
outputCloseErr := output.Close()
inputCloseErr := input.Close()
return errors.Join(copyErr, outputCloseErr, inputCloseErr)
}
func markerMatches(dir string, project pagesProjectRef) bool {
data, err := os.ReadFile(filepath.Join(dir, ".openflare-pages.json")) //nolint:gosec // dir is managed PagesDir
if err != nil {
@@ -515,8 +991,3 @@ func pagesProjectCurrentDir(baseDir string, projectID uint) string {
func pagesProjectReleaseDir(baseDir string, projectID uint, checksum string) string {
return filepath.Join(baseDir, "projects", fmt.Sprintf("%d", projectID), "releases", checksum)
}
func checksumBytes(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
@@ -0,0 +1,388 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package sync
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Rain-kl/Wavelet/internal/apps/agent/protocol"
"github.com/Rain-kl/Wavelet/internal/apps/agent/state"
)
func TestEnsurePagesProjectRejectsMetadataBeyondAgentCapsBeforeDownload(t *testing.T) {
packageBytes := testPagesPackage(t, map[string]string{"index.html": "x"})
base := protocol.PagesProjectLatestHashResponse{
ProjectID: 1,
DeploymentID: 1,
Hash: testBytesChecksum(packageBytes),
PackageSize: int64(len(packageBytes)),
FileCount: 1,
TotalSize: 1,
}
tests := []struct {
name string
mutate func(*protocol.PagesProjectLatestHashResponse)
}{
{
name: "package size",
mutate: func(metadata *protocol.PagesProjectLatestHashResponse) {
metadata.PackageSize = agentPagesMaxPackageBytes + 1
},
},
{
name: "file count",
mutate: func(metadata *protocol.PagesProjectLatestHashResponse) {
metadata.FileCount = agentPagesMaxFiles + 1
},
},
{
name: "total size",
mutate: func(metadata *protocol.PagesProjectLatestHashResponse) {
metadata.TotalSize = agentPagesMaxTotalBytes + 1
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
metadata := base
test.mutate(&metadata)
client := &fakeClient{
pagesPackages: map[uint][]byte{1: packageBytes},
pagesMetadata: map[uint]protocol.PagesProjectLatestHashResponse{1: metadata},
}
service := New(client, &fakeManager{}, nil)
service.SetPagesDir(t.TempDir())
err := service.ensurePagesProject(context.Background(), &state.Snapshot{}, 1)
if err == nil || !strings.Contains(err.Error(), "agent limit") {
t.Fatalf("ensurePagesProject(%s metadata) error = %v, want agent limit error", test.name, err)
}
if client.pagesPackageDownloads != 0 {
t.Errorf("ensurePagesProject(%s metadata) downloads = %d, want 0", test.name, client.pagesPackageDownloads)
}
})
}
}
func TestEnsurePagesProjectRetriesSameHashDifferentDeployment(t *testing.T) {
packageBytes := testPagesPackage(t, map[string]string{"index.html": "same"})
hash := testBytesChecksum(packageBytes)
client := &racingLatestClient{
pkgA: packageBytes,
pkgB: packageBytes,
hashA: hash,
hashB: hash,
}
service := New(client, &fakeManager{}, nil)
pagesDir := t.TempDir()
service.SetPagesDir(pagesDir)
snapshot := &state.Snapshot{PagesDeployments: []state.PagesDeployment{{ProjectID: 42}}}
if err := service.ensurePagesProject(context.Background(), snapshot, 42); err != nil {
t.Fatalf("ensurePagesProject(same hash deployment race) error = %v", err)
}
if client.downloadCalls != 2 {
t.Errorf("ensurePagesProject(same hash deployment race) downloads = %d, want 2", client.downloadCalls)
}
if snapshot.PagesDeployments[0].DeploymentID != 2 || snapshot.PagesDeployments[0].Hash != hash {
t.Errorf("snapshot Pages deployment = %+v, want deployment 2/hash %s", snapshot.PagesDeployments[0], hash)
}
}
func TestEnsurePagesProjectExtractionFailureCleansTempAndPreservesCurrent(t *testing.T) {
projectID := uint(9)
oldPackage := testPagesPackage(t, map[string]string{"index.html": "old"})
oldHash := testBytesChecksum(oldPackage)
newPackage := testPagesPackage(t, map[string]string{"index.html": "new"})
newHash := testBytesChecksum(newPackage)
pagesDir := t.TempDir()
oldRelease := pagesProjectReleaseDir(pagesDir, projectID, oldHash)
if err := extractTestPagesPackage(t, oldPackage, oldRelease, pagesProjectRef{
ProjectID: projectID,
DeploymentID: 1,
Checksum: oldHash,
}); err != nil {
t.Fatalf("extractTestPagesPackage(old) error = %v", err)
}
if err := switchPagesProjectCurrentDir(pagesDir, projectID, oldRelease); err != nil {
t.Fatalf("switchPagesProjectCurrentDir(old) error = %v", err)
}
client := &fakeClient{
pagesPackages: map[uint][]byte{projectID: newPackage},
pagesMetadata: map[uint]protocol.PagesProjectLatestHashResponse{
projectID: {
ProjectID: projectID,
DeploymentID: 2,
Hash: newHash,
PackageSize: int64(len(newPackage)),
FileCount: 1,
TotalSize: 2, // Smaller than the actual three-byte file.
},
},
}
service := New(client, &fakeManager{}, nil)
service.SetPagesDir(pagesDir)
err := service.ensurePagesProject(context.Background(), &state.Snapshot{}, projectID)
if err == nil {
t.Fatal("ensurePagesProject(metadata-tightened extraction) error = nil, want error")
}
current, readErr := os.ReadFile(pagesProjectCurrentDir(pagesDir, projectID) + "/index.html")
if readErr != nil {
t.Fatalf("read old current after failed extraction error = %v", readErr)
}
if string(current) != "old" {
t.Errorf("current content after failed extraction = %q, want %q", current, "old")
}
entries, readErr := os.ReadDir(filepath.Join(pagesDir, "projects", "9", "releases"))
if readErr != nil {
t.Fatalf("read releases after failed extraction error = %v", readErr)
}
if len(entries) != 1 || entries[0].Name() != oldHash {
t.Errorf("releases after failed extraction = %v, want only %s", entries, oldHash)
}
}
func TestEnsurePagesProjectAcceptsAllZeroByteFiles(t *testing.T) {
packageBytes := testPagesPackage(t, map[string]string{
"index.html": "",
".gitkeep": "",
})
client := &fakeClient{pagesPackages: map[uint][]byte{5: packageBytes}}
service := New(client, &fakeManager{}, nil)
pagesDir := t.TempDir()
service.SetPagesDir(pagesDir)
if err := service.ensurePagesProject(context.Background(), &state.Snapshot{}, 5); err != nil {
t.Fatalf("ensurePagesProject(all-zero files) error = %v", err)
}
for _, name := range []string{"index.html", ".gitkeep"} {
info, err := os.Stat(filepath.Join(pagesProjectCurrentDir(pagesDir, 5), name))
if err != nil {
t.Errorf("stat all-zero file %q error = %v", name, err)
continue
}
if info.Size() != 0 {
t.Errorf("all-zero file %q size = %d, want 0", name, info.Size())
}
}
}
func TestSwitchPagesProjectCurrentDirRenameFailureKeepsPreviousCurrent(t *testing.T) {
pagesDir := t.TempDir()
projectID := uint(21)
oldRelease := pagesProjectReleaseDir(pagesDir, projectID, "old")
newRelease := pagesProjectReleaseDir(pagesDir, projectID, "new")
for path, content := range map[string]string{
oldRelease: "old",
newRelease: "new",
} {
if err := os.MkdirAll(path, pagesDirPerm); err != nil {
t.Fatalf("mkdir release %q error = %v", path, err)
}
if err := os.WriteFile(filepath.Join(path, "index.html"), []byte(content), pagesFilePerm); err != nil {
t.Fatalf("write release %q error = %v", path, err)
}
}
if err := switchPagesProjectCurrentDir(pagesDir, projectID, oldRelease); err != nil {
t.Fatalf("seed previous current error = %v", err)
}
currentDir := pagesProjectCurrentDir(pagesDir, projectID)
renameErr := errors.New("injected current rename failure")
err := switchPagesProjectCurrentDirWithRename(
pagesDir,
projectID,
newRelease,
func(oldPath string, newPath string) error {
if oldPath == currentDir+".tmp" && newPath == currentDir {
return renameErr
}
return os.Rename(oldPath, newPath)
},
)
if !errors.Is(err, renameErr) {
t.Fatalf("switchPagesProjectCurrentDirWithRename() error = %v, want injected rename error", err)
}
current, err := os.ReadFile(filepath.Join(currentDir, "index.html"))
if err != nil {
t.Fatalf("read previous current after rename failure error = %v", err)
}
if string(current) != "old" {
t.Errorf("current after rename failure = %q, want %q", current, "old")
}
if _, err := os.Lstat(currentDir + ".tmp"); !os.IsNotExist(err) {
t.Errorf("temporary current symlink remains after rename failure: %v", err)
}
}
func TestPromoteSameHashReleaseFailureRestoresPreviousCurrent(t *testing.T) {
pagesDir := t.TempDir()
projectID := uint(22)
hash := "same-hash"
project := pagesProjectRef{ProjectID: projectID, DeploymentID: 2, Checksum: hash}
releaseDir := pagesProjectReleaseDir(pagesDir, projectID, hash)
if err := os.MkdirAll(releaseDir, pagesDirPerm); err != nil {
t.Fatalf("mkdir previous same-hash release error = %v", err)
}
if err := os.WriteFile(filepath.Join(releaseDir, "index.html"), []byte("old"), pagesFilePerm); err != nil {
t.Fatalf("write previous same-hash release error = %v", err)
}
if err := writePagesMarker(releaseDir, project); err != nil {
t.Fatalf("write previous same-hash marker error = %v", err)
}
if err := switchPagesProjectCurrentDir(pagesDir, projectID, releaseDir); err != nil {
t.Fatalf("seed same-hash current error = %v", err)
}
stagingDir, err := os.MkdirTemp(filepath.Dir(releaseDir), ".same-hash-*.tmp")
if err != nil {
t.Fatalf("create same-hash staging error = %v", err)
}
if err := os.WriteFile(filepath.Join(stagingDir, "index.html"), []byte("new"), pagesFilePerm); err != nil {
t.Fatalf("write repaired same-hash release error = %v", err)
}
if err := writePagesMarker(stagingDir, project); err != nil {
t.Fatalf("write repaired same-hash marker error = %v", err)
}
copyErr := errors.New("injected same-hash copy failure")
err = promotePagesReleaseWithCopy(
stagingDir,
releaseDir,
project,
func(_ string, targetDir string) error {
if err := os.MkdirAll(targetDir, pagesDirPerm); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(targetDir, "index.html"), []byte("partial"), pagesFilePerm); err != nil {
return err
}
return copyErr
},
)
if !errors.Is(err, copyErr) {
t.Fatalf("promotePagesReleaseWithCopy() error = %v, want injected copy error", err)
}
for name, path := range map[string]string{
"current": filepath.Join(pagesProjectCurrentDir(pagesDir, projectID), "index.html"),
"release": filepath.Join(releaseDir, "index.html"),
} {
content, readErr := os.ReadFile(path)
if readErr != nil {
t.Fatalf("read restored %s after same-hash repair failure error = %v", name, readErr)
}
if string(content) != "old" {
t.Errorf("restored %s after same-hash repair failure = %q, want %q", name, content, "old")
}
}
if _, err := os.Stat(stagingDir); !os.IsNotExist(err) {
t.Errorf("same-hash staging remains after successful rollback: %v", err)
}
}
func TestPromotePagesReleaseRepairsDanglingCurrent(t *testing.T) {
pagesDir := t.TempDir()
projectID := uint(23)
releaseDir := pagesProjectReleaseDir(pagesDir, projectID, "new-hash")
currentDir := pagesProjectCurrentDir(pagesDir, projectID)
requireTestMkdirAll(t, filepath.Dir(currentDir))
relTarget, err := filepath.Rel(filepath.Dir(currentDir), releaseDir)
if err != nil {
t.Fatalf("relative release target error = %v", err)
}
if err := os.Symlink(relTarget, currentDir); err != nil {
t.Skipf("symlink unsupported: %v", err)
}
requireTestMkdirAll(t, filepath.Dir(releaseDir))
stagingDir, err := os.MkdirTemp(filepath.Dir(releaseDir), ".dangling-*.tmp")
if err != nil {
t.Fatalf("create dangling repair staging error = %v", err)
}
if err := os.WriteFile(filepath.Join(stagingDir, "index.html"), []byte("repaired"), pagesFilePerm); err != nil {
t.Fatalf("write dangling repair staging error = %v", err)
}
project := pagesProjectRef{ProjectID: projectID, DeploymentID: 1, Checksum: "new-hash"}
if err := writePagesMarker(stagingDir, project); err != nil {
t.Fatalf("write dangling repair marker error = %v", err)
}
if err := promotePagesRelease(stagingDir, releaseDir, project); err != nil {
t.Fatalf("promotePagesRelease(dangling current) error = %v", err)
}
content, err := os.ReadFile(filepath.Join(currentDir, "index.html"))
if err != nil {
t.Fatalf("read repaired dangling current error = %v", err)
}
if string(content) != "repaired" {
t.Errorf("repaired dangling current = %q, want %q", content, "repaired")
}
}
func TestSwitchPagesCurrentDirCopiesOverLegacyDirectory(t *testing.T) {
pagesDir := t.TempDir()
currentDir := filepath.Join(pagesDir, "current")
releaseDir := filepath.Join(pagesDir, "releases", "new")
requireTestMkdirAll(t, currentDir)
requireTestMkdirAll(t, releaseDir)
if err := os.WriteFile(filepath.Join(currentDir, "index.html"), []byte("old"), pagesFilePerm); err != nil {
t.Fatalf("write legacy current error = %v", err)
}
if err := os.WriteFile(filepath.Join(releaseDir, "index.html"), []byte("new"), pagesFilePerm); err != nil {
t.Fatalf("write new release error = %v", err)
}
if err := switchPagesCurrentDir(currentDir, releaseDir, os.Rename); err != nil {
t.Fatalf("switchPagesCurrentDir(legacy directory) error = %v", err)
}
content, err := os.ReadFile(filepath.Join(currentDir, "index.html"))
if err != nil {
t.Fatalf("read copied legacy current error = %v", err)
}
if string(content) != "new" {
t.Errorf("copied legacy current = %q, want %q", content, "new")
}
}
func TestSwitchPagesCurrentDirFallsBackWhenSymlinkUnavailable(t *testing.T) {
pagesDir := t.TempDir()
currentDir := filepath.Join(pagesDir, "current")
releaseDir := filepath.Join(pagesDir, "releases", "new")
requireTestMkdirAll(t, releaseDir)
if err := os.WriteFile(filepath.Join(releaseDir, "index.html"), []byte("new"), pagesFilePerm); err != nil {
t.Fatalf("write fallback release error = %v", err)
}
symlinkErr := errors.New("injected symlink unavailable")
if err := switchPagesCurrentDirWithOps(
currentDir,
releaseDir,
os.Rename,
func(string, string) error { return symlinkErr },
); err != nil {
t.Fatalf("switchPagesCurrentDirWithOps(symlink unavailable) error = %v", err)
}
info, err := os.Lstat(currentDir)
if err != nil {
t.Fatalf("lstat copied current error = %v", err)
}
if !info.IsDir() {
t.Errorf("copied current mode = %v, want directory", info.Mode())
}
content, err := os.ReadFile(filepath.Join(currentDir, "index.html"))
if err != nil {
t.Fatalf("read fallback current error = %v", err)
}
if string(content) != "new" {
t.Errorf("fallback current = %q, want %q", content, "new")
}
}
func requireTestMkdirAll(t *testing.T, dir string) {
t.Helper()
if err := os.MkdirAll(dir, pagesDirPerm); err != nil {
t.Fatalf("mkdir %q error = %v", dir, err)
}
}
+6 -2
View File
@@ -1,3 +1,6 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package sync
import (
@@ -7,6 +10,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"sort"
"strconv"
@@ -31,9 +35,9 @@ const (
type ConfigClient interface {
GetActiveConfig(ctx context.Context) (*protocol.ActiveConfigResponse, error)
GetPagesDeploymentHash(ctx context.Context, deploymentID uint) (string, error)
DownloadPagesDeploymentPackage(ctx context.Context, deploymentID uint) ([]byte, error)
DownloadPagesDeploymentPackage(ctx context.Context, deploymentID uint, dst io.Writer, maxBytes int64) (int64, error)
GetPagesProjectLatestHash(ctx context.Context, projectID uint) (*protocol.PagesProjectLatestHashResponse, error)
DownloadPagesProjectLatestPackage(ctx context.Context, projectID uint) ([]byte, error)
DownloadPagesProjectLatestPackage(ctx context.Context, projectID uint, dst io.Writer, maxBytes int64) (int64, error)
ReportApplyLog(ctx context.Context, payload protocol.ApplyLogPayload) error
SyncWAFIPGroups(ctx context.Context, payload protocol.WAFIPGroupSyncRequest) (*protocol.WAFIPGroupSyncResponse, error)
}
+128 -18
View File
@@ -1,3 +1,6 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package sync
import (
@@ -7,6 +10,7 @@ import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"strings"
@@ -16,6 +20,7 @@ import (
"github.com/Rain-kl/Wavelet/internal/apps/agent/nginx"
"github.com/Rain-kl/Wavelet/internal/apps/agent/protocol"
"github.com/Rain-kl/Wavelet/internal/apps/agent/state"
"github.com/Rain-kl/Wavelet/pkg/pagesarchive"
)
type fakeExecutor struct {
@@ -28,15 +33,17 @@ func testPagesSourceConfigJSON(projectID, deploymentID uint, checksum string) st
}
type fakeClient struct {
config protocol.ActiveConfigResponse
reports []protocol.ApplyLogPayload
wafSyncCalls []protocol.WAFIPGroupSyncRequest
pagesPackages map[uint][]byte // key: project_id (latest package)
pagesHashes map[uint]string // key: project_id
pagesLatestDeployIDs map[uint]uint // key: project_id → deployment_id
wafSyncResult protocol.WAFIPGroupSyncResponse
fetchCalls int
hashCalls int
config protocol.ActiveConfigResponse
reports []protocol.ApplyLogPayload
wafSyncCalls []protocol.WAFIPGroupSyncRequest
pagesPackages map[uint][]byte // key: project_id (latest package)
pagesHashes map[uint]string // key: project_id
pagesLatestDeployIDs map[uint]uint // key: project_id → deployment_id
pagesMetadata map[uint]protocol.PagesProjectLatestHashResponse
pagesPackageDownloads int
wafSyncResult protocol.WAFIPGroupSyncResponse
fetchCalls int
hashCalls int
}
type fakeManager struct {
@@ -98,17 +105,28 @@ func (f *fakeClient) GetPagesDeploymentHash(ctx context.Context, deploymentID ui
return f.projectHash(deploymentID)
}
func (f *fakeClient) DownloadPagesDeploymentPackage(ctx context.Context, deploymentID uint) ([]byte, error) {
func (f *fakeClient) DownloadPagesDeploymentPackage(
ctx context.Context,
deploymentID uint,
dst io.Writer,
maxBytes int64,
) (int64, error) {
for projectID, depID := range f.pagesLatestDeployIDs {
if depID == deploymentID {
return f.projectPackage(projectID)
return f.writeProjectPackage(projectID, dst, maxBytes)
}
}
return f.projectPackage(deploymentID)
return f.writeProjectPackage(deploymentID, dst, maxBytes)
}
func (f *fakeClient) GetPagesProjectLatestHash(ctx context.Context, projectID uint) (*protocol.PagesProjectLatestHashResponse, error) {
f.hashCalls++
if f.pagesMetadata != nil {
if metadata, ok := f.pagesMetadata[projectID]; ok {
result := metadata
return &result, nil
}
}
hash, err := f.projectHash(projectID)
if err != nil {
return nil, err
@@ -119,15 +137,32 @@ func (f *fakeClient) GetPagesProjectLatestHash(ctx context.Context, projectID ui
deploymentID = id
}
}
packageBytes, err := f.projectPackage(projectID)
if err != nil {
return nil, err
}
fileCount, totalSize, err := testPagesPackageStats(packageBytes)
if err != nil {
return nil, err
}
return &protocol.PagesProjectLatestHashResponse{
ProjectID: projectID,
DeploymentID: deploymentID,
Hash: hash,
PackageSize: int64(len(packageBytes)),
FileCount: fileCount,
TotalSize: totalSize,
}, nil
}
func (f *fakeClient) DownloadPagesProjectLatestPackage(ctx context.Context, projectID uint) ([]byte, error) {
return f.projectPackage(projectID)
func (f *fakeClient) DownloadPagesProjectLatestPackage(
ctx context.Context,
projectID uint,
dst io.Writer,
maxBytes int64,
) (int64, error) {
f.pagesPackageDownloads++
return f.writeProjectPackage(projectID, dst, maxBytes)
}
func (f *fakeClient) projectHash(projectID uint) (string, error) {
@@ -155,6 +190,22 @@ func (f *fakeClient) projectPackage(projectID uint) ([]byte, error) {
return packageBytes, nil
}
func (f *fakeClient) writeProjectPackage(projectID uint, dst io.Writer, maxBytes int64) (int64, error) {
packageBytes, err := f.projectPackage(projectID)
if err != nil {
return 0, err
}
limited := &io.LimitedReader{R: bytes.NewReader(packageBytes), N: maxBytes + 1}
written, err := io.Copy(dst, limited)
if err != nil {
return written, err
}
if written > maxBytes {
return written, fmt.Errorf("test pages package exceeds limit %d", maxBytes)
}
return written, nil
}
func (f *fakeClient) ReportApplyLog(ctx context.Context, payload protocol.ApplyLogPayload) error {
f.reports = append(f.reports, payload)
return nil
@@ -572,7 +623,9 @@ func TestSyncOnceRejectsPagesZipSlipBeforeApply(t *testing.T) {
service.SetPagesDir(t.TempDir())
err := service.SyncOnce(context.Background(), &protocol.ActiveConfigMeta{Version: "20260309-102", Checksum: "pages-config-checksum"})
if err == nil || (!strings.Contains(err.Error(), "escapes deployment root") && !strings.Contains(err.Error(), "escapes directory")) {
if err == nil || (!strings.Contains(err.Error(), "escapes deployment root") &&
!strings.Contains(err.Error(), "escapes directory") &&
!strings.Contains(err.Error(), "dot segment")) {
t.Fatalf("expected zip-slip rejection, got %v", err)
}
if len(manager.applyRouteContents) != 0 {
@@ -1316,7 +1369,7 @@ func TestSyncOnceRedownloadsPagesDeploymentWhenServerHashChanges(t *testing.T) {
}
pagesDir := t.TempDir()
releaseDir := pagesProjectReleaseDir(pagesDir, projectID, initialHash)
if err = extractPagesPackage(initialPackage, releaseDir, pagesProjectRef{
if err = extractTestPagesPackage(t, initialPackage, releaseDir, pagesProjectRef{
ProjectID: projectID,
Checksum: initialHash,
}); err != nil {
@@ -1385,20 +1438,42 @@ func (r *racingLatestClient) GetPagesProjectLatestHash(ctx context.Context, proj
// 2: verify after downloading B → B (race)
// 3+: stable on B for retry
hash, dep := r.hashA, uint(1)
packageBytes := r.pkgA
if r.hashCall >= 2 {
hash, dep = r.hashB, 2
packageBytes = r.pkgB
}
fileCount, totalSize, err := testPagesPackageStats(packageBytes)
if err != nil {
return nil, err
}
return &protocol.PagesProjectLatestHashResponse{
ProjectID: projectID,
DeploymentID: dep,
Hash: hash,
PackageSize: int64(len(packageBytes)),
FileCount: fileCount,
TotalSize: totalSize,
}, nil
}
func (r *racingLatestClient) DownloadPagesProjectLatestPackage(ctx context.Context, projectID uint) ([]byte, error) {
func (r *racingLatestClient) DownloadPagesProjectLatestPackage(
ctx context.Context,
projectID uint,
dst io.Writer,
maxBytes int64,
) (int64, error) {
r.downloadCalls++
// Always return package B (what "latest download" would stream mid-race / after).
return r.pkgB, nil
limited := &io.LimitedReader{R: bytes.NewReader(r.pkgB), N: maxBytes + 1}
written, err := io.Copy(dst, limited)
if err != nil {
return written, err
}
if written > maxBytes {
return written, fmt.Errorf("test pages package exceeds limit %d", maxBytes)
}
return written, nil
}
func TestEnsurePagesProjectSurvivesHashPackageRace(t *testing.T) {
@@ -1641,6 +1716,41 @@ func testPagesPackage(t *testing.T, files map[string]string) []byte {
return buffer.Bytes()
}
func extractTestPagesPackage(
t *testing.T,
packageBytes []byte,
releaseDir string,
project pagesProjectRef,
) error {
t.Helper()
packagePath := filepath.Join(t.TempDir(), "pages-package.zip")
if err := os.WriteFile(packagePath, packageBytes, pagesFilePerm); err != nil {
t.Fatalf("write test Pages package error = %v", err)
}
return extractPagesPackageFile(packagePath, releaseDir, project, pagesarchive.Limits{
MaxFiles: agentPagesMaxFiles,
MaxFileBytes: agentPagesMaxFileBytes,
MaxTotalBytes: agentPagesMaxTotalBytes,
}, nil)
}
func testPagesPackageStats(packageBytes []byte) (int, int64, error) {
reader, err := zip.NewReader(bytes.NewReader(packageBytes), int64(len(packageBytes)))
if err != nil {
return 0, 0, err
}
fileCount := 0
totalSize := int64(0)
for _, file := range reader.File {
if file.FileInfo().IsDir() {
continue
}
fileCount++
totalSize += int64(file.UncompressedSize64) //nolint:gosec // test packages are memory-bounded
}
return fileCount, totalSize, nil
}
func testBytesChecksum(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])