Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion internal/dockerdeploy/identity_mapping_guard.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import (
// A portable declaration is intent, including when its leaves are unresolved.
// Later reviewed runtime admission must replace this guard as one complete cut.
func unmappedRuntimeDocumentV1(state deploy.StateV1) (blueprint.Document, error) {
document, err := blueprint.DecodeResolvedDocumentV1(state.Blueprint)
// The public guard continues to inhibit even fresh mapped entry. The private
// mode boundary adds no public activation or occupied-exec qualification.
document, err := runtimeDocumentForEntryModeV1(state, mappedEntryFreshInstanceV1)
if err != nil {
return blueprint.Document{}, err
}
Expand Down
55 changes: 55 additions & 0 deletions internal/dockerdeploy/mapped_entry.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package dockerdeploy

import (
"errors"
"fmt"

"github.com/omry/reploy/internal/blueprint"
"github.com/omry/reploy/internal/deploy"
)

// Entry mode describes the operation's required execution context. It is
// selected by trusted entry code, never by a public qualification flag. Do not
// replace occupied entry with a fresh instance when its mode is unsupported.
type mappedEntryModeV1 uint8

const (
mappedEntryFreshInstanceV1 mappedEntryModeV1 = iota + 1
mappedEntryOccupiedExecV1
mappedEntryHostNativeV1
)

var (
errMappedOccupiedEntryV1 = errors.New("mapped occupied-container entry is unsupported: no qualified exec transition")
errMappedHostNativeEntryV1 = errors.New("mapped host-native entry is unsupported")
)

// runtimeDocumentForEntryModeV1 consumes the existing owner's locked state.
// Declaration alone counts as intent, including unresolved mapping leaves;
// retained mapping authority still counts if the declaration was removed.
//
// This is a pure refusal boundary, not admission. Fresh instance classification
// grants no permission to create, release or attach. Public inhibition and
// fresh mapped admission remain separate mandatory checks. In particular,
// caller capability lists, UID strings and expected-state IDs are not inputs.
func runtimeDocumentForEntryModeV1(state deploy.StateV1, mode mappedEntryModeV1) (blueprint.Document, error) {
switch mode {
case mappedEntryFreshInstanceV1, mappedEntryOccupiedExecV1, mappedEntryHostNativeV1:
default:
return blueprint.Document{}, fmt.Errorf("unsupported runtime entry mode %d", mode)
}
document, err := blueprint.DecodeResolvedDocumentV1(state.Blueprint)
if err != nil {
return blueprint.Document{}, err
}
mapped := state.IdentityMapping != nil || document.Environment.Runtime.IdentityMapping != nil || document.Environment.Runtime.IdentityPolicy != nil
if mapped {
switch mode {
case mappedEntryOccupiedExecV1:
return blueprint.Document{}, fmt.Errorf("environment %q: %w", document.Environment.ID, errMappedOccupiedEntryV1)
case mappedEntryHostNativeV1:
return blueprint.Document{}, fmt.Errorf("environment %q: %w", document.Environment.ID, errMappedHostNativeEntryV1)
}
}
return document, nil
}
167 changes: 167 additions & 0 deletions internal/dockerdeploy/mapped_entry_linux_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
//go:build linux

package dockerdeploy

import (
"bufio"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"
"time"

"golang.org/x/sys/unix"
)

// This owned same-UID peer is deliberately outside a product container. Future
// occupied-entry proposals must test proc/ptrace and capability access against
// such an existing process, not only against their newly started helper. The
// current slice qualifies refusal only; it performs no authority transition.
func TestMappedOccupiedPeerV1(t *testing.T) {
if os.Getenv("REPLOY_TEST_OCCUPIED_PEER") != "child" {
return
}
fmt.Fprintln(os.Stdout, "ready")
_, _ = io.Copy(io.Discard, os.Stdin)
os.Exit(0)
}

type mappedOccupiedHarnessV1 struct {
pid int
privatePath string
descriptors []*os.File
status []byte
procError error
}

// Retain real private file/directory/socket/pidfd/high descriptors and a live
// same-UID peer. No fixture value is production evidence or a qualification
// flag. A future successful transition needs separately reviewed probes for
// proc/ptrace access and leaks; today's gate must reach none of those effects.
func newMappedOccupiedHarnessV1(t *testing.T) mappedOccupiedHarnessV1 {
t.Helper()
h := mappedOccupiedHarnessV1{privatePath: filepath.Join(t.TempDir(), "private")}
if err := os.WriteFile(h.privatePath, []byte("private-entry-canary"), 0600); err != nil {
t.Fatal(err)
}
retain := func(file *os.File) {
h.descriptors = append(h.descriptors, file)
t.Cleanup(func() { _ = file.Close() })
}
for _, name := range []string{h.privatePath, filepath.Dir(h.privatePath)} {
file, err := os.Open(name)
if err != nil {
t.Fatal(err)
}
retain(file)
}
sockets, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
if err != nil {
t.Fatal(err)
}
for _, fd := range sockets {
retain(os.NewFile(uintptr(fd), "private-socket"))
}
high, err := unix.FcntlInt(h.descriptors[0].Fd(), unix.F_DUPFD_CLOEXEC, 4096)
if err != nil {
t.Fatalf("high descriptor fixture unavailable (no qualification): %v", err)
}
retain(os.NewFile(uintptr(high), "private-high-file"))
// testing cancels t.Context before cleanup runs. Keep the owned peer alive
// until our cleanup closes its input, while retaining a hard reap deadline.
ctx, cancel := context.WithTimeout(context.WithoutCancel(t.Context()), 15*time.Second)
t.Cleanup(cancel)
self, err := os.Executable()
if err != nil {
t.Fatal(err)
}
peer := exec.CommandContext(ctx, self, "-test.run=^TestMappedOccupiedPeerV1$")
peer.Env = append(os.Environ(), "REPLOY_TEST_OCCUPIED_PEER=child")
input, err := peer.StdinPipe()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = input.Close() })
output, err := peer.StdoutPipe()
if err != nil {
t.Fatal(err)
}
if err := peer.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = input.Close()
if err := peer.Wait(); err != nil {
t.Errorf("reap owned peer: %v", err)
}
})
line, err := bufio.NewReader(output).ReadString('\n')
if err != nil || line != "ready\n" {
t.Fatalf("owned peer readiness: %q %v", line, err)
}
h.pid = peer.Process.Pid
pidfd, err := unix.PidfdOpen(h.pid, 0)
if err != nil {
t.Fatalf("pidfd fixture unavailable (no qualification): %v", err)
}
retain(os.NewFile(uintptr(pidfd), "private-peer-pidfd"))
h.status, err = os.ReadFile(fmt.Sprintf("/proc/%d/status", h.pid))
if err != nil {
t.Fatal(err)
}
for _, field := range []string{"Uid:", "Gid:", "CapPrm:", "CapEff:", "CapBnd:", "CapInh:", "CapAmb:", "TracerPid:"} {
if !strings.Contains(string(h.status), field) {
t.Fatalf("peer status lacks %s", field)
}
}
uidLine := fmt.Sprintf("Uid:\t%d\t%d\t%d\t%d", os.Getuid(), os.Geteuid(), os.Geteuid(), os.Geteuid())
if !strings.Contains(string(h.status), uidLine) {
t.Fatal("fixture peer does not share current UID")
}
mem, err := os.Open(fmt.Sprintf("/proc/%d/mem", h.pid))
h.procError = err
if err == nil {
retain(mem)
}
return h
}

