독립 호스트에서 안전한 작업 실행과 복구를 제공하기 위해 런타임 설정, 정책, 상태 저장소, 워크스페이스 격리 및 AgentTask 오케스트레이션을 확장한다.
561 lines
18 KiB
Go
561 lines
18 KiB
Go
package agentguard
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
type admissionFixture struct {
|
|
request AdmissionRequest
|
|
baseRoot string
|
|
taskRoot string
|
|
commonGit string
|
|
gitDir string
|
|
}
|
|
|
|
func TestAdmissionS17Matrix(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("symlink and worktree pointer fixtures require Unix semantics")
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
mode IsolationMode
|
|
mutate func(*testing.T, *admissionFixture)
|
|
wantCode BlockerCode
|
|
wantInvoke int
|
|
}{
|
|
{
|
|
name: "registered full clone allowed",
|
|
mode: IsolationModeClone,
|
|
wantInvoke: 1,
|
|
},
|
|
{
|
|
name: "registered worktree with exact metadata allowance allowed",
|
|
mode: IsolationModeWorktree,
|
|
wantInvoke: 1,
|
|
},
|
|
{
|
|
name: "unregistered workspace",
|
|
mode: IsolationModeClone,
|
|
mutate: func(_ *testing.T, fixture *admissionFixture) {
|
|
fixture.request.Grant = nil
|
|
},
|
|
wantCode: BlockerCodeMissingWorkspaceGrant,
|
|
},
|
|
{
|
|
name: "worktree metadata allowance denied",
|
|
mode: IsolationModeWorktree,
|
|
mutate: func(_ *testing.T, fixture *admissionFixture) {
|
|
fixture.request.Grant.VCSMetadataRoots = nil
|
|
},
|
|
wantCode: BlockerCodeVCSMetadataNotAllowed,
|
|
},
|
|
{
|
|
name: "working directory symlink escape",
|
|
mode: IsolationModeClone,
|
|
mutate: func(t *testing.T, fixture *admissionFixture) {
|
|
outside := canonicalMkdir(t, filepath.Join(filepath.Dir(fixture.taskRoot), "outside"))
|
|
link := filepath.Join(fixture.taskRoot, "escape")
|
|
if err := os.Symlink(outside, link); err != nil {
|
|
t.Fatalf("Symlink: %v", err)
|
|
}
|
|
fixture.request.Isolation.WorkingDir = link
|
|
},
|
|
wantCode: BlockerCodeWorkspaceRootEscape,
|
|
},
|
|
{
|
|
name: "isolation cannot enforce writable roots",
|
|
mode: IsolationModeClone,
|
|
mutate: func(_ *testing.T, fixture *admissionFixture) {
|
|
fixture.request.Isolation.ConfinementRevision = ""
|
|
},
|
|
wantCode: BlockerCodeWritableConfinementUnavailable,
|
|
},
|
|
{
|
|
name: "profile cannot confine writable roots",
|
|
mode: IsolationModeClone,
|
|
mutate: func(_ *testing.T, fixture *admissionFixture) {
|
|
fixture.request.Profile.WritableRootConfinement = false
|
|
},
|
|
wantCode: BlockerCodeWritableConfinementUnavailable,
|
|
},
|
|
{
|
|
name: "provider unattended disabled",
|
|
mode: IsolationModeClone,
|
|
mutate: func(_ *testing.T, fixture *admissionFixture) {
|
|
fixture.request.Profile.Unattended = false
|
|
},
|
|
wantCode: BlockerCodeProviderUnattendedUnavailable,
|
|
},
|
|
{
|
|
name: "provider approval bypass disabled",
|
|
mode: IsolationModeClone,
|
|
mutate: func(_ *testing.T, fixture *admissionFixture) {
|
|
fixture.request.Profile.ApprovalBypass = false
|
|
},
|
|
wantCode: BlockerCodeProviderApprovalBypassUnavailable,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
fixture := newAdmissionFixture(t, test.mode)
|
|
if test.mutate != nil {
|
|
test.mutate(t, &fixture)
|
|
}
|
|
invocations := 0
|
|
admission := Admit(fixture.request)
|
|
if test.wantCode != "" {
|
|
if admission.Allowed() || admission.Permit != nil ||
|
|
admission.Blocker == nil || admission.Blocker.Code != test.wantCode {
|
|
t.Fatalf("admission = %#v, want blocker %q", admission, test.wantCode)
|
|
}
|
|
if admission.Notification == nil || admission.Notification.SetupGuidance == "" {
|
|
t.Fatalf("notification = %#v", admission.Notification)
|
|
}
|
|
} else {
|
|
if !admission.Allowed() {
|
|
t.Fatalf("admission blocked: %#v", admission.Blocker)
|
|
}
|
|
invocationResult, err := Invoke(
|
|
context.Background(),
|
|
admission.Permit,
|
|
fixture.request,
|
|
func(_ context.Context, workspace CanonicalWorkspace) error {
|
|
invocations++
|
|
if workspace.WorkingDir != fixture.taskRoot {
|
|
t.Fatalf("working dir = %q, want %q", workspace.WorkingDir, fixture.taskRoot)
|
|
}
|
|
return nil
|
|
},
|
|
)
|
|
if err != nil || !invocationResult.Allowed() {
|
|
t.Fatalf("Invoke = result:%#v err:%v", invocationResult, err)
|
|
}
|
|
}
|
|
if invocations != test.wantInvoke {
|
|
t.Fatalf("invocations = %d, want %d", invocations, test.wantInvoke)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAdmissionRejectsForgedStaleAndIdentityChangedPermitsWithoutInvocation(t *testing.T) {
|
|
t.Run("forged", func(t *testing.T) {
|
|
fixture := newAdmissionFixture(t, IsolationModeClone)
|
|
invocations := 0
|
|
result, err := Invoke(
|
|
context.Background(),
|
|
&Permit{},
|
|
fixture.request,
|
|
func(context.Context, CanonicalWorkspace) error {
|
|
invocations++
|
|
return nil
|
|
},
|
|
)
|
|
if err != nil || result.Blocker == nil || result.Blocker.Code != BlockerCodePermitInvalid {
|
|
t.Fatalf("Invoke forged = result:%#v err:%v", result, err)
|
|
}
|
|
if invocations != 0 {
|
|
t.Fatalf("forged permit invoked provider %d times", invocations)
|
|
}
|
|
})
|
|
|
|
t.Run("stale revision", func(t *testing.T) {
|
|
fixture := newAdmissionFixture(t, IsolationModeClone)
|
|
admission := Admit(fixture.request)
|
|
if !admission.Allowed() {
|
|
t.Fatalf("Admit: %#v", admission.Blocker)
|
|
}
|
|
fixture.request.Grant.Revision = "grant-r2"
|
|
invocations := 0
|
|
result, err := Invoke(
|
|
context.Background(),
|
|
admission.Permit,
|
|
fixture.request,
|
|
func(context.Context, CanonicalWorkspace) error {
|
|
invocations++
|
|
return nil
|
|
},
|
|
)
|
|
if err != nil || result.Blocker == nil || result.Blocker.Code != BlockerCodePermitStale {
|
|
t.Fatalf("Invoke stale = result:%#v err:%v", result, err)
|
|
}
|
|
if invocations != 0 {
|
|
t.Fatalf("stale permit invoked provider %d times", invocations)
|
|
}
|
|
})
|
|
|
|
t.Run("filesystem identity replacement", func(t *testing.T) {
|
|
fixture := newAdmissionFixture(t, IsolationModeClone)
|
|
admission := Admit(fixture.request)
|
|
if !admission.Allowed() {
|
|
t.Fatalf("Admit: %#v", admission.Blocker)
|
|
}
|
|
oldTaskRoot := fixture.taskRoot + "-old"
|
|
if err := os.Rename(fixture.taskRoot, oldTaskRoot); err != nil {
|
|
t.Fatalf("rename task root: %v", err)
|
|
}
|
|
canonicalMkdir(t, fixture.taskRoot)
|
|
canonicalMkdir(t, filepath.Join(fixture.taskRoot, ".git"))
|
|
invocations := 0
|
|
result, err := Invoke(
|
|
context.Background(),
|
|
admission.Permit,
|
|
fixture.request,
|
|
func(context.Context, CanonicalWorkspace) error {
|
|
invocations++
|
|
return nil
|
|
},
|
|
)
|
|
if err != nil || result.Blocker == nil ||
|
|
result.Blocker.Code != BlockerCodeWorkspaceIdentityMismatch {
|
|
t.Fatalf("Invoke replaced = result:%#v err:%v", result, err)
|
|
}
|
|
if invocations != 0 {
|
|
t.Fatalf("identity replacement invoked provider %d times", invocations)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAdmissionCanonicalContainmentBoundaries(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("symlink fixtures require Unix semantics")
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
mutate func(*testing.T, *admissionFixture)
|
|
wantCode BlockerCode
|
|
}{
|
|
{
|
|
name: "relative task root",
|
|
mutate: func(_ *testing.T, fixture *admissionFixture) {
|
|
fixture.request.Isolation.TaskRoot = "relative/task"
|
|
},
|
|
wantCode: BlockerCodeWorkspaceNotCanonical,
|
|
},
|
|
{
|
|
name: "nonexistent task root",
|
|
mutate: func(_ *testing.T, fixture *admissionFixture) {
|
|
fixture.request.Isolation.TaskRoot = filepath.Join(filepath.Dir(fixture.taskRoot), "missing")
|
|
},
|
|
wantCode: BlockerCodeWorkspaceNotCanonical,
|
|
},
|
|
{
|
|
name: "parent traversal task root",
|
|
mutate: func(_ *testing.T, fixture *admissionFixture) {
|
|
fixture.request.Isolation.TaskRoot = fixture.taskRoot + "/../" + filepath.Base(fixture.taskRoot)
|
|
},
|
|
wantCode: BlockerCodeWorkspaceNotCanonical,
|
|
},
|
|
{
|
|
name: "canonical task root symlink",
|
|
mutate: func(t *testing.T, fixture *admissionFixture) {
|
|
link := fixture.taskRoot + "-link"
|
|
if err := os.Symlink(fixture.taskRoot, link); err != nil {
|
|
t.Fatalf("Symlink: %v", err)
|
|
}
|
|
fixture.request.Isolation.TaskRoot = link
|
|
},
|
|
wantCode: BlockerCodeWorkspaceNotCanonical,
|
|
},
|
|
{
|
|
name: "working file symlink",
|
|
mutate: func(t *testing.T, fixture *admissionFixture) {
|
|
file := filepath.Join(fixture.taskRoot, "file")
|
|
if err := os.WriteFile(file, []byte("fixture"), 0o600); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
link := filepath.Join(fixture.taskRoot, "file-link")
|
|
if err := os.Symlink(file, link); err != nil {
|
|
t.Fatalf("Symlink: %v", err)
|
|
}
|
|
fixture.request.Isolation.WorkingDir = link
|
|
},
|
|
wantCode: BlockerCodeWorkspaceRootEscape,
|
|
},
|
|
{
|
|
name: "prefix collision writable root",
|
|
mutate: func(t *testing.T, fixture *admissionFixture) {
|
|
sibling := canonicalMkdir(t, fixture.taskRoot+"-sibling")
|
|
fixture.request.Isolation.WritableRoots = []string{sibling}
|
|
},
|
|
wantCode: BlockerCodeWritableRootEscape,
|
|
},
|
|
{
|
|
name: "symlink loop",
|
|
mutate: func(t *testing.T, fixture *admissionFixture) {
|
|
a := filepath.Join(fixture.taskRoot, "loop-a")
|
|
b := filepath.Join(fixture.taskRoot, "loop-b")
|
|
if err := os.Symlink(b, a); err != nil {
|
|
t.Fatalf("symlink a: %v", err)
|
|
}
|
|
if err := os.Symlink(a, b); err != nil {
|
|
t.Fatalf("symlink b: %v", err)
|
|
}
|
|
fixture.request.Isolation.WorkingDir = a
|
|
},
|
|
wantCode: BlockerCodeWorkspaceRootEscape,
|
|
},
|
|
{
|
|
name: "clone git metadata symlink",
|
|
mutate: func(t *testing.T, fixture *admissionFixture) {
|
|
if err := os.RemoveAll(filepath.Join(fixture.taskRoot, ".git")); err != nil {
|
|
t.Fatalf("RemoveAll .git: %v", err)
|
|
}
|
|
externalGit := canonicalMkdir(t, filepath.Join(filepath.Dir(fixture.taskRoot), "external-git"))
|
|
if err := os.Symlink(externalGit, filepath.Join(fixture.taskRoot, ".git")); err != nil {
|
|
t.Fatalf("Symlink .git: %v", err)
|
|
}
|
|
},
|
|
wantCode: BlockerCodeVCSMetadataNotAllowed,
|
|
},
|
|
{
|
|
name: "base root revision mismatch",
|
|
mutate: func(_ *testing.T, fixture *admissionFixture) {
|
|
fixture.request.Isolation.BaseRoot = fixture.taskRoot
|
|
},
|
|
wantCode: BlockerCodeRevisionMismatch,
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
fixture := newAdmissionFixture(t, IsolationModeClone)
|
|
test.mutate(t, &fixture)
|
|
result := Admit(fixture.request)
|
|
if result.Blocker == nil || result.Blocker.Code != test.wantCode {
|
|
t.Fatalf("Admit = %#v, want %q", result, test.wantCode)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAdmissionNestedWorkingRepositoryMetadata(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("symlink and worktree pointer fixtures require Unix semantics")
|
|
}
|
|
|
|
t.Run("nested internal git directory allowed", func(t *testing.T) {
|
|
fixture := newAdmissionFixture(t, IsolationModeClone)
|
|
nestedDir := canonicalMkdir(t, filepath.Join(fixture.taskRoot, "nested"))
|
|
canonicalMkdir(t, filepath.Join(nestedDir, ".git"))
|
|
fixture.request.Isolation.WorkingDir = nestedDir
|
|
|
|
admission := Admit(fixture.request)
|
|
if !admission.Allowed() {
|
|
t.Fatalf("Admit = %#v, want allowed", admission.Blocker)
|
|
}
|
|
if admission.Workspace.WorkingDir != nestedDir {
|
|
t.Fatalf("working dir = %q, want %q", admission.Workspace.WorkingDir, nestedDir)
|
|
}
|
|
})
|
|
|
|
t.Run("nested external gitdir denied without allowance", func(t *testing.T) {
|
|
fixture := newAdmissionFixture(t, IsolationModeClone)
|
|
nestedDir := canonicalMkdir(t, filepath.Join(fixture.taskRoot, "nested"))
|
|
outsideGit := canonicalMkdir(t, filepath.Join(filepath.Dir(fixture.taskRoot), "outside-git"))
|
|
if err := os.WriteFile(
|
|
filepath.Join(nestedDir, ".git"),
|
|
[]byte("gitdir: "+outsideGit+"\n"),
|
|
0o600,
|
|
); err != nil {
|
|
t.Fatalf("write nested .git pointer: %v", err)
|
|
}
|
|
fixture.request.Isolation.WorkingDir = nestedDir
|
|
|
|
admission := Admit(fixture.request)
|
|
if admission.Allowed() || admission.Blocker == nil ||
|
|
admission.Blocker.Code != BlockerCodeVCSMetadataNotAllowed {
|
|
t.Fatalf("Admit = %#v, want %s", admission, BlockerCodeVCSMetadataNotAllowed)
|
|
}
|
|
if admission.Notification == nil || admission.Notification.SetupGuidance == "" {
|
|
t.Fatalf("notification = %#v", admission.Notification)
|
|
}
|
|
})
|
|
|
|
t.Run("nested external gitdir allowed with exact grant allowance", func(t *testing.T) {
|
|
fixture := newAdmissionFixture(t, IsolationModeClone)
|
|
nestedDir := canonicalMkdir(t, filepath.Join(fixture.taskRoot, "nested"))
|
|
outsideGit := canonicalMkdir(t, filepath.Join(filepath.Dir(fixture.taskRoot), "outside-git"))
|
|
if err := os.WriteFile(
|
|
filepath.Join(nestedDir, ".git"),
|
|
[]byte("gitdir: "+outsideGit+"\n"),
|
|
0o600,
|
|
); err != nil {
|
|
t.Fatalf("write nested .git pointer: %v", err)
|
|
}
|
|
fixture.request.Isolation.WorkingDir = nestedDir
|
|
fixture.request.Grant.VCSMetadataRoots = []string{outsideGit}
|
|
|
|
admission := Admit(fixture.request)
|
|
if !admission.Allowed() {
|
|
t.Fatalf("Admit = %#v, want allowed", admission.Blocker)
|
|
}
|
|
found := false
|
|
for _, root := range admission.Workspace.VCSMetadataRoots {
|
|
if root == outsideGit {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("VCSMetadataRoots = %v, want to contain %q", admission.Workspace.VCSMetadataRoots, outsideGit)
|
|
}
|
|
})
|
|
|
|
t.Run("nested symlink git entry denied", func(t *testing.T) {
|
|
fixture := newAdmissionFixture(t, IsolationModeClone)
|
|
nestedDir := canonicalMkdir(t, filepath.Join(fixture.taskRoot, "nested"))
|
|
outsideGit := canonicalMkdir(t, filepath.Join(filepath.Dir(fixture.taskRoot), "outside-git"))
|
|
if err := os.Symlink(outsideGit, filepath.Join(nestedDir, ".git")); err != nil {
|
|
t.Fatalf("Symlink nested .git: %v", err)
|
|
}
|
|
fixture.request.Isolation.WorkingDir = nestedDir
|
|
|
|
admission := Admit(fixture.request)
|
|
if admission.Allowed() || admission.Blocker == nil ||
|
|
admission.Blocker.Code != BlockerCodeVCSMetadataNotAllowed {
|
|
t.Fatalf("Admit = %#v, want %s", admission, BlockerCodeVCSMetadataNotAllowed)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAdmissionAllowsSymlinkResolvedInsideWorkingDirectory(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("symlink fixture requires Unix semantics")
|
|
}
|
|
fixture := newAdmissionFixture(t, IsolationModeClone)
|
|
inside := canonicalMkdir(t, filepath.Join(fixture.taskRoot, "inside"))
|
|
link := filepath.Join(fixture.taskRoot, "inside-link")
|
|
if err := os.Symlink(inside, link); err != nil {
|
|
t.Fatalf("Symlink: %v", err)
|
|
}
|
|
fixture.request.Isolation.WorkingDir = link
|
|
result := Admit(fixture.request)
|
|
if !result.Allowed() {
|
|
t.Fatalf("Admit: %#v", result.Blocker)
|
|
}
|
|
if result.Workspace.WorkingDir != inside {
|
|
t.Fatalf("working dir = %q, want %q", result.Workspace.WorkingDir, inside)
|
|
}
|
|
}
|
|
|
|
func TestBlockedProjectDoesNotStopIndependentProject(t *testing.T) {
|
|
blockedFixture := newAdmissionFixture(t, IsolationModeClone)
|
|
blockedFixture.request.Grant.ProjectID = "project-blocked"
|
|
blockedFixture.request.Profile.ApprovalBypass = false
|
|
allowedFixture := newAdmissionFixture(t, IsolationModeClone)
|
|
allowedFixture.request.Grant.ProjectID = "project-allowed"
|
|
|
|
invocations := map[string]int{}
|
|
for _, fixture := range []admissionFixture{blockedFixture, allowedFixture} {
|
|
admission := Admit(fixture.request)
|
|
if !admission.Allowed() {
|
|
continue
|
|
}
|
|
_, err := Invoke(
|
|
context.Background(),
|
|
admission.Permit,
|
|
fixture.request,
|
|
func(_ context.Context, workspace CanonicalWorkspace) error {
|
|
invocations[workspace.ProjectID]++
|
|
return nil
|
|
},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("Invoke: %v", err)
|
|
}
|
|
}
|
|
if invocations[blockedFixture.request.Grant.ProjectID] != 0 {
|
|
t.Fatalf("blocked project invocation count = %d", invocations[blockedFixture.request.Grant.ProjectID])
|
|
}
|
|
if invocations[allowedFixture.request.Grant.ProjectID] != 1 {
|
|
t.Fatalf("independent project invocation count = %d", invocations[allowedFixture.request.Grant.ProjectID])
|
|
}
|
|
}
|
|
|
|
func newAdmissionFixture(t *testing.T, mode IsolationMode) admissionFixture {
|
|
t.Helper()
|
|
root := canonicalTempDir(t)
|
|
baseRoot := canonicalMkdir(t, filepath.Join(root, "base-"+strings.ReplaceAll(t.Name(), "/", "-")))
|
|
taskRoot := canonicalMkdir(t, filepath.Join(root, "task-"+strings.ReplaceAll(t.Name(), "/", "-")))
|
|
fixture := admissionFixture{
|
|
baseRoot: baseRoot,
|
|
taskRoot: taskRoot,
|
|
request: AdmissionRequest{
|
|
Grant: &WorkspaceGrant{
|
|
ProjectID: "project-" + strings.ReplaceAll(t.Name(), "/", "-"),
|
|
WorkspaceID: "workspace-a",
|
|
Root: baseRoot,
|
|
Revision: "grant-r1",
|
|
},
|
|
Isolation: &IsolationDescriptor{
|
|
ID: "isolation-a",
|
|
Revision: "isolation-r1",
|
|
Mode: mode,
|
|
BaseRoot: baseRoot,
|
|
TaskRoot: taskRoot,
|
|
WorkingDir: taskRoot,
|
|
WritableRoots: []string{taskRoot},
|
|
PinnedBaseRevision: "base-r1",
|
|
ConfinementRevision: "confinement-r1",
|
|
},
|
|
Profile: ProviderProfile{
|
|
ProviderID: "codex",
|
|
ModelID: "gpt",
|
|
ProfileID: "codex-headless",
|
|
Revision: "profile-r1",
|
|
Unattended: true,
|
|
ApprovalBypass: true,
|
|
WritableRootConfinement: true,
|
|
},
|
|
},
|
|
}
|
|
|
|
switch mode {
|
|
case IsolationModeClone:
|
|
canonicalMkdir(t, filepath.Join(taskRoot, ".git"))
|
|
case IsolationModeWorktree:
|
|
commonGit := canonicalMkdir(t, filepath.Join(baseRoot, ".git"))
|
|
gitDir := canonicalMkdir(t, filepath.Join(commonGit, "worktrees", "task-a"))
|
|
if err := os.WriteFile(
|
|
filepath.Join(taskRoot, ".git"),
|
|
[]byte("gitdir: "+gitDir+"\n"),
|
|
0o600,
|
|
); err != nil {
|
|
t.Fatalf("write .git pointer: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(gitDir, "commondir"), []byte("../..\n"), 0o600); err != nil {
|
|
t.Fatalf("write commondir: %v", err)
|
|
}
|
|
fixture.commonGit = commonGit
|
|
fixture.gitDir = gitDir
|
|
fixture.request.Grant.VCSMetadataRoots = []string{gitDir, commonGit}
|
|
}
|
|
return fixture
|
|
}
|
|
|
|
func canonicalTempDir(t *testing.T) string {
|
|
t.Helper()
|
|
path, err := filepath.EvalSymlinks(t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("EvalSymlinks temp dir: %v", err)
|
|
}
|
|
return filepath.Clean(path)
|
|
}
|
|
|
|
func canonicalMkdir(t *testing.T, path string) string {
|
|
t.Helper()
|
|
if err := os.MkdirAll(path, 0o700); err != nil {
|
|
t.Fatalf("MkdirAll %s: %v", path, err)
|
|
}
|
|
canonical, err := filepath.EvalSymlinks(path)
|
|
if err != nil {
|
|
t.Fatalf("EvalSymlinks %s: %v", path, err)
|
|
}
|
|
return filepath.Clean(canonical)
|
|
}
|