diff --git a/internal/qapi/profile.go b/internal/qapi/profile.go index 22974a8..8f0413b 100644 --- a/internal/qapi/profile.go +++ b/internal/qapi/profile.go @@ -139,3 +139,32 @@ func sameProfileInfo(before, after os.FileInfo) bool { func profileDirectoryOrFileInfo(info os.FileInfo) bool { return profileDirectoryInfo(info) || profileFileInfo(info) } + +// ProfileSelection reports the defaultProfile id and every profile id the +// user-owned profile below root defines, from the same single safe snapshot +// read route resolution uses. It reports ok=false when no source is present or +// when the source cannot be inspected or decoded safely, so a caller describes +// a refusal without opening a second, weaker read path (#374 S6). +// +// The ids come from qaroute.ProfileIDs and the default from +// qaroute.DefaultProfileID: the two existing decoders over one verified byte +// snapshot, never a second profile representation. +func ProfileSelection(root string) (string, []string, bool) { + source, ok := inspectProfileSource(root) + if !ok { + return "", nil, false + } + data, ok := source.read() + if !ok { + return "", nil, false + } + ids, ok := qaroute.ProfileIDs(data) + if !ok { + return "", nil, false + } + defaultID, ok := qaroute.DefaultProfileID(data) + if !ok { + return "", nil, false + } + return defaultID, ids, true +} diff --git a/internal/qapi/profile_test.go b/internal/qapi/profile_test.go index 6f3321b..22ca5d5 100644 --- a/internal/qapi/profile_test.go +++ b/internal/qapi/profile_test.go @@ -153,3 +153,55 @@ func writeProfile(t *testing.T, root string, data []byte) { t.Fatal(err) } } + +// TestProfileSelectionReportsTheSameSnapshotResolutionReads pins the S6 (#374) +// listing seam: the default id and the sorted admitted ids come from the one +// safe snapshot read the route policy uses, so a refusal enumerates ids the +// policy itself accepts instead of a second, weaker representation. +func TestProfileSelectionReportsTheSameSnapshotResolutionReads(t *testing.T) { + root := testProfileRoot(t) + writeProfile(t, root, []byte(`{"schemaVersion":1,"defaultProfile":"fleet","profiles":{"qa-fleet":{"routes":{"test-runner":{"pi":{"provider":"nan","model":"qwen3.6","effort":"low"}}}},"fleet":{"routes":{"test-runner":{"pi":{"provider":"nan","model":"glm5.3","effort":"high"}}}}}}`)) + defaultID, ids, ok := ProfileSelection(root) + if !ok || defaultID != "fleet" { + t.Fatalf("ProfileSelection() = (%q, %q, %v), want the file's defaultProfile", defaultID, ids, ok) + } + if len(ids) != 2 || ids[0] != "fleet" || ids[1] != "qa-fleet" { + t.Fatalf("ProfileSelection() ids = %q, want [fleet qa-fleet]", ids) + } + // An absent or unsafe source has no selection at all: a caller reports the + // refusal instead of listing ids from a file it could not verify. + for name, setup := range map[string]func(t *testing.T, root string){ + "absent": func(t *testing.T, root string) {}, + "symlink": func(t *testing.T, root string) { + mustSymlink(t, filepath.Join(testProfileRoot(t), "elsewhere.json"), stagedProfilePath(t, root)) + }, + "malformed": func(t *testing.T, root string) { + writeProfile(t, root, []byte(`{"schemaVersion":1,"defaultProfile":"9bad","profiles":{}}`)) + }, + } { + t.Run(name, func(t *testing.T) { + root := testProfileRoot(t) + setup(t, root) + if defaultID, ids, ok := ProfileSelection(root); ok || defaultID != "" || ids != nil { + t.Fatalf("ProfileSelection() = (%q, %q, %v), want no selection", defaultID, ids, ok) + } + }) + } +} + +func mustSymlink(t *testing.T, target, link string) { + t.Helper() + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } +} + +// stagedProfilePath creates the profile directory of root and returns the file +// path inside it, without writing a file there. +func stagedProfilePath(t *testing.T, root string) string { + t.Helper() + if err := os.MkdirAll(filepath.Dir(profilePath(root)), 0o755); err != nil { + t.Fatal(err) + } + return profilePath(root) +} diff --git a/internal/qaroute/profile.go b/internal/qaroute/profile.go index 8fd7dcb..d7f9a25 100644 --- a/internal/qaroute/profile.go +++ b/internal/qaroute/profile.go @@ -5,6 +5,7 @@ import ( "encoding/json" "io" "regexp" + "sort" "unicode/utf8" "github.com/refactor-ia/cortex/internal/qarole" @@ -150,3 +151,22 @@ func DefaultProfileID(data []byte) (string, bool) { } return defaultID, true } + +// ProfileIDs lists the ids one profile snapshot defines, sorted, so a refusal +// can enumerate what the file actually offers (#374 S6). It decodes through +// decodeProfiles: one decoder and one validity rule, so every id this function +// lists is an id Resolve accepts, and an id it omits is one Resolve refuses. +// Bounds are decodeProfiles' own (snapshot size, at most 64 profiles, and the +// profile-id pattern), so this adds no second representation of a profile. +func ProfileIDs(data []byte) ([]string, bool) { + decoded, ok := decodeProfiles(data) + if !ok { + return nil, false + } + ids := make([]string, 0, len(decoded)) + for id := range decoded { + ids = append(ids, id) + } + sort.Strings(ids) + return ids, true +} diff --git a/internal/qaroute/profile_test.go b/internal/qaroute/profile_test.go index db89c87..20b8b3d 100644 --- a/internal/qaroute/profile_test.go +++ b/internal/qaroute/profile_test.go @@ -83,3 +83,36 @@ func TestResolvePerBackendRoutes(t *testing.T) { t.Fatalf("unadmitted backend key failure = %q, want profile_invalid", failure.Code) } } + +// TestProfileIDsListsSortedAdmittedIDs pins the enumeration a refusal uses to +// name what a profile file actually offers (#374 S6): the ids come from the one +// existing decoder, so an id this function lists is an id Resolve accepts, and +// the order is sorted rather than the file's or Go's map order. +func TestProfileIDsListsSortedAdmittedIDs(t *testing.T) { + body := []byte(`{"schemaVersion":1,"defaultProfile":"fleet","profiles":{"qa-fleet":{"routes":{"requirements-analyst":{"pi":{"provider":"nan","model":"qwen3.6","effort":"low"}}}},"fleet":{"routes":{"requirements-analyst":{"pi":{"provider":"nan","model":"glm5.3","effort":"high"}}}},"alpha":{"routes":{"requirements-analyst":{"pi":{"provider":"nan","model":"deepseek-v4-flash","effort":"low"}}}}}}`) + ids, ok := ProfileIDs(body) + if !ok { + t.Fatal("ProfileIDs rejected a snapshot the route policy accepts") + } + want := []string{"alpha", "fleet", "qa-fleet"} + if len(ids) != len(want) { + t.Fatalf("ProfileIDs() = %q, want %q", ids, want) + } + for index, id := range want { + if ids[index] != id { + t.Fatalf("ProfileIDs() = %q, want %q", ids, want) + } + if _, failure := Resolve(Request{Role: qarole.RequirementsAnalyst, Backend: "pi", ProfileID: id}, Snapshot{Present: true, Bytes: body}); failure.Code != "" { + t.Fatalf("ProfileIDs listed %q but Resolve refused it: %q", id, failure.Code) + } + } + for name, invalid := range map[string][]byte{ + "malformed": []byte(`{"schemaVersion":1,"defaultProfile":"9bad","profiles":{}}`), + "default absent": []byte(`{"schemaVersion":1,"defaultProfile":"missing","profiles":{"fleet":{"routes":{"requirements-analyst":{"pi":{"provider":"nan","model":"glm5.3","effort":"high"}}}}}}`), + "not an object": []byte(`[]`), + } { + if ids, ok := ProfileIDs(invalid); ok || ids != nil { + t.Fatalf("%s: ProfileIDs() = (%q, %v), want no enumeration", name, ids, ok) + } + } +}