func TestMappedOccupiedRefusalWithLiveAdversarialSurfacesV1(t *testing.T) {
h := newMappedOccupiedHarnessV1(t)
t.Logf("owned same-UID peer=%d, proc-mem open=%v; no ptrace or exec qualification claimed", h.pid, h.procError)
for _, kind := range []string{"declaration", "unresolved", "policy", "retained"} {
state := mappedEntryStateFixtureV1(t, kind)
for _, mode := range []mappedEntryModeV1{mappedEntryOccupiedExecV1, mappedEntryHostNativeV1} {
_, err := runtimeDocumentForEntryModeV1(state, mode)
if !errors.Is(err, errMappedOccupiedEntryV1) && !errors.Is(err, errMappedHostNativeEntryV1) {
t.Fatal("adversarial occupied entry did not refuse", err)
}
}
// Exercise a real public entry, with all runtime callbacks instrumented.
// Its broad public guard must still stop before build, helpers, engine
// create/start/attach or a fresh-container fallback can be selected.
current, _ := runtimeCurrentBuildFixture(t)
current.State = state
dir := t.TempDir()
var order []string
err := runCurrentShellV1(t.Context(), CurrentShellRunInputV1{DeploymentDir: dir}, currentShellRunTestBackend(t, dir, current, CurrentRuntimePlanV1{}, &order))
if err == nil || !reflect.DeepEqual(order, []string{"acquire", "store", "state"}) {
t.Fatalf("public mapped shell reached effects: %v %v", order, err)
}
}
data, err := os.ReadFile(h.privatePath)
if err != nil || string(data) != "private-entry-canary" {
t.Fatal("private fixture changed during refusal", err)
}
for _, file := range h.descriptors {
if _, err := file.Stat(); err != nil {
t.Fatalf("private descriptor damaged during refusal: %s: %v", file.Name(), err)
}
}
}
92 changes: 92 additions & 0 deletions internal/dockerdeploy/mapped_entry_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package dockerdeploy

