diff --git a/integration/agentcompat/internal/fixture/agent_path.go b/integration/agentcompat/internal/fixture/agent_path.go index 6e1bef6e..3a4c3bfb 100644 --- a/integration/agentcompat/internal/fixture/agent_path.go +++ b/integration/agentcompat/internal/fixture/agent_path.go @@ -70,6 +70,11 @@ func (root AgentRoot) newPath(relative string, destructive bool) (AgentPath, err if err := ensureRealParentDirectories(root.absolute, nativeRelative); err != nil { return AgentPath{}, err } + if info, err := os.Lstat(absolute); err == nil && info.Mode()&os.ModeSymlink != 0 { + return AgentPath{}, rejectPath(PathRejectionSymlinkFinal) + } else if err != nil && !errors.Is(err, os.ErrNotExist) { + return AgentPath{}, fmt.Errorf("inspect agent fixture path: %w", err) + } return AgentPath{absolute: absolute, relative: nativeRelative}, nil } diff --git a/integration/agentcompat/internal/fixture/agent_path_test.go b/integration/agentcompat/internal/fixture/agent_path_test.go index d274b273..d7c77051 100644 --- a/integration/agentcompat/internal/fixture/agent_path_test.go +++ b/integration/agentcompat/internal/fixture/agent_path_test.go @@ -97,6 +97,41 @@ func TestFixture_AgentPathRejectsSymlinkParent(t *testing.T) { assertPathRejection(t, err, PathRejectionSymlinkParent) } +func TestFixture_AgentPathRejectsSymlinkTarget(t *testing.T) { + // Given + root := newTestAgentRoot(t, "agent-symlink-target") + outside := filepath.Join(t.TempDir(), "outside.txt") + requireNoFixtureError(t, os.WriteFile(outside, []byte("outside"), 0o600)) + requireNoFixtureError(t, os.Symlink(outside, filepath.Join(root.Absolute(), "linked.txt"))) + + // When + _, err := root.Path("linked.txt") + + // Then + assertPathRejection(t, err, PathRejectionSymlinkFinal) +} + +func TestFixture_AgentPathRejectsExistingFinalSymlink(t *testing.T) { + // Given + root := newTestAgentRoot(t, "agent-final-symlink") + outside := filepath.Join(t.TempDir(), "outside.txt") + requireNoFixtureError(t, os.WriteFile(outside, []byte("unchanged"), 0o600)) + requireNoFixtureError(t, os.Symlink(outside, filepath.Join(root.Absolute(), "linked.txt"))) + + // When + _, pathErr := root.Path("linked.txt") + _, destructiveErr := root.DestructivePath("linked.txt") + + // Then + assertPathRejection(t, pathErr, PathRejectionSymlinkFinal) + assertPathRejection(t, destructiveErr, PathRejectionSymlinkFinal) + content, err := os.ReadFile(outside) + requireNoFixtureError(t, err) + if string(content) != "unchanged" { + t.Fatalf("final symlink target changed: %q", content) + } +} + func TestFixture_AgentPathRejectsCleanedEscape(t *testing.T) { // Given root := newTestAgentRoot(t, "agent-cleaned-escape") diff --git a/integration/agentcompat/internal/fixture/fixture_errors.go b/integration/agentcompat/internal/fixture/fixture_errors.go index 790e0683..d8408423 100644 --- a/integration/agentcompat/internal/fixture/fixture_errors.go +++ b/integration/agentcompat/internal/fixture/fixture_errors.go @@ -18,6 +18,7 @@ const ( PathRejectionDestructiveRoot PathRejectionReason = "destructive_root" PathRejectionEscape PathRejectionReason = "escape" PathRejectionSymlinkParent PathRejectionReason = "symlink_parent" + PathRejectionSymlinkFinal PathRejectionReason = "symlink_final" PathRejectionADS PathRejectionReason = "ads" ) diff --git a/integration/agentcompat/internal/fixture/nat_echo_test.go b/integration/agentcompat/internal/fixture/nat_echo_test.go index bab246a3..57d1dc6f 100644 --- a/integration/agentcompat/internal/fixture/nat_echo_test.go +++ b/integration/agentcompat/internal/fixture/nat_echo_test.go @@ -82,3 +82,53 @@ func TestFixture_NATEcho(t *testing.T) { t.Fatalf("NAT request record = %+v", record) } } + +func TestFixture_NATEchoCloseInterruptsIncompleteRequest(t *testing.T) { + // Given + backend, err := StartNATEchoBackend() + requireNoFixtureError(t, err) + connection, err := net.DialTimeout("tcp", backend.Address(), time.Second) + requireNoFixtureError(t, err) + defer connection.Close() + _, err = io.WriteString(connection, "GET /incomplete HTTP/1.1\r\nHost: nat.invalid\r\n") + requireNoFixtureError(t, err) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + requireNoFixtureError(t, backend.WaitConnection(ctx)) + + // When + closed := make(chan error, 1) + go func() { closed <- backend.Close() }() + + // Then + select { + case err := <-closed: + requireNoFixtureError(t, err) + case <-ctx.Done(): + t.Fatal("NAT echo close did not interrupt incomplete request") + } +} + +func TestFixture_NATEchoCloseTerminatesSockets(t *testing.T) { + // Given + backend, err := StartNATHalfCloseEchoBackend() + requireNoFixtureError(t, err) + address := backend.Address() + connection, err := net.DialTimeout("tcp", address, time.Second) + requireNoFixtureError(t, err) + requireNoFixtureError(t, connection.SetDeadline(time.Now().Add(time.Second))) + + // When + requireNoFixtureError(t, backend.Close()) + + // Then + buffer := make([]byte, 1) + if _, err := connection.Read(buffer); err == nil { + t.Fatal("active NAT socket remained readable after backend close") + } + requireNoFixtureError(t, connection.Close()) + if connection, err := net.DialTimeout("tcp", address, 100*time.Millisecond); err == nil { + _ = connection.Close() + t.Fatal("NAT listener accepted a connection after backend close") + } +}