Files
2026-07-20 04:51:20 +00:00

158 lines
7.6 KiB
Go

//go:build linux
package scenario
import (
"context"
"errors"
"fmt"
"os"
"strings"
"time"
"github.com/nezhahq/nezha/integration/agentcompat/internal/client"
"github.com/nezhahq/nezha/integration/agentcompat/internal/contract"
"github.com/nezhahq/nezha/integration/agentcompat/internal/fixture"
)
type transferExecution struct {
client *client.Client
serverID uint64
root fixture.AgentRoot
residueScope transferResidueScope
sentinels transferSentinels
}
func (execution transferExecution) run(ctx context.Context, assertions *AssertionSet, fault contract.Fault) (TransferEvidence, error) {
payload, err := fixture.NewPayload(contract.DefaultSeed, contract.TransferBytes)
if err != nil {
return TransferEvidence{}, err
}
stableDigest, err := fixture.VerifyPayload(payload.Reader(), contract.TransferBytes)
if err != nil {
return TransferEvidence{}, err
}
warmupEvidence, err := execution.runWarmup(ctx)
if err != nil {
return TransferEvidence{}, fmt.Errorf("transfer warm-up: %w", err)
}
quiescenceDeadline, err := confirmTransferQuiescence(ctx, execution.residueScope)
if err != nil {
return TransferEvidence{}, err
}
warmupEvidence.deadline = quiescenceDeadline
assertions.Record("small real upload and download warm-up precedes event and deadline quiescence", warmupEvidence.valid(), fmt.Sprintf("completion_event=download_response upload_bytes=%d download_bytes=%d sha256=%s duration=%s deadline_remaining=%s", warmupEvidence.uploadBytes, warmupEvidence.downloadBytes, warmupEvidence.sha256, warmupEvidence.duration, warmupEvidence.deadline))
uploadPath, err := execution.root.Path("measured/nested/upload.bin")
if err != nil {
return TransferEvidence{}, err
}
heapProbe := fixture.NewRetainedHeapProbe()
uploadEvidence, uploadURL, uploadErr := execution.upload(ctx, uploadPath, payload, stableDigest, fault)
if fault.String() == "transfer-hash" {
return execution.finishHashFault(ctx, assertions, uploadPath, warmupEvidence, uploadErr)
}
if uploadErr != nil {
return TransferEvidence{}, uploadErr
}
assertions.Record("exact 100MiB upload has size mode SHA and create_dirs", uploadEvidence.validUpload(stableDigest), uploadEvidence.details())
downloadEvidence, downloadURL, downloadErr := execution.download(ctx, uploadPath)
if downloadErr != nil {
return TransferEvidence{}, downloadErr
}
assertions.Record("exact 100MiB download has equal nonempty SHA", downloadEvidence.validDownload(stableDigest), downloadEvidence.details())
retainedHeapBytes := heapProbe.RetainedBytes()
uploadReplayErr := execution.replayUpload(ctx, uploadURL)
uploadReplayRejected := isTransferHTTPError(uploadReplayErr, 401, "already-used")
assertions.Record("upload token replay is typed unauthorized", uploadReplayRejected, errorText(uploadReplayErr))
downloadReplayErr := execution.replayDownload(ctx, downloadURL)
downloadReplayRejected := isTransferHTTPError(downloadReplayErr, 401, "")
assertions.Record("download token replay is typed unauthorized", downloadReplayRejected, errorText(downloadReplayErr))
oversizeErr := execution.probeOversize(ctx)
oversizeRejected := isTransferHTTPError(oversizeErr, 413, "transfer cap")
assertions.Record("100MiB plus one upload is typed too large", oversizeRejected, errorText(oversizeErr))
if _, err := confirmTransferQuiescence(ctx, execution.residueScope); err != nil {
return TransferEvidence{}, err
}
residue, err := transferResidue(execution.residueScope)
if err != nil {
return TransferEvidence{}, err
}
agentResidue, dashboardResidue := countTransferResidue(residue)
assertions.Record("Dashboard spool and Agent temp residue are zero", agentResidue == 0 && dashboardResidue == 0, fmt.Sprintf("agent_temp=%d dashboard_spool=%d", agentResidue, dashboardResidue))
sentinelsUnchanged, sentinelErr := execution.sentinels.unchanged()
assertions.Record("outside-root sentinels remain unchanged", sentinelErr == nil && sentinelsUnchanged, errorText(sentinelErr))
evidence := TransferEvidence{
WarmupUploadBytes: warmupEvidence.uploadBytes, WarmupDownloadBytes: warmupEvidence.downloadBytes,
WarmupSHA256: warmupEvidence.sha256, WarmupDuration: warmupEvidence.duration,
WarmupDeadlineRemaining: warmupEvidence.deadline, WarmupQuiescent: true,
UploadBytes: uploadEvidence.bytes, DownloadBytes: downloadEvidence.bytes,
UploadSHA256: uploadEvidence.sha256, DownloadSHA256: downloadEvidence.sha256,
UploadChunks: uploadEvidence.chunks, DownloadChunks: downloadEvidence.chunks,
UploadDuration: uploadEvidence.duration, DownloadDuration: downloadEvidence.duration,
RetainedHeapBytes: retainedHeapBytes, Mode: "0640", CreateDirs: true,
UploadReplayRejected: uploadReplayRejected, DownloadReplayRejected: downloadReplayRejected, OversizeRejected: oversizeRejected,
AgentTempResidue: agentResidue, DashboardSpoolResidue: dashboardResidue,
OutsideRootSentinelsUnchanged: sentinelsUnchanged,
}
heapErr := evidence.Validate()
assertions.Record("retained live heap stays within 16MiB", !errors.Is(heapErr, ErrTransferEvidenceHeapBudget), fmt.Sprintf("retained_heap_bytes=%d", evidence.RetainedHeapBytes))
if err := evidence.Validate(); err != nil {
return evidence, err
}
return evidence, nil
}
func (execution transferExecution) finishHashFault(ctx context.Context, assertions *AssertionSet, uploadPath fixture.AgentPath, warmup transferWarmupEvidence, uploadErr error) (TransferEvidence, error) {
assertions.Record("transfer-hash rejects upload with typed 502", isTransferHTTPError(uploadErr, 502, "sha256 mismatch"), errorText(uploadErr))
_, statErr := os.Stat(uploadPath.String())
assertions.Record("transfer-hash leaves target absent", errors.Is(statErr, os.ErrNotExist), errorText(statErr))
if _, err := confirmTransferQuiescence(ctx, execution.residueScope); err != nil {
return TransferEvidence{}, err
}
residue, err := transferResidue(execution.residueScope)
if err != nil {
return TransferEvidence{}, err
}
agentResidue, dashboardResidue := countTransferResidue(residue)
assertions.Record("Dashboard spool and Agent temp residue are zero", agentResidue == 0 && dashboardResidue == 0, fmt.Sprintf("agent_temp=%d dashboard_spool=%d", agentResidue, dashboardResidue))
unchanged, sentinelErr := execution.sentinels.unchanged()
assertions.Record("outside-root sentinels remain unchanged", sentinelErr == nil && unchanged, errorText(sentinelErr))
return TransferEvidence{
WarmupUploadBytes: warmup.uploadBytes, WarmupDownloadBytes: warmup.downloadBytes,
WarmupSHA256: warmup.sha256, WarmupDuration: warmup.duration,
WarmupDeadlineRemaining: warmup.deadline, WarmupQuiescent: true,
AgentTempResidue: agentResidue, DashboardSpoolResidue: dashboardResidue, OutsideRootSentinelsUnchanged: unchanged,
}, ErrTransferHashFault
}
func isTransferHTTPError(err error, status int, text string) bool {
var httpError *client.HTTPError
return errors.As(err, &httpError) && httpError.StatusCode == status && (text == "" || strings.Contains(httpError.Message, text))
}
type transferPathEvidence struct {
bytes uint64
sha256 string
chunks uint64
duration time.Duration
mode os.FileMode
}
func (evidence transferPathEvidence) details() string {
return fmt.Sprintf("bytes=%d sha256=%s chunks=%d duration=%s mode=%04o", evidence.bytes, evidence.sha256, evidence.chunks, evidence.duration, evidence.mode.Perm())
}
func (evidence transferPathEvidence) validUpload(want fixture.PayloadDigest) bool {
return evidence.validDownload(want) && evidence.mode.Perm() == 0o640
}
func (evidence transferPathEvidence) validDownload(want fixture.PayloadDigest) bool {
return evidence.bytes == contract.TransferBytes && evidence.sha256 != "" && evidence.sha256 == want.Hex()
}