diff --git a/.changes/unreleased/+multi-identity-mapping-geometry.yaml b/.changes/unreleased/+multi-identity-mapping-geometry.yaml new file mode 100644 index 00000000..a54abe44 --- /dev/null +++ b/.changes/unreleased/+multi-identity-mapping-geometry.yaml @@ -0,0 +1,2 @@ +kind: Added +body: Check numeric identity mapping geometry and protect retained ID assignments against reassignment. Mapped execution remains unavailable. diff --git a/internal/deploy/identity_mapping_geometry.go b/internal/deploy/identity_mapping_geometry.go new file mode 100644 index 00000000..b83b0628 --- /dev/null +++ b/internal/deploy/identity_mapping_geometry.go @@ -0,0 +1,43 @@ +package deploy + +import ( + "fmt" + "strconv" + + "github.com/omry/reploy/internal/blueprint" + "github.com/omry/reploy/internal/identitymapping" +) + +// ResolveIdentityMappingGeometryV1 checks one operation's effective input. The +// numeric result grants neither protected reservations nor execution admission. +func ResolveIdentityMappingGeometryV1(document blueprint.Document, override *blueprint.IdentityMappingOverrideV1) (*identitymapping.Geometry, error) { + resolved, err := blueprint.ResolveIdentityMappingV1(document, override) + if err != nil || resolved == nil { + return nil, err + } + geometry := identitymapping.Geometry{Profile: resolved.Profile.Value} + for i, values := range [][]blueprint.MappingIntervalV1{resolved.UIDs, resolved.GIDs} { + intervals := make([]identitymapping.Interval, len(values)) + for j, value := range values { + fields := [3]uint32{} + for k, scalar := range []blueprint.MappingScalarV1{value.ContainerStart, value.HostStart, value.Count} { + number, err := strconv.ParseUint(scalar.Value, 10, 32) + if err != nil || scalar.Kind != "integer" || strconv.FormatUint(number, 10) != scalar.Value { + return nil, fmt.Errorf("mapping %s[%d] requires resolved integer IDs", []string{"uids", "gids"}[i], j) + } + fields[k] = uint32(number) + } + intervals[j] = identitymapping.Interval{ContainerStart: fields[0], HostStart: fields[1], Count: fields[2]} + } + if i == 0 { + geometry.UIDs = intervals + } else { + geometry.GIDs = intervals + } + } + checked, err := identitymapping.ValidateGeometry(geometry) + if err != nil { + return nil, fmt.Errorf("environment %q mapping geometry: %w", document.Environment.ID, err) + } + return &checked, nil +} diff --git a/internal/deploy/identity_mapping_geometry_test.go b/internal/deploy/identity_mapping_geometry_test.go new file mode 100644 index 00000000..516bd1a0 --- /dev/null +++ b/internal/deploy/identity_mapping_geometry_test.go @@ -0,0 +1,87 @@ +package deploy + +import ( + "reflect" + "strings" + "testing" + + "github.com/omry/reploy/internal/blueprint" + "github.com/omry/reploy/internal/identitymapping" + "gopkg.in/yaml.v3" +) + +func geometryDocumentForTest(t *testing.T, input string) blueprint.Document { + t.Helper() + var mapping blueprint.IdentityMappingV1 + if err := yaml.Unmarshal([]byte(input), &mapping); err != nil { + t.Fatal(err) + } + return blueprint.Document{Environment: blueprint.Environment{ID: "demo", Runtime: blueprint.EnvironmentRuntime{IdentityMapping: &mapping}}} +} + +func TestResolveMappingGeometryUsesTypedOverrideFirstInput(t *testing.T) { + document := geometryDocumentForTest(t, `{schema: 1, profile: "???", uids: [{container_start: 0, host_start: "???", count: 1}], gids: [{container_start: 0, host_start: 400000, count: 2}]}`) + overrides := decodeMappingSidecarV1(t, mappingSidecarV1(`{profile: exact, uids: [{container_start: 5, host_start: 300005, count: 2}, {container_start: 3, host_start: 300003, count: 2}]}`)) + before, err := yaml.Marshal(document) + if err != nil { + t.Fatal(err) + } + got, err := ResolveIdentityMappingGeometryV1(document, overrides.IdentityMappingOverride()) + want := identitymapping.Geometry{Profile: "exact", UIDs: []identitymapping.Interval{{ContainerStart: 3, HostStart: 300003, Count: 4}}, GIDs: []identitymapping.Interval{{ContainerStart: 0, HostStart: 400000, Count: 2}}} + if err != nil || got == nil || !reflect.DeepEqual(*got, want) { + t.Fatalf("numeric operation candidate = %#v / %v", got, err) + } + after, err := yaml.Marshal(document) + if err != nil || string(before) != string(after) { + t.Fatalf("numeric resolution mutated source authority: %v", err) + } + // Converting input does not accept or replace retained assignments. + retained := want + retained.UIDs = []identitymapping.Interval{{ContainerStart: 3, HostStart: 500003, Count: 4}} + if _, err := identitymapping.RetainGeometry(*got, &retained); err == nil { + t.Fatal("resolved input silently replaced established assignment") + } +} + +func TestResolveMappingGeometryPreservesVariableKindsAndMissingPaths(t *testing.T) { + for _, tc := range []struct { + name string + value any + kind string + text string + valid bool + }{ + {"integer", 9, "integer", "9", true}, + {"integral float", 9.0, "float", "9", false}, + {"numeric string", "9", "string", "9", false}, + {"boolean", true, "bool", "true", false}, + {"missing", "???", "missing", "???", false}, + } { + t.Run(tc.name, func(t *testing.T) { + document := geometryDocumentForTest(t, `{schema: 1, profile: exact, uids: [{container_start: "{{ start }}", host_start: 300000, count: 2}], gids: [{container_start: 0, host_start: 400000, count: 2}]}`) + document.Environment.Vars = map[string]any{"start": tc.value} + document.Environment.Runtime.IdentityMapping.Variables = map[string]blueprint.MappingScalarV1{"start": {Kind: tc.kind, Value: tc.text}} + got, err := ResolveIdentityMappingGeometryV1(document, nil) + if (err == nil) != tc.valid || !tc.valid && got != nil { + t.Fatalf("incorrect type acceptance: %#v / %v", got, err) + } + if tc.valid && got.UIDs[0].ContainerStart != 9 { + t.Fatalf("integer binding lost: %#v", got) + } + if tc.kind == "missing" && !strings.Contains(err.Error(), "uids[0].container_start") { + t.Fatalf("missing consumer path lost: %v", err) + } + }) + } +} + +func TestResolveMappingGeometryOrdinaryAndUndeclaredControls(t *testing.T) { + document := blueprint.Document{Environment: blueprint.Environment{ID: "demo"}} + if got, err := ResolveIdentityMappingGeometryV1(document, nil); err != nil || got != nil { + t.Fatalf("ordinary input changed: %#v / %v", got, err) + } + overrides := decodeMappingSidecarV1(t, mappingSidecarV1(`{profile: exact}`)) + if got, err := ResolveIdentityMappingGeometryV1(document, overrides.IdentityMappingOverride()); err == nil || got != nil { + t.Fatalf("undeclared input accepted: %#v / %v", got, err) + } +} diff --git a/internal/dockerdeploy/identity_mapping_input.go b/internal/dockerdeploy/identity_mapping_input.go index 832ae151..1904f433 100644 --- a/internal/dockerdeploy/identity_mapping_input.go +++ b/internal/dockerdeploy/identity_mapping_input.go @@ -5,11 +5,12 @@ import ( "github.com/omry/reploy/internal/blueprint" "github.com/omry/reploy/internal/deploy" + "github.com/omry/reploy/internal/identitymapping" ) // This private metadata seam does not allocate IDs or admit a workload. // B5 will bind its candidate to the selected generation and mapping revision. -func loadStagedIdentityMappingV1(operation *deploy.OperationLock, dir string) (*blueprint.IdentityMappingV1, error) { +func loadStagedIdentityMappingV1(operation *deploy.OperationLock, dir string) (*identitymapping.Geometry, error) { if operation == nil { return nil, fmt.Errorf("mapping input requires a deployment operation lock") } @@ -28,5 +29,5 @@ func loadStagedIdentityMappingV1(operation *deploy.OperationLock, dir string) (* if err != nil { return nil, err } - return blueprint.ResolveIdentityMappingV1(document, overrides.IdentityMappingOverride()) + return deploy.ResolveIdentityMappingGeometryV1(document, overrides.IdentityMappingOverride()) } diff --git a/internal/dockerdeploy/identity_mapping_input_test.go b/internal/dockerdeploy/identity_mapping_input_test.go index 10a2747d..28a7eaa2 100644 --- a/internal/dockerdeploy/identity_mapping_input_test.go +++ b/internal/dockerdeploy/identity_mapping_input_test.go @@ -12,6 +12,7 @@ import ( "github.com/omry/reploy/internal/blueprint" "github.com/omry/reploy/internal/deploy" + "github.com/omry/reploy/internal/identitymapping" "github.com/omry/reploy/internal/providerstore" ) @@ -45,7 +46,7 @@ func TestMappingInputUsesLockedSelectedStateAndHasNoEffects(t *testing.T) { } before := identityGuardTreeV1(t, dir) mapping, err := loadStagedIdentityMappingV1(operation, dir) - if err != nil || mapping.UIDs[0].HostStart.Value != "300000" { + if err != nil || mapping.UIDs[0].HostStart != 300000 { t.Fatalf("effective selected input = %#v / %v", mapping, err) } after := identityGuardTreeV1(t, dir) @@ -102,11 +103,51 @@ func TestMappingInputCapturedSidecarDoesNotRereadChangedFile(t *testing.T) { t.Fatalf("captured bytes were replaced during resolution: %#v / %v", oldCandidate, err) } newCandidate, err := loadStagedIdentityMappingV1(operation, dir) - if err != nil || newCandidate.UIDs[0].HostStart.Value != "400000" { + if err != nil || newCandidate.UIDs[0].HostStart != 400000 { t.Fatalf("new operation did not capture current input: %#v / %v", newCandidate, err) } } +func TestMappingInputNumericGeometryKeepsProtectedReservationsSeparate(t *testing.T) { + dir, state := identityGuardFixtureV1(t, true, false) + document, err := blueprint.DecodeResolvedDocumentV1(state.Blueprint) + if err != nil { + t.Fatal(err) + } + operation, err := deploy.AcquireOperationLock(t.Context(), dir) + if err != nil { + t.Fatal(err) + } + defer operation.Unlock() + if err := operation.CommitPackageOverridesV1(mappingInputOverridesV1(t, document.Environment.ID, "300000")); err != nil { + t.Fatal(err) + } + before := identityGuardTreeV1(t, dir) + candidate, err := loadStagedIdentityMappingV1(operation, dir) + if err != nil || candidate == nil || candidate.Profile != "exact" { + t.Fatalf("numeric staging candidate unavailable: %#v / %v", candidate, err) + } + protected := identitymapping.Geometry{Profile: "exact", UIDs: []identitymapping.Interval{{ContainerStart: 0, HostStart: 300000, Count: 32}}, GIDs: []identitymapping.Interval{{ContainerStart: 0, HostStart: 200000, Count: 32}}} + reserved, err := identitymapping.RetainGeometry(*candidate, &protected) + if err != nil || reserved.UIDs[0].Count != 32 || reserved.GIDs[0].Count != 32 || candidate.UIDs[0].Count != 16 { + t.Fatalf("candidate omitted protected IDs or gained authority: %#v / %#v / %v", reserved, candidate, err) + } + protected.UIDs[0].HostStart = 500000 + if _, err := identitymapping.RetainGeometry(*candidate, &protected); err == nil { + t.Fatal("new input silently replaced protected host assignments") + } + if !maps.Equal(before, identityGuardTreeV1(t, dir)) { + t.Fatal("geometry comparison changed deployment state or files") + } + current, found, err := operation.ReadStateV1() + if err != nil || !found { + t.Fatalf("selected state lost: %v", err) + } + if _, err := unmappedRuntimeDocumentV1(current); err == nil { + t.Fatal("numeric mapping validation granted payload admission") + } +} + func TestMappingInputRejectsInstalledAndUndeclaredOwners(t *testing.T) { for _, installed := range []bool{false, true} { dir, state := identityGuardFixtureV1(t, false, installed) diff --git a/internal/identitymapping/geometry.go b/internal/identitymapping/geometry.go new file mode 100644 index 00000000..d4a31033 --- /dev/null +++ b/internal/identitymapping/geometry.go @@ -0,0 +1,146 @@ +package identitymapping + +import ( + "cmp" + "fmt" + "slices" +) + +// Geometry is numeric mapping input, not host authority or execution admission. +// UID and GID assignments occupy separate collision domains. +type Geometry struct { + Profile string + UIDs []Interval + GIDs []Interval +} + +// ValidateGeometry returns an independent canonical copy. Work scales with +// explicit intervals, never the number of IDs represented by an interval. +func ValidateGeometry(value Geometry) (Geometry, error) { + if value.Profile != "exact" && value.Profile != "bounded" { + return Geometry{}, fmt.Errorf("mapping profile must be exact or bounded") + } + result := Geometry{Profile: value.Profile} + for i, values := range [][]Interval{value.UIDs, value.GIDs} { + kind := []string{"UID", "GID"}[i] + if len(values) == 0 || value.Profile == "bounded" && len(values) != 1 { + return Geometry{}, fmt.Errorf("%s mapping requires nonempty intervals; bounded requires exactly one", kind) + } + ordered := slices.Clone(values) + sortContainerIntervals(ordered) + for j, interval := range ordered { + if err := ValidateInterval(interval); err != nil { + return Geometry{}, fmt.Errorf("%s geometry: %w", kind, err) + } + if j > 0 && uint64(interval.ContainerStart) < containerEnd(ordered[j-1]) { + return Geometry{}, fmt.Errorf("%s container intervals overlap", kind) + } + } + if _, err := hostSpans(ordered); err != nil { + return Geometry{}, fmt.Errorf("%s geometry: %w", kind, err) + } + // Non-overlapping adjacent intervals may describe one affine binding. + canonical, err := combineReservations(ordered) + if err != nil { + return Geometry{}, err + } + if i == 0 { + result.UIDs = canonical + } else { + result.GIDs = canonical + } + } + return result, nil +} + +// RetainGeometry checks a proposed mapping against protected reservations and +// returns their union. Omitting an assignment never releases its reservation. +// The result does not classify IDs as active or dormant. In particular, a +// never-admitted candidate is not evidence of a previously admitted identity. +// Callers must retain admission and pending-operation provenance separately. +func RetainGeometry(proposed Geometry, retained *Geometry) (Geometry, error) { + candidate, err := ValidateGeometry(proposed) + if err != nil { + return Geometry{}, fmt.Errorf("proposed mapping: %w", err) + } + if retained == nil { + return candidate, nil + } + previous, err := ValidateGeometry(*retained) + if err != nil { + return Geometry{}, fmt.Errorf("retained mapping: %w", err) + } + if candidate.Profile != previous.Profile { + return Geometry{}, fmt.Errorf("retained mapping profile cannot change") + } + if candidate.Profile == "bounded" { + if !slices.Equal(candidate.UIDs, previous.UIDs) || !slices.Equal(candidate.GIDs, previous.GIDs) { + return Geometry{}, fmt.Errorf("retained bounded mapping geometry cannot change") + } + return previous, nil + } + result := Geometry{Profile: candidate.Profile} + for i, values := range [][]Interval{candidate.UIDs, candidate.GIDs} { + protected := previous.UIDs + if i == 1 { + protected = previous.GIDs + } + combined := append(slices.Clone(protected), values...) + sortContainerIntervals(combined) + reserved, err := combineReservations(combined) + if err != nil { + return Geometry{}, fmt.Errorf("%s retained mapping: %w", []string{"UID", "GID"}[i], err) + } + // A different container ID cannot take a retained host ID, including + // one omitted by the new candidate. UID and GID checks stay separate. + if _, err := hostSpans(reserved); err != nil { + return Geometry{}, fmt.Errorf("%s retained mapping: %w", []string{"UID", "GID"}[i], err) + } + if i == 0 { + result.UIDs = reserved + } else { + result.GIDs = reserved + } + } + return result, nil +} + +func containerEnd(value Interval) uint64 { + return uint64(value.ContainerStart) + uint64(value.Count) +} + +func sortContainerIntervals(values []Interval) { + slices.SortFunc(values, func(a, b Interval) int { + return cmp.Compare(a.ContainerStart, b.ContainerStart) + }) +} + +func bindingOffset(value Interval) int64 { + return int64(value.HostStart) - int64(value.ContainerStart) +} + +// Inputs have individually validated arithmetic and are container-sorted. +// Compare affine bindings on intersections, including partial intersections. +func combineReservations(values []Interval) ([]Interval, error) { + result := make([]Interval, 0, len(values)) + for _, value := range values { + if len(result) == 0 { + result = append(result, value) + continue + } + last := &result[len(result)-1] + overlaps := uint64(value.ContainerStart) < containerEnd(*last) + adjacent := uint64(value.ContainerStart) == containerEnd(*last) + sameBinding := bindingOffset(value) == bindingOffset(*last) + if overlaps && !sameBinding { + return nil, fmt.Errorf("established container-to-host assignment cannot change") + } + if sameBinding && (overlaps || adjacent) { + end := max(containerEnd(*last), containerEnd(value)) + last.Count = uint32(end - uint64(last.ContainerStart)) + } else { + result = append(result, value) + } + } + return result, nil +} diff --git a/internal/identitymapping/geometry_test.go b/internal/identitymapping/geometry_test.go new file mode 100644 index 00000000..2563ce81 --- /dev/null +++ b/internal/identitymapping/geometry_test.go @@ -0,0 +1,269 @@ +package identitymapping + +import ( + "math" + "math/rand" + "reflect" + "slices" + "testing" +) + +func geometryForTest(profile string, values ...Interval) Geometry { + return Geometry{Profile: profile, UIDs: slices.Clone(values), GIDs: slices.Clone(values)} +} + +func cloneGeometryForTest(value Geometry) Geometry { + return Geometry{Profile: value.Profile, UIDs: slices.Clone(value.UIDs), GIDs: slices.Clone(value.GIDs)} +} + +func TestGeometryRejectsInvalidInputWithoutMutation(t *testing.T) { + valid := geometryForTest("exact", Interval{0, 100, 10}) + cases := map[string]Geometry{ + "missing profile": {}, + "unknown profile": geometryForTest("auto", Interval{0, 100, 10}), + "empty UIDs": {Profile: "exact", GIDs: valid.GIDs}, + "empty GIDs": {Profile: "exact", UIDs: valid.UIDs}, + "bounded UID split": {Profile: "bounded", UIDs: []Interval{{0, 100, 5}, {5, 105, 5}}, GIDs: valid.GIDs}, + "bounded GID split": {Profile: "bounded", UIDs: valid.UIDs, GIDs: []Interval{{0, 100, 5}, {5, 105, 5}}}, + "zero count": geometryForTest("exact", Interval{0, 100, 0}), + "container sentinel": geometryForTest("exact", Interval{math.MaxUint32, 100, 1}), + "host sentinel": geometryForTest("exact", Interval{0, math.MaxUint32, 1}), + "container overflow": geometryForTest("exact", Interval{math.MaxUint32 - 1, 100, 2}), + "host overflow": geometryForTest("exact", Interval{0, math.MaxUint32 - 1, 2}), + "host root": geometryForTest("exact", Interval{0, 0, 1}), + "container overlap": geometryForTest("exact", Interval{5, 200, 4}, Interval{0, 100, 8}), + "duplicate container": geometryForTest("exact", Interval{0, 100, 1}, Interval{0, 200, 1}), + "host overlap": geometryForTest("exact", Interval{0, 100, 8}, Interval{20, 104, 8}), + "duplicate host": geometryForTest("exact", Interval{0, 100, 1}, Interval{20, 100, 1}), + "equal affine overlap": geometryForTest("exact", Interval{0, 100, 8}, Interval{4, 104, 8}), + "GID host root": {Profile: "exact", UIDs: valid.UIDs, GIDs: []Interval{{0, 0, 1}}}, + "GID overlap": {Profile: "exact", UIDs: valid.UIDs, GIDs: []Interval{{0, 100, 8}, {20, 104, 8}}}, + } + for name, value := range cases { + t.Run(name, func(t *testing.T) { + before := cloneGeometryForTest(value) + got, err := ValidateGeometry(value) + if err == nil || !reflect.DeepEqual(got, Geometry{}) { + t.Fatalf("invalid input returned authority: %#v / %v", got, err) + } + if !reflect.DeepEqual(value, before) { + t.Fatal("validation mutated input") + } + }) + } +} + +func TestGeometryCanonicalizesAndDoesNotAliasInputs(t *testing.T) { + value := Geometry{ + Profile: "exact", + UIDs: []Interval{{12, 112, 3}, {2, 102, 3}, {0, 100, 2}}, + GIDs: []Interval{{4, 400, 2}, {0, 200, 1}}, + } + before := cloneGeometryForTest(value) + want := Geometry{Profile: "exact", UIDs: []Interval{{0, 100, 5}, {12, 112, 3}}, GIDs: []Interval{{0, 200, 1}, {4, 400, 2}}} + got, err := ValidateGeometry(value) + if err != nil || !reflect.DeepEqual(got, want) || !reflect.DeepEqual(value, before) { + t.Fatalf("canonical geometry = %#v / %v; source %#v", got, err, value) + } + again, err := ValidateGeometry(got) + if err != nil || !reflect.DeepEqual(again, got) { + t.Fatalf("normal form is not idempotent: %#v / %v", again, err) + } + again.UIDs[0].HostStart++ + got.GIDs[0].HostStart++ + if !reflect.DeepEqual(value, before) || got.UIDs[0] != want.UIDs[0] { + t.Fatal("result aliases source or a later result") + } +} + +func TestRetainGeometryProtectsAllEstablishedAssociations(t *testing.T) { + old := geometryForTest("exact", Interval{0, 100, 10}, Interval{20, 200, 5}) + for _, tc := range []struct { + name string + proposed Geometry + retained *Geometry + want Geometry + fail bool + }{ + {"first candidate", old, nil, old, false}, + {"unchanged", old, &old, old, false}, + {"equivalent splits", geometryForTest("exact", Interval{0, 100, 3}, Interval{3, 103, 7}, Interval{20, 200, 5}), &old, old, false}, + {"omitted assignment stays reserved", geometryForTest("exact", Interval{0, 100, 10}), &old, old, false}, + {"partial omission stays reserved", geometryForTest("exact", Interval{2, 102, 4}), &old, old, false}, + {"rollback subset", geometryForTest("exact", Interval{20, 200, 5}), &old, old, false}, + {"disjoint addition", geometryForTest("exact", Interval{40, 300, 3}), &old, geometryForTest("exact", Interval{0, 100, 10}, Interval{20, 200, 5}, Interval{40, 300, 3}), false}, + {"same binding extends", geometryForTest("exact", Interval{5, 105, 10}), &old, geometryForTest("exact", Interval{0, 100, 15}, Interval{20, 200, 5}), false}, + {"changed host", geometryForTest("exact", Interval{0, 300, 10}), &old, Geometry{}, true}, + {"partial reassignment", geometryForTest("exact", Interval{5, 300, 10}), &old, Geometry{}, true}, + {"omitted host reused", geometryForTest("exact", Interval{40, 200, 5}), &old, Geometry{}, true}, + {"partial host reuse", geometryForTest("exact", Interval{40, 198, 4}), &old, Geometry{}, true}, + {"host reuse starts inside", geometryForTest("exact", Interval{40, 204, 2}), &old, Geometry{}, true}, + {"profile changed", geometryForTest("bounded", Interval{0, 100, 10}), &old, Geometry{}, true}, + {"invalid candidate", geometryForTest("exact", Interval{0, 100, 0}), &old, Geometry{}, true}, + } { + t.Run(tc.name, func(t *testing.T) { + before := cloneGeometryForTest(tc.proposed) + var saved Geometry + if tc.retained != nil { + saved = cloneGeometryForTest(*tc.retained) + } + got, err := RetainGeometry(tc.proposed, tc.retained) + if (err != nil) != tc.fail || !reflect.DeepEqual(got, tc.want) { + t.Fatalf("retained = %#v / %v; want %#v, failure %v", got, err, tc.want, tc.fail) + } + if !reflect.DeepEqual(before, tc.proposed) || tc.retained != nil && !reflect.DeepEqual(saved, *tc.retained) { + t.Fatal("comparison mutated its inputs") + } + if err == nil { + got.UIDs[0].HostStart++ + got.GIDs[0].HostStart++ + if !reflect.DeepEqual(before, tc.proposed) || tc.retained != nil && !reflect.DeepEqual(saved, *tc.retained) { + t.Fatal("reservation result aliases its inputs") + } + } + }) + } +} + +func TestRetainGeometryRejectsMalformedProtectedAuthority(t *testing.T) { + candidate := geometryForTest("exact", Interval{0, 100, 3}) + for _, old := range []Geometry{ + {}, {Profile: "exact", UIDs: candidate.UIDs}, + geometryForTest("exact", Interval{0, 100, 0}), + geometryForTest("exact", Interval{0, 0, 1}), + geometryForTest("exact", Interval{0, 100, 3}, Interval{2, 200, 2}), + geometryForTest("exact", Interval{0, 100, 3}, Interval{20, 102, 2}), + } { + before := cloneGeometryForTest(old) + if got, err := RetainGeometry(candidate, &old); err == nil || !reflect.DeepEqual(got, Geometry{}) { + t.Fatalf("malformed protected input accepted: %#v / %v", got, err) + } + if !reflect.DeepEqual(before, old) { + t.Fatal("invalid protected input mutated") + } + } +} + +func TestBoundedGeometryCannotChangeInEitherIDDomain(t *testing.T) { + old := Geometry{Profile: "bounded", UIDs: []Interval{{0, 100, 20}}, GIDs: []Interval{{0, 200, 30}}} + got, err := RetainGeometry(old, &old) + if err != nil || !reflect.DeepEqual(got, old) { + t.Fatalf("unchanged bounded geometry rejected: %#v / %v", got, err) + } + for _, kind := range []string{"UID", "GID"} { + for _, field := range []string{"container", "host", "count"} { + t.Run(kind+"/"+field, func(t *testing.T) { + candidate := cloneGeometryForTest(old) + interval := &candidate.UIDs[0] + if kind == "GID" { + interval = &candidate.GIDs[0] + } + switch field { + case "container": + interval.ContainerStart++ + case "host": + interval.HostStart++ + case "count": + interval.Count++ + } + if _, err := RetainGeometry(candidate, &old); err == nil { + t.Fatal("bounded geometry change accepted") + } + }) + } + } + exact := cloneGeometryForTest(old) + exact.Profile = "exact" + if _, err := RetainGeometry(exact, &old); err == nil { + t.Fatal("bounded-to-exact profile change accepted") + } +} + +func TestGeometryKeepsUIDAndGIDDomainsIndependent(t *testing.T) { + old := Geometry{Profile: "exact", UIDs: []Interval{{0, 100, 2}}, GIDs: []Interval{{0, 200, 2}}} + candidate := Geometry{Profile: "exact", UIDs: []Interval{{20, 200, 2}}, GIDs: []Interval{{20, 100, 2}}} + got, err := RetainGeometry(candidate, &old) + if err != nil || len(got.UIDs) != 2 || len(got.GIDs) != 2 { + t.Fatalf("cross-domain numbers incorrectly collide: %#v / %v", got, err) + } + candidate.GIDs = []Interval{{20, 200, 2}} + if _, err := RetainGeometry(candidate, &old); err == nil { + t.Fatal("same-domain GID host reuse accepted") + } +} + +func TestGeometryLargeSpansAndSignedOffsets(t *testing.T) { + // This represents almost 2^32 IDs with one interval. No per-ID work or + // allocation is possible in the production comparison. + old := geometryForTest("exact", Interval{0, 1, math.MaxUint32 - 1}) + subset := geometryForTest("exact", Interval{math.MaxUint32 - 3, math.MaxUint32 - 2, 2}) + got, err := RetainGeometry(subset, &old) + if err != nil || !reflect.DeepEqual(got, old) { + t.Fatalf("large reservation comparison failed: %#v / %v", got, err) + } + negative := geometryForTest("exact", Interval{math.MaxUint32 - 2, 1, 1}) + addition := geometryForTest("exact", Interval{math.MaxUint32 - 1, 2, 1}) + want := geometryForTest("exact", Interval{math.MaxUint32 - 2, 1, 2}) + got, err = RetainGeometry(addition, &negative) + if err != nil || !reflect.DeepEqual(got, want) { + t.Fatalf("large negative offset failed: %#v / %v", got, err) + } + last := geometryForTest("exact", Interval{math.MaxUint32 - 1, math.MaxUint32 - 1, 1}) + if _, err := ValidateGeometry(last); err != nil { + t.Fatalf("last usable IDs rejected: %v", err) + } +} + +func TestRetainGeometryAgainstFiniteAssignmentOracle(t *testing.T) { + // The oracle deliberately enumerates only small test domains. Production + // uses interval geometry; the independent model checks both directions. + random := rand.New(rand.NewSource(8312)) + intervals := func() []Interval { + a, b := random.Perm(12), random.Perm(12) + values := make([]Interval, 1+random.Intn(6)) + for i := range values { + values[i] = Interval{uint32(a[i]), uint32(100 + b[i]), 1} + } + return values + } + assignments := func(values []Interval) map[uint32]uint32 { + result := map[uint32]uint32{} + for _, value := range values { + for n := uint32(0); n < value.Count; n++ { + result[value.ContainerStart+n] = value.HostStart + n + } + } + return result + } + merge := func(a, b []Interval) (map[uint32]uint32, bool) { + result, hosts := map[uint32]uint32{}, map[uint32]uint32{} + for _, values := range [][]Interval{a, b} { + for container, host := range assignments(values) { + if previous, ok := result[container]; ok && previous != host { + return nil, false + } + if previous, ok := hosts[host]; ok && previous != container { + return nil, false + } + result[container], hosts[host] = host, container + } + } + return result, true + } + for n := 0; n < 500; n++ { + old := Geometry{Profile: "exact", UIDs: intervals(), GIDs: intervals()} + candidate := Geometry{Profile: "exact", UIDs: intervals(), GIDs: intervals()} + if n%3 == 0 { + candidate = cloneGeometryForTest(old) + } + uids, uidOK := merge(old.UIDs, candidate.UIDs) + gids, gidOK := merge(old.GIDs, candidate.GIDs) + got, err := RetainGeometry(candidate, &old) + if (err == nil) != (uidOK && gidOK) { + t.Fatalf("oracle acceptance differs at %d: %#v / %#v / %v", n, old, candidate, err) + } + if err == nil && (!reflect.DeepEqual(assignments(got.UIDs), uids) || !reflect.DeepEqual(assignments(got.GIDs), gids)) { + t.Fatalf("oracle reservations differ at %d: %#v", n, got) + } + } +}