import (
"encoding/json"
"errors"
"reflect"
"strings"
"testing"

"github.com/omry/reploy/internal/blueprint"
"github.com/omry/reploy/internal/deploy"
)

func mappedEntryStateFixtureV1(t *testing.T, kind string) deploy.StateV1 {
t.Helper()
current, _ := runtimeCurrentBuildFixture(t)
state := identityGuardDocumentV1(t, current.State, false, false)
switch kind {
case "ordinary":
case "declaration":
state = identityGuardDocumentV1(t, state, true, false)
case "unresolved":
state = identityGuardDocumentV1(t, state, true, true)
case "policy":
state = policyGuardStateV1(t, state)
case "retained":
// A declaration removed from the blueprint cannot erase retained
// mapping authority, even if the remaining state is incomplete.
state.IdentityMapping = &deploy.IdentityMappingStateV1{}
default:
t.Fatalf("unknown entry fixture %q", kind)
}
return state
}

func TestMappedEntryModesV1(t *testing.T) {
for _, kind := range []string{"ordinary", "declaration", "unresolved", "policy", "retained"} {
t.Run(kind, func(t *testing.T) {
state := mappedEntryStateFixtureV1(t, kind)
before, err := json.Marshal(state)
if err != nil {
t.Fatal(err)
}
wantDocument, err := blueprint.DecodeResolvedDocumentV1(state.Blueprint)
if err != nil {
t.Fatal(err)
}
for _, mode := range []mappedEntryModeV1{mappedEntryFreshInstanceV1, mappedEntryOccupiedExecV1, mappedEntryHostNativeV1} {
document, err := runtimeDocumentForEntryModeV1(state, mode)
if kind == "ordinary" || mode == mappedEntryFreshInstanceV1 {
if err != nil || !reflect.DeepEqual(document, wantDocument) {
t.Fatalf("mode %d changed ordinary document or fresh classification: %v", mode, err)
}
} else {
want := errMappedOccupiedEntryV1
if mode == mappedEntryHostNativeV1 {
want = errMappedHostNativeEntryV1
}
if !errors.Is(err, want) || !reflect.DeepEqual(document, blueprint.Document{}) {
t.Fatalf("mode %d returned document or wrong refusal: %v", mode, err)
}
}
}
after, err := json.Marshal(state)
if err != nil || string(before) != string(after) {
t.Fatal("entry classification changed owner state", err)
}
// Fresh classification cannot bypass the unchanged public guard.
_, err = unmappedRuntimeDocumentV1(state)
if (err == nil) != (kind == "ordinary") {
t.Fatalf("public guard changed for %s: %v", kind, err)
}
})
}
}

func TestMappedEntryInvalidModeAndBlueprintV1(t *testing.T) {
for _, kind := range []string{"ordinary", "retained", "policy"} {
state := mappedEntryStateFixtureV1(t, kind)
for _, mode := range []mappedEntryModeV1{0, 4, 255} {
if _, err := runtimeDocumentForEntryModeV1(state, mode); err == nil || !strings.Contains(err.Error(), "unsupported runtime entry mode") {
t.Fatalf("unknown mode %d accepted: %v", mode, err)
}
}
state.Blueprint = blueprint.ResolvedDocumentV1("")
for _, mode := range []mappedEntryModeV1{mappedEntryFreshInstanceV1, mappedEntryOccupiedExecV1, mappedEntryHostNativeV1} {
if document, err := runtimeDocumentForEntryModeV1(state, mode); err == nil || !reflect.DeepEqual(document, blueprint.Document{}) {
t.Fatalf("malformed owner blueprint returned a document in mode %d: %v", mode, err)
}
}
}
}
Loading