mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 14:15:00 +00:00
* feat(collaboration): isolate delegated artifacts and child runs Isolate delegated inputs and outputs behind secure artifact exchange lifecycles. Scope Agent Link tasks by tenant and root agent, and enforce delegation spawn-tree boundaries. Add process-wide child-run admission and preserve logical media paths across native, MCP, and sandbox execution. * fix(collaboration): harden delegated task isolation Enforce tenant and root-agent task scope across migrations and stores. Add exactly-once async completion delivery, delegated sandbox boundaries, and confined artifact and media recovery across runtime surfaces. * fix(collaboration): recover interrupted async tasks * fix(collaboration): normalize persisted child-run status --------- Co-authored-by: ntduc <ntduc@cpp.ai.vn>
752 lines
22 KiB
Go
752 lines
22 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
func TestDelegationArtifactExchangeTenantLayoutAndInputContract(t *testing.T) {
|
|
tenantRoot := t.TempDir()
|
|
sourcePath := t.TempDir()
|
|
if err := os.MkdirAll(filepath.Join(sourcePath, "one"), 0750); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Join(sourcePath, "two"), 0750); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(sourcePath, "one", "report.txt"), []byte("first"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(sourcePath, "two", "report.txt"), []byte("second"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
tenantID := uuid.New()
|
|
delegationID := uuid.New()
|
|
exchange := newTestDelegationExchange(t, tenantRoot, tenantID, delegationID, DelegationArtifactLimits{})
|
|
source := openTestArtifactRoot(t, sourcePath)
|
|
staged, err := exchange.StageInputs(
|
|
context.Background(),
|
|
source,
|
|
[]string{"one/report.txt", "two/report.txt"},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("StageInputs() error = %v", err)
|
|
}
|
|
if got, want := len(staged), 2; got != want {
|
|
t.Fatalf("staged count = %d, want %d", got, want)
|
|
}
|
|
if staged[0].Path != "inputs/report.txt" || staged[1].Path != "inputs/report-2.txt" {
|
|
t.Fatalf("staged paths = %#v", []string{staged[0].Path, staged[1].Path})
|
|
}
|
|
|
|
wantExchangeRoot := filepath.Join(
|
|
tenantRoot,
|
|
"collaboration",
|
|
"delegations",
|
|
delegationID.String(),
|
|
)
|
|
if got := exchange.OutputsHostPath(); got != filepath.Join(wantExchangeRoot, "outputs") {
|
|
t.Fatalf("OutputsHostPath() = %q, want %q", got, filepath.Join(wantExchangeRoot, "outputs"))
|
|
}
|
|
mount := exchange.InputsMount()
|
|
if mount.LogicalAlias != "inputs" || mount.ContainerPath != "/workspace/inputs" || !mount.ReadOnly {
|
|
t.Fatalf("InputsMount() = %#v", mount)
|
|
}
|
|
if mount.HostRoot != filepath.Join(wantExchangeRoot, "inputs") {
|
|
t.Fatalf("input host root = %q", mount.HostRoot)
|
|
}
|
|
for _, name := range []string{"report.txt", "report-2.txt"} {
|
|
info, err := os.Stat(filepath.Join(mount.HostRoot, name))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if info.Mode().Perm()&0222 != 0 {
|
|
t.Fatalf("%s mode = %o, want read-only", name, info.Mode().Perm())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDelegationArtifactStageRejectsTraversalSymlinksAndHardlinks(t *testing.T) {
|
|
testCases := []struct {
|
|
name string
|
|
prepare func(t *testing.T, source string) string
|
|
wantErr error
|
|
}{
|
|
{
|
|
name: "traversal",
|
|
prepare: func(t *testing.T, source string) string {
|
|
return "../outside.txt"
|
|
},
|
|
wantErr: ErrArtifactInvalidPath,
|
|
},
|
|
{
|
|
name: "absolute",
|
|
prepare: func(t *testing.T, source string) string {
|
|
return filepath.Join(source, "outside.txt")
|
|
},
|
|
wantErr: ErrArtifactInvalidPath,
|
|
},
|
|
{
|
|
name: "alternate data stream",
|
|
prepare: func(t *testing.T, source string) string {
|
|
return "report.txt:secret"
|
|
},
|
|
wantErr: ErrArtifactInvalidPath,
|
|
},
|
|
{
|
|
name: "reserved device",
|
|
prepare: func(t *testing.T, source string) string {
|
|
return "NUL.txt"
|
|
},
|
|
wantErr: ErrArtifactInvalidPath,
|
|
},
|
|
{
|
|
name: "control character",
|
|
prepare: func(t *testing.T, source string) string {
|
|
return "report\n.txt"
|
|
},
|
|
wantErr: ErrArtifactInvalidPath,
|
|
},
|
|
{
|
|
name: "symlink",
|
|
prepare: func(t *testing.T, source string) string {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("symlink creation requires privileges on some Windows hosts")
|
|
}
|
|
if err := os.WriteFile(filepath.Join(source, "real.txt"), []byte("secret"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink("real.txt", filepath.Join(source, "link.txt")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return "link.txt"
|
|
},
|
|
wantErr: ErrArtifactNonRegular,
|
|
},
|
|
{
|
|
name: "hardlink",
|
|
prepare: func(t *testing.T, source string) string {
|
|
if err := os.WriteFile(filepath.Join(source, "real.txt"), []byte("secret"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Link(filepath.Join(source, "real.txt"), filepath.Join(source, "hard.txt")); err != nil {
|
|
t.Skipf("hardlinks unavailable: %v", err)
|
|
}
|
|
return "hard.txt"
|
|
},
|
|
wantErr: ErrArtifactHardlink,
|
|
},
|
|
}
|
|
|
|
for _, testCase := range testCases {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
sourcePath := t.TempDir()
|
|
input := testCase.prepare(t, sourcePath)
|
|
exchange := newTestDelegationExchange(
|
|
t,
|
|
t.TempDir(),
|
|
uuid.New(),
|
|
uuid.New(),
|
|
DelegationArtifactLimits{},
|
|
)
|
|
source := openTestArtifactRoot(t, sourcePath)
|
|
_, err := exchange.StageInputs(context.Background(), source, []string{input})
|
|
if !errors.Is(err, testCase.wantErr) {
|
|
t.Fatalf("StageInputs() error = %v, want errors.Is(%v)", err, testCase.wantErr)
|
|
}
|
|
retention, retained := exchange.FailureRetention()
|
|
if !retained || retention.ReasonCode == "" || !retention.RetainUntil.After(retention.FailedAt) {
|
|
t.Fatalf("failure retention = %#v, %v", retention, retained)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDelegationArtifactPublishManifestV1IsSortedAndPathSafe(t *testing.T) {
|
|
exchange := newTestDelegationExchange(
|
|
t,
|
|
t.TempDir(),
|
|
uuid.New(),
|
|
uuid.New(),
|
|
DelegationArtifactLimits{},
|
|
)
|
|
if err := os.MkdirAll(filepath.Join(exchange.OutputsHostPath(), "nested"), 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(exchange.OutputsHostPath(), "z.txt"), []byte("z"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(exchange.OutputsHostPath(), "nested", "a.txt"), []byte("alpha"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
destinationPath := t.TempDir()
|
|
destination := openTestArtifactRoot(t, destinationPath)
|
|
publishedAt := time.Date(2026, 7, 29, 8, 30, 0, 0, time.FixedZone("ICT", 7*60*60))
|
|
publication, err := exchange.Publish(context.Background(), destination, publishedAt)
|
|
if err != nil {
|
|
t.Fatalf("Publish() error = %v", err)
|
|
}
|
|
if publication.RootPath != filepath.ToSlash(filepath.Join(".delegations", exchange.DelegationID().String())) {
|
|
t.Fatalf("publication root = %q", publication.RootPath)
|
|
}
|
|
if publication.Manifest.SchemaVersion != 1 ||
|
|
publication.Manifest.DelegationID != exchange.DelegationID().String() ||
|
|
publication.Manifest.PublishedAt.Location() != time.UTC ||
|
|
publication.Manifest.OutputCount != 2 ||
|
|
publication.Manifest.OutputBytes != 6 {
|
|
t.Fatalf("manifest = %#v", publication.Manifest)
|
|
}
|
|
if got := []string{
|
|
publication.Manifest.Outputs[0].Path,
|
|
publication.Manifest.Outputs[1].Path,
|
|
}; got[0] != "outputs/nested/a.txt" || got[1] != "outputs/z.txt" {
|
|
t.Fatalf("manifest output order = %#v", got)
|
|
}
|
|
for _, output := range publication.Manifest.Outputs {
|
|
if err := validateManifestOutputPath(output.Path); err != nil {
|
|
t.Fatalf("manifest output path %q invalid: %v", output.Path, err)
|
|
}
|
|
if len(output.SHA256) != 64 || output.MediaType == "" {
|
|
t.Fatalf("manifest output = %#v", output)
|
|
}
|
|
}
|
|
|
|
manifestBytes, err := os.ReadFile(filepath.Join(destinationPath, filepath.FromSlash(publication.ManifestPath)))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(string(manifestBytes), destinationPath) ||
|
|
strings.Contains(string(manifestBytes), exchange.OutputsHostPath()) {
|
|
t.Fatalf("manifest leaked host path: %s", manifestBytes)
|
|
}
|
|
var durable DelegationArtifactManifest
|
|
if err := json.Unmarshal(manifestBytes, &durable); err != nil {
|
|
t.Fatalf("decode manifest: %v", err)
|
|
}
|
|
if durable.Outputs == nil || durable.OutputCount != len(durable.Outputs) {
|
|
t.Fatalf("durable outputs = %#v", durable.Outputs)
|
|
}
|
|
var rawManifest map[string]json.RawMessage
|
|
if err := json.Unmarshal(manifestBytes, &rawManifest); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(rawManifest) != 6 {
|
|
t.Fatalf("manifest persisted unexpected fields: %v", rawManifest)
|
|
}
|
|
for _, forbidden := range []string{"tenant_id", "agent_id", "workspace", "trace_id", "status"} {
|
|
if _, exists := rawManifest[forbidden]; exists {
|
|
t.Fatalf("manifest persisted forbidden field %q", forbidden)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDelegationArtifactPublishEmptyManifest(t *testing.T) {
|
|
exchange := newTestDelegationExchange(
|
|
t,
|
|
t.TempDir(),
|
|
uuid.New(),
|
|
uuid.New(),
|
|
DelegationArtifactLimits{},
|
|
)
|
|
destinationPath := t.TempDir()
|
|
publication, err := exchange.Publish(
|
|
context.Background(),
|
|
openTestArtifactRoot(t, destinationPath),
|
|
time.Unix(123, 0),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("Publish() error = %v", err)
|
|
}
|
|
if publication.Manifest.OutputCount != 0 ||
|
|
publication.Manifest.OutputBytes != 0 ||
|
|
publication.Manifest.Outputs == nil ||
|
|
len(publication.Manifest.Outputs) != 0 {
|
|
t.Fatalf("empty manifest = %#v", publication.Manifest)
|
|
}
|
|
manifestBytes, err := os.ReadFile(filepath.Join(destinationPath, filepath.FromSlash(publication.ManifestPath)))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(manifestBytes), `"outputs": []`) {
|
|
t.Fatalf("empty outputs must encode as [], manifest: %s", manifestBytes)
|
|
}
|
|
}
|
|
|
|
func TestDelegationArtifactPublishRejectsExcessEmptyDirectories(t *testing.T) {
|
|
exchange := newTestDelegationExchange(
|
|
t,
|
|
t.TempDir(),
|
|
uuid.New(),
|
|
uuid.New(),
|
|
DelegationArtifactLimits{},
|
|
)
|
|
for index := 0; index <= DelegationArtifactMaxFiles; index++ {
|
|
name := fmt.Sprintf("empty-%03d", index)
|
|
if err := os.Mkdir(filepath.Join(exchange.OutputsHostPath(), name), 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
_, err := exchange.Publish(
|
|
context.Background(),
|
|
openTestArtifactRoot(t, t.TempDir()),
|
|
time.Now(),
|
|
)
|
|
var artifactErr *DelegationArtifactError
|
|
if !errors.Is(err, ErrArtifactLimitExceeded) ||
|
|
!errors.As(err, &artifactErr) ||
|
|
artifactErr.Code != "artifact_directory_limit" {
|
|
t.Fatalf("Publish() error = %v, want artifact_directory_limit", err)
|
|
}
|
|
}
|
|
|
|
func TestDelegationArtifactPublishRejectsDeepOutputTree(t *testing.T) {
|
|
exchange := newTestDelegationExchange(
|
|
t,
|
|
t.TempDir(),
|
|
uuid.New(),
|
|
uuid.New(),
|
|
DelegationArtifactLimits{},
|
|
)
|
|
current := exchange.OutputsHostPath()
|
|
for depth := 0; depth <= artifactSecureMaxDepth; depth++ {
|
|
current = filepath.Join(current, "d")
|
|
if err := os.Mkdir(current, 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
_, err := exchange.Publish(
|
|
context.Background(),
|
|
openTestArtifactRoot(t, t.TempDir()),
|
|
time.Now(),
|
|
)
|
|
var artifactErr *DelegationArtifactError
|
|
if !errors.Is(err, ErrArtifactLimitExceeded) ||
|
|
!errors.As(err, &artifactErr) ||
|
|
artifactErr.Code != "artifact_depth_limit" {
|
|
t.Fatalf("Publish() error = %v, want artifact_depth_limit", err)
|
|
}
|
|
}
|
|
|
|
func TestDelegationArtifactPublishRejectsLongLogicalOutputPath(t *testing.T) {
|
|
exchange := newTestDelegationExchange(
|
|
t,
|
|
t.TempDir(),
|
|
uuid.New(),
|
|
uuid.New(),
|
|
DelegationArtifactLimits{},
|
|
)
|
|
currentHostPath := exchange.OutputsHostPath()
|
|
logicalPath := "outputs"
|
|
for depth := 0; len(logicalPath) <= artifactOutputMaxPathBytes; depth++ {
|
|
if depth >= artifactSecureMaxDepth {
|
|
t.Fatal("test path reached depth limit before path-length limit")
|
|
}
|
|
component := fmt.Sprintf("%02d-%s", depth, strings.Repeat("x", 47))
|
|
currentHostPath = filepath.Join(currentHostPath, component)
|
|
logicalPath += "/" + component
|
|
if err := os.Mkdir(currentHostPath, 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
_, err := exchange.Publish(
|
|
context.Background(),
|
|
openTestArtifactRoot(t, t.TempDir()),
|
|
time.Now(),
|
|
)
|
|
var artifactErr *DelegationArtifactError
|
|
if !errors.Is(err, ErrArtifactLimitExceeded) ||
|
|
!errors.As(err, &artifactErr) ||
|
|
artifactErr.Code != "artifact_path_limit" {
|
|
t.Fatalf("Publish() error = %v, want artifact_path_limit", err)
|
|
}
|
|
}
|
|
|
|
func TestDelegationArtifactPublishPreservesRegularOutputFileLimit(t *testing.T) {
|
|
limits := DelegationArtifactLimits{
|
|
MaxFileBytes: 8,
|
|
MaxTotalBytes: 32,
|
|
MaxFiles: 2,
|
|
}
|
|
exchange := newTestDelegationExchange(
|
|
t,
|
|
t.TempDir(),
|
|
uuid.New(),
|
|
uuid.New(),
|
|
limits,
|
|
)
|
|
for _, name := range []string{"one.txt", "two.txt", "three.txt"} {
|
|
if err := os.WriteFile(filepath.Join(exchange.OutputsHostPath(), name), []byte(name), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
_, err := exchange.Publish(
|
|
context.Background(),
|
|
openTestArtifactRoot(t, t.TempDir()),
|
|
time.Now(),
|
|
)
|
|
var artifactErr *DelegationArtifactError
|
|
if !errors.Is(err, ErrArtifactLimitExceeded) ||
|
|
!errors.As(err, &artifactErr) ||
|
|
artifactErr.Code != "artifact_file_limit" {
|
|
t.Fatalf("Publish() error = %v, want artifact_file_limit", err)
|
|
}
|
|
}
|
|
|
|
func TestDelegationArtifactPublishRejectsSymlinkAndHardlinkOutputs(t *testing.T) {
|
|
testCases := []struct {
|
|
name string
|
|
prepare func(t *testing.T, exchange *DelegationArtifactExchange)
|
|
wantErr error
|
|
}{
|
|
{
|
|
name: "symlink",
|
|
prepare: func(t *testing.T, exchange *DelegationArtifactExchange) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("symlink creation requires privileges on some Windows hosts")
|
|
}
|
|
outside := filepath.Join(t.TempDir(), "outside.txt")
|
|
if err := os.WriteFile(outside, []byte("secret"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink(outside, filepath.Join(exchange.OutputsHostPath(), "link.txt")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
},
|
|
wantErr: ErrArtifactNonRegular,
|
|
},
|
|
{
|
|
name: "hardlink",
|
|
prepare: func(t *testing.T, exchange *DelegationArtifactExchange) {
|
|
outside := filepath.Join(t.TempDir(), "outside.txt")
|
|
if err := os.WriteFile(outside, []byte("secret"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Link(outside, filepath.Join(exchange.OutputsHostPath(), "hard.txt")); err != nil {
|
|
t.Skipf("hardlinks unavailable: %v", err)
|
|
}
|
|
},
|
|
wantErr: ErrArtifactHardlink,
|
|
},
|
|
}
|
|
|
|
for _, testCase := range testCases {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
exchange := newTestDelegationExchange(
|
|
t,
|
|
t.TempDir(),
|
|
uuid.New(),
|
|
uuid.New(),
|
|
DelegationArtifactLimits{},
|
|
)
|
|
testCase.prepare(t, exchange)
|
|
destinationPath := t.TempDir()
|
|
_, err := exchange.Publish(
|
|
context.Background(),
|
|
openTestArtifactRoot(t, destinationPath),
|
|
time.Now(),
|
|
)
|
|
if !errors.Is(err, testCase.wantErr) {
|
|
t.Fatalf("Publish() error = %v, want errors.Is(%v)", err, testCase.wantErr)
|
|
}
|
|
if _, statErr := os.Stat(filepath.Join(destinationPath, ".delegations", exchange.DelegationID().String())); !errors.Is(statErr, os.ErrNotExist) {
|
|
t.Fatalf("final destination visible after rejection: %v", statErr)
|
|
}
|
|
retention, retained := exchange.FailureRetention()
|
|
if !retained || !strings.HasPrefix(
|
|
retention.PublicationTempPath,
|
|
".delegations/.tmp-"+exchange.DelegationID().String()+"-",
|
|
) {
|
|
t.Fatalf("publication temp path not retained for janitor: %#v, %v", retention, retained)
|
|
}
|
|
parentEntries, readErr := os.ReadDir(filepath.Join(destinationPath, ".delegations"))
|
|
if readErr != nil {
|
|
t.Fatal(readErr)
|
|
}
|
|
for _, entry := range parentEntries {
|
|
if !strings.HasPrefix(entry.Name(), ".tmp-") {
|
|
t.Fatalf("unexpected publication entry remains: %s", entry.Name())
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDelegationArtifactPublishNoReplaceCollision(t *testing.T) {
|
|
exchange := newTestDelegationExchange(
|
|
t,
|
|
t.TempDir(),
|
|
uuid.New(),
|
|
uuid.New(),
|
|
DelegationArtifactLimits{},
|
|
)
|
|
if err := os.WriteFile(filepath.Join(exchange.OutputsHostPath(), "new.txt"), []byte("new"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
destinationPath := t.TempDir()
|
|
finalPath := filepath.Join(destinationPath, ".delegations", exchange.DelegationID().String())
|
|
if err := os.MkdirAll(finalPath, 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sentinel := filepath.Join(finalPath, "sentinel.txt")
|
|
if err := os.WriteFile(sentinel, []byte("original"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err := exchange.Publish(
|
|
context.Background(),
|
|
openTestArtifactRoot(t, destinationPath),
|
|
time.Now(),
|
|
)
|
|
if !errors.Is(err, ErrArtifactPublishConflict) {
|
|
t.Fatalf("Publish() error = %v, want conflict", err)
|
|
}
|
|
content, readErr := os.ReadFile(sentinel)
|
|
if readErr != nil || string(content) != "original" {
|
|
t.Fatalf("pre-existing destination changed: %q, %v", content, readErr)
|
|
}
|
|
}
|
|
|
|
func TestDelegationArtifactSecureRenameNeverReplaces(t *testing.T) {
|
|
root := openTestArtifactRoot(t, t.TempDir())
|
|
source, err := root.root.createSubroot("source", 0700)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := source.close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
destination, err := root.root.createSubroot("destination", 0700)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := destination.close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err = root.root.renameNoReplace("source", "destination")
|
|
if !errors.Is(err, ErrArtifactPublishConflict) {
|
|
t.Fatalf("renameNoReplace() error = %v, want conflict", err)
|
|
}
|
|
for _, name := range []string{"source", "destination"} {
|
|
exists, statErr := root.root.exists(name)
|
|
if statErr != nil || !exists {
|
|
t.Fatalf("%s existence = %v, %v; rename replaced an entry", name, exists, statErr)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDelegationArtifactPublishUsesCapturedDestinationRoot(t *testing.T) {
|
|
exchange := newTestDelegationExchange(
|
|
t,
|
|
t.TempDir(),
|
|
uuid.New(),
|
|
uuid.New(),
|
|
DelegationArtifactLimits{},
|
|
)
|
|
if err := os.WriteFile(filepath.Join(exchange.OutputsHostPath(), "result.txt"), []byte("result"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
parent := t.TempDir()
|
|
originalPath := filepath.Join(parent, "workspace")
|
|
movedPath := filepath.Join(parent, "workspace-captured")
|
|
if err := os.Mkdir(originalPath, 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
destination := openTestArtifactRoot(t, originalPath)
|
|
if err := os.Rename(originalPath, movedPath); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Mkdir(originalPath, 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
publication, err := exchange.Publish(context.Background(), destination, time.Now())
|
|
if err != nil {
|
|
t.Fatalf("Publish() error = %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(movedPath, filepath.FromSlash(publication.ManifestPath))); err != nil {
|
|
t.Fatalf("artifact not published to captured root: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(originalPath, filepath.FromSlash(publication.ManifestPath))); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("replacement path received publication: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDelegationArtifactBudgetsAreSharedAcrossInputsAndOutputs(t *testing.T) {
|
|
limits := DelegationArtifactLimits{
|
|
MaxFileBytes: 5,
|
|
MaxTotalBytes: 8,
|
|
MaxFiles: 2,
|
|
}
|
|
sourcePath := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(sourcePath, "input.bin"), []byte("1234"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
exchange := newTestDelegationExchange(t, t.TempDir(), uuid.New(), uuid.New(), limits)
|
|
if _, err := exchange.StageInputs(
|
|
context.Background(),
|
|
openTestArtifactRoot(t, sourcePath),
|
|
[]string{"input.bin"},
|
|
); err != nil {
|
|
t.Fatalf("StageInputs() error = %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(exchange.OutputsHostPath(), "output.bin"), []byte("12345"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
destinationPath := t.TempDir()
|
|
_, err := exchange.Publish(
|
|
context.Background(),
|
|
openTestArtifactRoot(t, destinationPath),
|
|
time.Now(),
|
|
)
|
|
if !errors.Is(err, ErrArtifactLimitExceeded) {
|
|
t.Fatalf("Publish() error = %v, want shared total-byte limit", err)
|
|
}
|
|
if _, statErr := os.Stat(filepath.Join(destinationPath, ".delegations", exchange.DelegationID().String())); !errors.Is(statErr, os.ErrNotExist) {
|
|
t.Fatalf("final destination visible after budget failure: %v", statErr)
|
|
}
|
|
}
|
|
|
|
func TestDelegationArtifactInputFileLimitRejectsBeforeStaging(t *testing.T) {
|
|
limits := DelegationArtifactLimits{
|
|
MaxFileBytes: 8,
|
|
MaxTotalBytes: 16,
|
|
MaxFiles: 1,
|
|
}
|
|
sourcePath := t.TempDir()
|
|
for _, name := range []string{"one.txt", "two.txt"} {
|
|
if err := os.WriteFile(filepath.Join(sourcePath, name), []byte(name), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
exchange := newTestDelegationExchange(t, t.TempDir(), uuid.New(), uuid.New(), limits)
|
|
_, err := exchange.StageInputs(
|
|
context.Background(),
|
|
openTestArtifactRoot(t, sourcePath),
|
|
[]string{"one.txt", "two.txt"},
|
|
)
|
|
if !errors.Is(err, ErrArtifactLimitExceeded) {
|
|
t.Fatalf("StageInputs() error = %v, want file limit", err)
|
|
}
|
|
entries, readErr := os.ReadDir(exchange.InputsMount().HostRoot)
|
|
if readErr != nil {
|
|
t.Fatal(readErr)
|
|
}
|
|
if len(entries) != 0 {
|
|
t.Fatalf("staging began before structured input count rejection: %v", entries)
|
|
}
|
|
}
|
|
|
|
func TestDelegationArtifactPublishRejectsSymlinkPublicationParent(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("symlink creation requires privileges on some Windows hosts")
|
|
}
|
|
exchange := newTestDelegationExchange(
|
|
t,
|
|
t.TempDir(),
|
|
uuid.New(),
|
|
uuid.New(),
|
|
DelegationArtifactLimits{},
|
|
)
|
|
destinationPath := t.TempDir()
|
|
outsidePath := t.TempDir()
|
|
if err := os.Symlink(outsidePath, filepath.Join(destinationPath, ".delegations")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err := exchange.Publish(
|
|
context.Background(),
|
|
openTestArtifactRoot(t, destinationPath),
|
|
time.Now(),
|
|
)
|
|
if err == nil {
|
|
t.Fatal("Publish() accepted symlink publication parent")
|
|
}
|
|
entries, readErr := os.ReadDir(outsidePath)
|
|
if readErr != nil {
|
|
t.Fatal(readErr)
|
|
}
|
|
if len(entries) != 0 {
|
|
t.Fatalf("publication escaped through symlink: %v", entries)
|
|
}
|
|
}
|
|
|
|
func TestDelegationArtifactFailureRetentionCleanupHook(t *testing.T) {
|
|
exchange := newTestDelegationExchange(
|
|
t,
|
|
t.TempDir(),
|
|
uuid.New(),
|
|
uuid.New(),
|
|
DelegationArtifactLimits{},
|
|
)
|
|
failedAt := time.Date(2026, 7, 29, 0, 0, 0, 0, time.UTC)
|
|
exchange.RetainFailure(failedAt, "validation_failed")
|
|
exchange.RetainFailure(failedAt.Add(time.Hour), "must_not_extend_ttl")
|
|
retention, ok := exchange.FailureRetention()
|
|
if !ok || retention.FailedAt != failedAt || retention.ReasonCode != "validation_failed" {
|
|
t.Fatalf("retention = %#v, %v", retention, ok)
|
|
}
|
|
if exchange.ReadyForCleanup(failedAt.Add(59 * time.Minute)) {
|
|
t.Fatal("exchange ready for cleanup before TTL")
|
|
}
|
|
if !exchange.ReadyForCleanup(failedAt.Add(60 * time.Minute)) {
|
|
t.Fatal("exchange not ready for cleanup at TTL")
|
|
}
|
|
}
|
|
|
|
func newTestDelegationExchange(
|
|
t *testing.T,
|
|
tenantRoot string,
|
|
tenantID uuid.UUID,
|
|
delegationID uuid.UUID,
|
|
limits DelegationArtifactLimits,
|
|
) *DelegationArtifactExchange {
|
|
t.Helper()
|
|
exchange, err := NewDelegationArtifactExchange(
|
|
tenantRoot,
|
|
tenantID,
|
|
delegationID,
|
|
limits,
|
|
0,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("NewDelegationArtifactExchange() error = %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if err := exchange.Close(); err != nil {
|
|
t.Errorf("close exchange: %v", err)
|
|
}
|
|
})
|
|
return exchange
|
|
}
|
|
|
|
func openTestArtifactRoot(t *testing.T, hostPath string) *DelegationArtifactRoot {
|
|
t.Helper()
|
|
root, err := OpenDelegationArtifactRoot(hostPath)
|
|
if err != nil {
|
|
t.Fatalf("OpenDelegationArtifactRoot() error = %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if err := root.Close(); err != nil {
|
|
t.Errorf("close root: %v", err)
|
|
}
|
|
})
|
|
return root
|
|
}
|