package authstorage import ( "bytes" "context" "encoding/base64" "encoding/json" "errors" "fmt" "os" "path/filepath" "runtime" "strings" "sync" "testing" "github.com/aritmolab/thothii/tools/tht/internal/safeio" "github.com/aritmolab/thothii/tools/tht/internal/testsupport" ) func TestProtocolListUsesACallerSuppliedBound(t *testing.T) { root := filepath.Join(privateTestRoot(t), "auth") for index := 0; index < 300; index++ { filename := fmt.Sprintf("%064x.json", index) runRequest(t, request{Version: 1, Operation: "create", Root: root, Directory: "sessions", Filename: filename, ContentBase64: base64.StdEncoding.EncodeToString([]byte("record"))}) } listed := runRequest(t, request{Version: 1, Operation: "list", Root: root, Directory: "sessions", MaximumEntries: 300}) if listed.Entries == nil || len(*listed.Entries) != 300 { t.Fatalf("bounded list size = %d, want 300", len(*listed.Entries)) } filename := fmt.Sprintf("%064x.json", 300) runRequest(t, request{Version: 1, Operation: "create", Root: root, Directory: "sessions", Filename: filename, ContentBase64: base64.StdEncoding.EncodeToString([]byte("record"))}) runRejected(t, request{Version: 1, Operation: "list", Root: root, Directory: "sessions", MaximumEntries: 300}) runRejected(t, request{Version: 1, Operation: "list", Root: root, Directory: "sessions", MaximumEntries: maximumEntries + 1}) runRejected(t, request{Version: 1, Operation: "read", Root: root, Directory: "sessions", Filename: filename, MaximumEntries: 1}) } func TestProtocolListPaginatesOrdinarySessionRecords(t *testing.T) { root := filepath.Join(privateTestRoot(t), "auth") for index := 0; index < 513; index++ { filename := fmt.Sprintf("%064x.json", index) runRequest(t, request{Version: 1, Operation: "create", Root: root, Directory: "sessions", Filename: filename, ContentBase64: base64.StdEncoding.EncodeToString([]byte("record"))}) } first := runRequest(t, request{Version: 1, Operation: "list", Root: root, Directory: "sessions", MaximumEntries: 512, Continuation: true}) if first.Entries == nil || len(*first.Entries) != 512 || first.More == nil || !*first.More { t.Fatalf("first continuation page = %#v", first) } second := runRequest(t, request{ Version: 1, Operation: "list", Root: root, Directory: "sessions", MaximumEntries: 512, Continuation: true, AfterName: (*first.Entries)[511].Name, }) if second.Entries == nil || len(*second.Entries) != 1 || second.More == nil || *second.More || (*second.Entries)[0].Name != fmt.Sprintf("%064x.json", 512) { t.Fatalf("second continuation page = %#v", second) } runRejected(t, request{Version: 1, Operation: "list", Root: root, Directory: "oidc", MaximumEntries: 1, Continuation: true, AfterName: fmt.Sprintf("%064x.json", 0)}) runRejected(t, request{Version: 1, Operation: "list", Root: root, Directory: "sessions", MaximumEntries: 1, Continuation: true, AfterName: "../unsafe.json"}) } func TestProtocolCreatesReadsReplacesListsAndRemovesPrivateRecord(t *testing.T) { root := filepath.Join(privateTestRoot(t), "auth") filename := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.json" created := runRequest(t, request{Version: 1, Operation: "create", Root: root, Directory: "sessions", Filename: filename, ContentBase64: base64.StdEncoding.EncodeToString([]byte("first"))}) if !created.Created { t.Fatal("create did not report a new record") } if duplicate := runRequest(t, request{Version: 1, Operation: "create", Root: root, Directory: "sessions", Filename: filename, ContentBase64: base64.StdEncoding.EncodeToString([]byte("other"))}); duplicate.Created { t.Fatal("duplicate exclusive create reported success") } read := runRequest(t, request{Version: 1, Operation: "read", Root: root, Directory: "sessions", Filename: filename}) if !read.Found || decodeContent(t, read) != "first" { t.Fatalf("read = %#v, want private first record", read) } updated := runRequest(t, request{Version: 1, Operation: "replace", Root: root, Directory: "sessions", Filename: filename, ContentBase64: base64.StdEncoding.EncodeToString([]byte("second"))}) if !updated.Replaced { t.Fatal("replace did not report success") } listed := runRequest(t, request{Version: 1, Operation: "list", Root: root, Directory: "sessions"}) if listed.Entries == nil || len(*listed.Entries) != 1 || (*listed.Entries)[0].Name != filename { t.Fatalf("list = %#v, want exactly %q", listed.Entries, filename) } removed := runRequest(t, request{Version: 1, Operation: "remove", Root: root, Directory: "sessions", Filename: filename}) if !removed.Removed { t.Fatal("remove did not report success") } } func TestProtocolPreflightsRootWithoutCreatingOrFollowingLinks(t *testing.T) { parent := privateTestRoot(t) missing := filepath.Join(parent, "auth") validated := runRequest(t, request{Version: 1, Operation: "validate-root", Root: missing}) if !validated.Validated { t.Fatal("missing final root was not validated") } if _, err := os.Lstat(missing); !errors.Is(err, os.ErrNotExist) { t.Fatalf("validate-root mutated missing root: %v", err) } realRoot := filepath.Join(parent, "real-auth") if err := safeio.EnsurePrivateDirectory(realRoot); err != nil { t.Fatal(err) } linkedRoot := filepath.Join(parent, "linked-auth") testsupport.SymlinkOrSkip(t, realRoot, linkedRoot) runRejected(t, request{Version: 1, Operation: "validate-root", Root: linkedRoot}) if entries, err := os.ReadDir(realRoot); err != nil || len(entries) != 0 { t.Fatalf("linked target was mutated: entries=%v error=%v", entries, err) } runRejected(t, request{Version: 1, Operation: "validate-root", Root: filepath.Join(parent, "missing", "auth")}) runRejected(t, request{Version: 1, Operation: "validate-root", Root: missing, Directory: "sessions"}) runRejected(t, request{Version: 1, Operation: "validate-root", Root: filepath.Join(parent, "auth\n")}) } func TestProtocolValidatesTheCompleteSessionLayoutWithoutCreatingIt(t *testing.T) { root := filepath.Join(privateTestRoot(t), "auth") if err := safeio.EnsurePrivateDirectory(root); err != nil { t.Fatal(err) } validated := runRequest(t, request{Version: 1, Operation: "validate-root", Root: root}) if !validated.Validated { t.Fatal("layout with safely creatable children was not validated") } for _, child := range []string{"sessions", "oidc"} { if _, err := os.Lstat(filepath.Join(root, child)); !errors.Is(err, os.ErrNotExist) { t.Fatalf("validate-root created %s: %v", child, err) } } outside := privateTestRoot(t) testsupport.SymlinkOrSkip(t, outside, filepath.Join(root, "sessions")) runRejected(t, request{Version: 1, Operation: "validate-root", Root: root}) if entries, err := os.ReadDir(outside); err != nil || len(entries) != 0 { t.Fatalf("linked child target was mutated: entries=%v error=%v", entries, err) } if err := os.Remove(filepath.Join(root, "sessions")); err != nil { t.Fatal(err) } if err := os.Mkdir(filepath.Join(root, "sessions"), 0o700); err != nil { t.Fatal(err) } if runtime.GOOS == "windows" { return // Native DACL coverage lives in storage_windows_test.go. } if err := os.Chmod(filepath.Join(root, "sessions"), 0o750); err != nil { t.Fatal(err) } runRejected(t, request{Version: 1, Operation: "validate-root", Root: root}) } func TestProtocolEnsuresTheCompletePrivateSessionLayout(t *testing.T) { root := filepath.Join(privateTestRoot(t), "auth") runRequest(t, request{Version: 1, Operation: "ensure-layout", Root: root}) for _, path := range []string{root, filepath.Join(root, "sessions"), filepath.Join(root, "oidc")} { if err := safeio.ValidatePrivateDirectory(path); err != nil { t.Fatalf("private layout path %q: %v", filepath.Base(path), err) } } runRejected(t, request{Version: 1, Operation: "ensure-layout", Root: root, Directory: "sessions"}) } func TestProtocolPinsTheOriginalPrivateRootBeforeEveryRecordMutation(t *testing.T) { parent := privateTestRoot(t) root := filepath.Join(parent, "auth") replacement := filepath.Join(parent, "replacement") moved := filepath.Join(parent, "auth-original") filename := "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee.json" for _, directory := range []string{root, replacement} { if err := safeio.EnsurePrivateDirectory(directory); err != nil { t.Fatal(err) } for _, child := range []string{"sessions", "oidc"} { if err := safeio.EnsurePrivateDirectory(filepath.Join(directory, child)); err != nil { t.Fatal(err) } } } swapped := false blocked := false restore := safeio.SetPrivateDirectoryTestHookForTest(func(stage string) { if stage != "after-auth-root-open" || swapped || blocked { return } if runtime.GOOS == "windows" { if err := os.Rename(root, moved); err == nil { t.Fatal("retained Windows root handle permitted rename") } blocked = true return } if err := os.Rename(root, moved); err != nil { t.Fatal(err) } if err := os.Rename(replacement, root); err != nil { t.Fatal(err) } swapped = true }) t.Cleanup(restore) runRequest(t, request{ Version: 1, Operation: "create", Root: root, Directory: "sessions", Filename: filename, ContentBase64: base64.StdEncoding.EncodeToString([]byte("pinned-root-record")), }) if !swapped && !blocked { t.Fatal("record operation did not expose the retained-root test hook") } if runtime.GOOS == "windows" { if _, err := safeio.ReadCanonicalPrivateRegular(filepath.Join(root, "sessions", filename), maximumSessionBytes); err != nil { t.Fatalf("record missing from retained Windows root: %v", err) } return } contents, err := safeio.ReadCanonicalPrivateRegular(filepath.Join(moved, "sessions", filename), maximumSessionBytes) if err != nil || string(contents) != "pinned-root-record" { t.Fatalf("pinned root contents = %q error = %v", contents, err) } if _, err := os.Lstat(filepath.Join(root, "sessions", filename)); !errors.Is(err, os.ErrNotExist) { t.Fatalf("replacement root was mutated: %v", err) } } func TestProtocolPinsTheOriginalPrivateRootForEveryRecordOperation(t *testing.T) { filename := "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee.json" otherFilename := "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff.json" for _, operation := range []string{ "create", "read", "replace", "remove", "list", "claim-consume", "read-claim", "remove-claim", } { t.Run(operation, func(t *testing.T) { parent := privateTestRoot(t) root := filepath.Join(parent, "auth") replacement := filepath.Join(parent, "replacement") moved := filepath.Join(parent, "auth-original") for _, directory := range []string{root, replacement} { if err := safeio.EnsurePrivateDirectory(directory); err != nil { t.Fatal(err) } for _, child := range []string{"sessions", "oidc"} { if err := safeio.EnsurePrivateDirectory(filepath.Join(directory, child)); err != nil { t.Fatal(err) } } } write := func(base, directory, name, contents string) { t.Helper() if err := safeio.WriteCanonicalNewPrivateFile(filepath.Join(base, directory, name), []byte(contents), 0o600); err != nil { t.Fatal(err) } } pair := func(base, name, contents string) { t.Helper() write(base, "oidc", name, contents) if err := os.Link(filepath.Join(base, "oidc", name), filepath.Join(base, "oidc", asClaimFilename(name))); err != nil { t.Fatal(err) } } input := request{Version: 1, Operation: operation, Root: root} switch operation { case "create": input.Directory, input.Filename = "sessions", filename input.ContentBase64 = base64.StdEncoding.EncodeToString([]byte("original-created")) case "read": write(root, "sessions", filename, "original-read") write(replacement, "sessions", filename, "replacement-read") input.Directory, input.Filename = "sessions", filename case "replace": write(root, "sessions", filename, "original-before") write(replacement, "sessions", filename, "replacement-before") input.Directory, input.Filename = "sessions", filename input.ContentBase64 = base64.StdEncoding.EncodeToString([]byte("original-replaced")) case "remove": write(root, "sessions", filename, "original-remove") write(replacement, "sessions", filename, "replacement-remove") input.Directory, input.Filename = "sessions", filename case "list": write(root, "sessions", filename, "original-list") write(replacement, "sessions", otherFilename, "replacement-list") input.Directory = "sessions" case "claim-consume": write(root, "oidc", filename, "original-claim") write(replacement, "oidc", filename, "replacement-claim") input.Directory, input.Filename = "oidc", filename case "read-claim", "remove-claim": pair(root, filename, "original-pair") pair(replacement, filename, "replacement-pair") input.Directory, input.Filename = "oidc", filename } swapped := false blocked := false restore := safeio.SetPrivateDirectoryTestHookForTest(func(stage string) { if stage != "after-auth-root-open" || swapped || blocked { return } if runtime.GOOS == "windows" { if err := os.Rename(root, moved); err == nil { t.Fatal("retained Windows root handle permitted rename") } blocked = true return } if err := os.Rename(root, moved); err != nil { t.Fatal(err) } if err := os.Rename(replacement, root); err != nil { t.Fatal(err) } swapped = true }) t.Cleanup(restore) output := runRequest(t, input) if !swapped && !blocked { t.Fatal("record operation did not expose the retained-root test hook") } original := root replacementRoot := replacement if swapped { original = moved replacementRoot = root } switch operation { case "create": contents, err := safeio.ReadCanonicalPrivateRegular(filepath.Join(original, "sessions", filename), maximumSessionBytes) if err != nil || string(contents) != "original-created" { t.Fatalf("pinned create contents = %q error = %v", contents, err) } if _, err := os.Lstat(filepath.Join(replacementRoot, "sessions", filename)); !errors.Is(err, os.ErrNotExist) { t.Fatalf("replacement root was mutated: %v", err) } case "read": if !output.Found || decodeContent(t, output) != "original-read" { t.Fatalf("pinned read = %#v", output) } case "replace": contents, err := safeio.ReadCanonicalPrivateRegular(filepath.Join(original, "sessions", filename), maximumSessionBytes) if err != nil || string(contents) != "original-replaced" { t.Fatalf("pinned replace contents = %q error = %v", contents, err) } replacementContents, err := safeio.ReadCanonicalPrivateRegular(filepath.Join(replacementRoot, "sessions", filename), maximumSessionBytes) if err != nil || string(replacementContents) != "replacement-before" { t.Fatalf("replacement contents = %q error = %v", replacementContents, err) } case "remove": if _, err := os.Lstat(filepath.Join(original, "sessions", filename)); !errors.Is(err, os.ErrNotExist) { t.Fatalf("pinned remove left original record: %v", err) } if _, err := safeio.ReadCanonicalPrivateRegular(filepath.Join(replacementRoot, "sessions", filename), maximumSessionBytes); err != nil { t.Fatalf("replacement record was mutated: %v", err) } case "list": if output.Entries == nil || len(*output.Entries) != 1 || (*output.Entries)[0].Name != filename { t.Fatalf("pinned list = %#v", output) } case "claim-consume": if !output.Found || decodeContent(t, output) != "original-claim" { t.Fatalf("pinned claim consume = %#v", output) } if _, err := os.Lstat(filepath.Join(original, "oidc", filename)); !errors.Is(err, os.ErrNotExist) { t.Fatalf("pinned claim source remains: %v", err) } if contents, err := safeio.ReadCanonicalPrivateRegular(filepath.Join(replacementRoot, "oidc", filename), maximumOIDCStateBytes); err != nil || string(contents) != "replacement-claim" { t.Fatalf("replacement claim contents = %q error = %v", contents, err) } case "read-claim": if !output.Found || decodeContent(t, output) != "original-pair" { t.Fatalf("pinned claim read = %#v", output) } case "remove-claim": for _, name := range []string{filename, asClaimFilename(filename)} { if _, err := os.Lstat(filepath.Join(original, "oidc", name)); !errors.Is(err, os.ErrNotExist) { t.Fatalf("pinned claim remove left original %s: %v", name, err) } if _, err := os.Lstat(filepath.Join(replacementRoot, "oidc", name)); err != nil { t.Fatalf("replacement claim pair was mutated: %v", err) } } } }) } } func TestProtocolPinsTheOriginalPrivateRootForLayoutCreation(t *testing.T) { parent := privateTestRoot(t) root := filepath.Join(parent, "auth") replacement := filepath.Join(parent, "replacement") moved := filepath.Join(parent, "auth-original") for _, directory := range []string{root, replacement} { if err := safeio.EnsurePrivateDirectory(directory); err != nil { t.Fatal(err) } } swapped := false blocked := false restore := safeio.SetPrivateDirectoryTestHookForTest(func(stage string) { if stage != "after-auth-root-open" || swapped || blocked { return } if runtime.GOOS == "windows" { if err := os.Rename(root, moved); err == nil { t.Fatal("retained Windows root handle permitted rename") } blocked = true return } if err := os.Rename(root, moved); err != nil { t.Fatal(err) } if err := os.Rename(replacement, root); err != nil { t.Fatal(err) } swapped = true }) t.Cleanup(restore) runRequest(t, request{Version: 1, Operation: "ensure-layout", Root: root}) if !swapped && !blocked { t.Fatal("layout creation did not expose the retained-root test hook") } original := root replacementRoot := replacement if swapped { original = moved replacementRoot = root } for _, child := range []string{"sessions", "oidc"} { if err := safeio.ValidatePrivateDirectory(filepath.Join(original, child)); err != nil { t.Fatalf("pinned %s child: %v", child, err) } if _, err := os.Lstat(filepath.Join(replacementRoot, child)); !errors.Is(err, os.ErrNotExist) { t.Fatalf("replacement root received layout mutation: %v", err) } } } func TestProtocolValidateRootRechecksChildrenObservedMissingUnderTheRetainedRoot(t *testing.T) { root := filepath.Join(privateTestRoot(t), "auth") if err := safeio.EnsurePrivateDirectory(root); err != nil { t.Fatal(err) } installedUnsafeChild := false restore := safeio.SetPrivateDirectoryTestHookForTest(func(stage string) { if stage != "after-auth-layout-first-pass" || installedUnsafeChild { return } if err := os.Mkdir(filepath.Join(root, "sessions"), 0o755); err != nil { t.Fatal(err) } installedUnsafeChild = true }) t.Cleanup(restore) runRejected(t, request{Version: 1, Operation: "validate-root", Root: root}) if !installedUnsafeChild { t.Fatal("layout validation did not expose the missing-child recheck hook") } } func TestProtocolValidateLayoutPinsTheOriginalPOSIXRootAcrossMissingAndUnsafeChildren(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("native Windows retained-handle coverage uses DACLs in storage_windows_test.go") } for _, scenario := range []struct { name string originalUnsafe bool replacementUnsafe bool wantAccepted bool }{ {name: "rejects an unsafe retained child after a safe lexical replacement", originalUnsafe: true, wantAccepted: false}, {name: "accepts missing retained children despite an unsafe lexical replacement", replacementUnsafe: true, wantAccepted: true}, } { t.Run(scenario.name, func(t *testing.T) { parent := privateTestRoot(t) root := filepath.Join(parent, "auth") replacement := filepath.Join(parent, "replacement") moved := filepath.Join(parent, "auth-original") for _, directory := range []string{root, replacement} { if err := safeio.EnsurePrivateDirectory(directory); err != nil { t.Fatal(err) } } if scenario.originalUnsafe { if err := os.Mkdir(filepath.Join(root, "sessions"), 0o755); err != nil { t.Fatal(err) } } if scenario.replacementUnsafe { if err := os.Mkdir(filepath.Join(replacement, "sessions"), 0o755); err != nil { t.Fatal(err) } } swapped := false restore := safeio.SetPrivateDirectoryTestHookForTest(func(stage string) { if stage != "after-auth-root-open" || swapped { return } if err := os.Rename(root, moved); err != nil { t.Fatal(err) } if err := os.Rename(replacement, root); err != nil { t.Fatal(err) } swapped = true }) t.Cleanup(restore) input := request{Version: 1, Operation: "validate-root", Root: root} if scenario.wantAccepted { runRequest(t, input) } else { runRejected(t, input) } if !swapped { t.Fatal("layout validation did not retain and expose the original root") } for _, child := range []string{"sessions", "oidc"} { if _, err := os.Lstat(filepath.Join(moved, child)); !errors.Is(err, os.ErrNotExist) && !(scenario.originalUnsafe && child == "sessions") { t.Fatalf("validate-root mutated retained %s child: %v", child, err) } } }) } } func TestProtocolReadsOnlyBoundedPrivateAuthConfig(t *testing.T) { root := privateTestRoot(t) filename := "auth.yaml" path := filepath.Join(root, filename) contents := []byte("version: 1\nmode: local\n") if err := safeio.WriteCanonicalNewPrivateFile(path, contents, 0o600); err != nil { t.Fatal(err) } read := runRequest(t, request{Version: 1, Operation: "read-auth-config", Root: root, Filename: filename}) if !read.Found || decodeContent(t, read) != string(contents) { t.Fatalf("read-auth-config = %#v", read) } hardLink := filepath.Join(root, "auth-copy.yaml") if err := os.Link(path, hardLink); err != nil { t.Fatal(err) } runRejected(t, request{Version: 1, Operation: "read-auth-config", Root: root, Filename: filename}) if err := os.Remove(hardLink); err != nil { t.Fatal(err) } if err := os.Remove(path); err != nil { t.Fatal(err) } if err := safeio.WriteCanonicalNewPrivateFile(path, bytes.Repeat([]byte("x"), maximumAuthConfigBytes+1), 0o600); err != nil { t.Fatal(err) } runRejected(t, request{Version: 1, Operation: "read-auth-config", Root: root, Filename: filename}) runRejected(t, request{Version: 1, Operation: "read-auth-config", Root: root, Filename: "../auth.yaml"}) runRejected(t, request{Version: 1, Operation: "read-auth-config", Root: root, Directory: "sessions", Filename: filename}) } func TestProtocolReadsLocalUsersOnlyAsABoundedPrivateRegularFile(t *testing.T) { root := privateTestRoot(t) filename := "users.yaml" path := filepath.Join(root, filename) contents := []byte("version: 1\nusers: []\n") if err := safeio.WriteCanonicalNewPrivateFile(path, contents, 0o600); err != nil { t.Fatal(err) } read := runRequest(t, request{Version: 1, Operation: "read-local-users", Root: root, Filename: filename}) if !read.Found || decodeContent(t, read) != string(contents) { t.Fatalf("read-local-users = %#v", read) } hardLink := filepath.Join(root, "users-copy.yaml") if err := os.Link(path, hardLink); err != nil { t.Fatal(err) } runRejected(t, request{Version: 1, Operation: "read-local-users", Root: root, Filename: filename}) if err := os.Remove(hardLink); err != nil { t.Fatal(err) } if err := os.Remove(path); err != nil { t.Fatal(err) } if err := safeio.WriteCanonicalNewPrivateFile(path, bytes.Repeat([]byte("x"), maximumAuthConfigBytes+1), 0o600); err != nil { t.Fatal(err) } runRejected(t, request{Version: 1, Operation: "read-local-users", Root: root, Filename: filename}) } func TestProtocolPermitsBoundedReservationSlotsOnlyForOIDCRecords(t *testing.T) { root := filepath.Join(privateTestRoot(t), "auth") slot := "slot-00.json" contents := base64.StdEncoding.EncodeToString([]byte("reservation")) if created := runRequest(t, request{Version: 1, Operation: "create", Root: root, Directory: "oidc", Filename: slot, ContentBase64: contents}); !created.Created { t.Fatal("OIDC slot create did not report success") } if read := runRequest(t, request{Version: 1, Operation: "read", Root: root, Directory: "oidc", Filename: slot}); !read.Found || decodeContent(t, read) != "reservation" { t.Fatalf("OIDC slot read = %#v", read) } listed := runRequest(t, request{Version: 1, Operation: "list", Root: root, Directory: "oidc"}) if listed.Entries == nil || len(*listed.Entries) != 1 || (*listed.Entries)[0].Name != slot { t.Fatalf("OIDC slot list = %#v", listed.Entries) } if removed := runRequest(t, request{Version: 1, Operation: "remove", Root: root, Directory: "oidc", Filename: slot}); !removed.Removed { t.Fatal("OIDC slot remove did not report success") } runRejected(t, request{Version: 1, Operation: "create", Root: root, Directory: "sessions", Filename: slot, ContentBase64: contents}) runRejected(t, request{Version: 1, Operation: "create", Root: root, Directory: "oidc", Filename: "slot-64.json", ContentBase64: contents}) } func TestProtocolListSerializesLowerCamelBridgeDTO(t *testing.T) { root := filepath.Join(privateTestRoot(t), "auth") filename := "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc.json" runRequest(t, request{Version: 1, Operation: "create", Root: root, Directory: "sessions", Filename: filename, ContentBase64: base64.StdEncoding.EncodeToString([]byte("record"))}) input, err := json.Marshal(request{Version: 1, Operation: "list", Root: root, Directory: "sessions"}) if err != nil { t.Fatal(err) } var stdout, stderr bytes.Buffer if code := Run(context.Background(), nil, bytes.NewReader(input), &stdout, &stderr); code != 0 { t.Fatalf("Run() code = %d stderr = %q", code, stderr.String()) } if !strings.Contains(stdout.String(), `"name":"`+filename+`"`) || strings.Contains(stdout.String(), `"Name":`) { t.Fatalf("list bridge JSON = %q, want lower-camel entry fields", stdout.String()) } } func TestProtocolListAlwaysSerializesAnEmptyEntriesArray(t *testing.T) { root := filepath.Join(privateTestRoot(t), "auth") input, err := json.Marshal(request{Version: 1, Operation: "list", Root: root, Directory: "sessions"}) if err != nil { t.Fatal(err) } var stdout, stderr bytes.Buffer if code := Run(context.Background(), nil, bytes.NewReader(input), &stdout, &stderr); code != 0 { t.Fatalf("Run() code = %d stderr = %q", code, stderr.String()) } if !strings.Contains(stdout.String(), `"entries":[]`) { t.Fatalf("empty list bridge JSON = %q, want entries array", stdout.String()) } } func TestProtocolPermitsClaimNamesOnlyForOIDCRemove(t *testing.T) { root := filepath.Join(privateTestRoot(t), "auth") digest := "abababababababababababababababababababababababababababababababab.json" claim := strings.TrimSuffix(digest, ".json") + ".claim" for _, directory := range []string{"sessions", "oidc"} { runRequest(t, request{Version: 1, Operation: "list", Root: root, Directory: directory}) } if err := safeio.WriteCanonicalNewPrivateFile(filepath.Join(root, "sessions", claim), []byte("orphan"), 0o600); err != nil { t.Fatal(err) } if err := safeio.WriteCanonicalNewPrivateFile(filepath.Join(root, "oidc", claim), []byte("orphan"), 0o600); err != nil { t.Fatal(err) } runRejected(t, request{Version: 1, Operation: "remove", Root: root, Directory: "sessions", Filename: claim}) runRejected(t, request{Version: 1, Operation: "list", Root: root, Directory: "sessions", Filename: claim}) runRejected(t, request{Version: 1, Operation: "read", Root: root, Directory: "oidc", Filename: claim}) runRejected(t, request{Version: 1, Operation: "create", Root: root, Directory: "oidc", Filename: claim, ContentBase64: base64.StdEncoding.EncodeToString([]byte("record"))}) runRejected(t, request{Version: 1, Operation: "replace", Root: root, Directory: "oidc", Filename: claim, ContentBase64: base64.StdEncoding.EncodeToString([]byte("record"))}) runRejected(t, request{Version: 1, Operation: "remove", Root: root, Directory: "oidc", Filename: "../" + claim}) runRejected(t, request{Version: 1, Operation: "remove", Root: root, Directory: "oidc", Filename: strings.TrimSuffix(claim, ".claim") + ".claim.bak"}) removed := runRequest(t, request{Version: 1, Operation: "remove", Root: root, Directory: "oidc", Filename: claim}) if !removed.Removed { t.Fatal("OIDC orphan claim removal did not report success") } } func TestRunRejectsProtocolOverflowAndTrailingJSONValues(t *testing.T) { root := filepath.Join(privateTestRoot(t), "auth") valid, err := json.Marshal(request{Version: 1, Operation: "list", Root: root, Directory: "sessions"}) if err != nil { t.Fatal(err) } if len(valid) >= maximumProtocolBytes { t.Fatal("test request unexpectedly consumes protocol bound") } overflowWhitespace := append(append([]byte(nil), valid...), bytes.Repeat([]byte(" "), maximumProtocolBytes+1-len(valid))...) runRawRejected(t, overflowWhitespace) withinBound := append(append([]byte(nil), valid...), []byte("{}")...) runRawRejected(t, withinBound) crossingBound := append(append([]byte(nil), valid...), bytes.Repeat([]byte(" "), maximumProtocolBytes-len(valid)-1)...) crossingBound = append(crossingBound, []byte("{}")...) runRawRejected(t, crossingBound) } func TestProtocolClaimConsumeIsAtomicAcrossConcurrentRequests(t *testing.T) { root := filepath.Join(privateTestRoot(t), "auth") filename := "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd.json" runRequest(t, request{Version: 1, Operation: "create", Root: root, Directory: "oidc", Filename: filename, ContentBase64: base64.StdEncoding.EncodeToString([]byte("oidc-record"))}) request := request{Version: 1, Operation: "claim-consume", Root: root, Directory: "oidc", Filename: filename} results := make(chan response, 2) errors := make(chan error, 2) var group sync.WaitGroup for range 2 { group.Add(1) go func() { defer group.Done() result, err := execute(request) if err != nil { errors <- err return } results <- result }() } group.Wait() close(results) close(errors) for err := range errors { t.Fatalf("concurrent claim error = %v", err) } found := 0 for result := range results { if result.Found { found++ } } if found != 1 { t.Fatalf("winning claim count = %d, want 1", found) } } func TestProtocolRejectsBoundsReparseAndUnexpectedStorageNames(t *testing.T) { root := filepath.Join(privateTestRoot(t), "auth") filename := "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee.json" runRejected(t, request{Version: 1, Operation: "create", Root: root, Directory: "sessions", Filename: filename, ContentBase64: base64.StdEncoding.EncodeToString(make([]byte, maximumSessionBytes+1))}) valid := request{Version: 1, Operation: "create", Root: root, Directory: "sessions", Filename: filename, ContentBase64: base64.StdEncoding.EncodeToString([]byte("record"))} runRequest(t, valid) if err := safeio.WriteCanonicalNewFile(filepath.Join(root, "sessions", "unexpected.txt"), []byte("junk"), 0o600); err != nil { t.Fatal(err) } runRejected(t, request{Version: 1, Operation: "list", Root: root, Directory: "sessions"}) runRejected(t, request{Version: 1, Operation: "list", Root: root, Directory: "sessions", MaximumEntries: 1, Continuation: true}) outer := privateTestRoot(t) linkedRoot := filepath.Join(outer, "linked-auth") testsupport.SymlinkOrSkip(t, filepath.Join(outer, "missing-real-auth"), linkedRoot) runRejected(t, request{Version: 1, Operation: "create", Root: linkedRoot, Directory: "sessions", Filename: filename, ContentBase64: base64.StdEncoding.EncodeToString([]byte("record"))}) } func TestProtocolClaimConsumeHasOneWinner(t *testing.T) { root := filepath.Join(privateTestRoot(t), "auth") filename := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.json" runRequest(t, request{Version: 1, Operation: "create", Root: root, Directory: "oidc", Filename: filename, ContentBase64: base64.StdEncoding.EncodeToString([]byte("oidc-record"))}) first := runRequest(t, request{Version: 1, Operation: "claim-consume", Root: root, Directory: "oidc", Filename: filename}) second := runRequest(t, request{Version: 1, Operation: "claim-consume", Root: root, Directory: "oidc", Filename: filename}) if !first.Found || decodeContent(t, first) != "oidc-record" || second.Found { t.Fatalf("claim results = %#v, %#v", first, second) } } func runRequest(t *testing.T, value request) response { t.Helper() input, err := json.Marshal(value) if err != nil { t.Fatal(err) } var stdout, stderr bytes.Buffer if code := Run(context.Background(), nil, bytes.NewReader(input), &stdout, &stderr); code != 0 { t.Fatalf("Run() code = %d stderr = %q", code, stderr.String()) } var output response if err := json.Unmarshal(stdout.Bytes(), &output); err != nil || !output.OK || output.Version != 1 { t.Fatalf("stdout = %q response = %#v error = %v", stdout.String(), output, err) } return output } func runRejected(t *testing.T, value request) { t.Helper() input, err := json.Marshal(value) if err != nil { t.Fatal(err) } var stdout, stderr bytes.Buffer if code := Run(context.Background(), nil, bytes.NewReader(input), &stdout, &stderr); code == 0 || stdout.Len() != 0 || stderr.String() != "tht: auth storage request failed\n" { t.Fatalf("Run() rejection code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) } } func runRawRejected(t *testing.T, input []byte) { t.Helper() var stdout, stderr bytes.Buffer if code := Run(context.Background(), nil, bytes.NewReader(input), &stdout, &stderr); code == 0 || stdout.Len() != 0 || stderr.String() != "tht: auth storage request failed\n" { t.Fatalf("Run() rejection code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) } } func decodeContent(t *testing.T, value response) string { t.Helper() contents, err := base64.StdEncoding.DecodeString(value.ContentBase64) if err != nil { t.Fatal(err) } return string(contents) } func privateTestRoot(t *testing.T) string { t.Helper() temporary, err := filepath.EvalSymlinks(os.TempDir()) if err != nil { t.Fatal(err) } root, err := os.MkdirTemp(temporary, "tht-authstorage-") if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = os.RemoveAll(root) }) return root }