From e4432fc5554df67fc69a1c0be0831af898a20f3a Mon Sep 17 00:00:00 2001 From: George Lambert Date: Tue, 29 Sep 2026 11:43:22 -0400 Subject: [PATCH] feat: module scaffold and digest normalize/guard pkg/digest (not internal/) so Task 2 pkg/tsnats can import helpers. --- .gitignore | 3 +- README.md | 2 +- go.mod | 3 ++ pkg/digest/digest.go | 72 +++++++++++++++++++++++++++++++++++++++ pkg/digest/digest_test.go | 72 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 go.mod create mode 100644 pkg/digest/digest.go create mode 100644 pkg/digest/digest_test.go diff --git a/.gitignore b/.gitignore index b2e84a2..8ae75ba 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ - .worktrees/ .superpowers/ bin/ +*.exe +.venv/ diff --git a/README.md b/README.md index 0d1887e..33d1740 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ cd verae-mcp-timestamping-server-go go build -o bin/verae-mcp-timestamping-server ./cmd/verae-mcp-timestamping-server ``` -(Requires Go 1.22+ once the module is scaffolded. Design: `docs/superpowers/specs/2026-09-29-go-mcp-timestamping-server-design.md`.) +Requires Go 1.22+. Design: `docs/superpowers/specs/2026-09-29-go-mcp-timestamping-server-design.md`. ## Usage diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..274cb05 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.georgelambert.org/marchon/verae-mcp-timestamping-server-go + +go 1.22 diff --git a/pkg/digest/digest.go b/pkg/digest/digest.go new file mode 100644 index 0000000..cdf4084 --- /dev/null +++ b/pkg/digest/digest.go @@ -0,0 +1,72 @@ +package digest + +import ( + "fmt" + "regexp" + "sort" + "strings" +) + +// ForbiddenKeys are argument names rejected by digests-only tools. +var ForbiddenKeys = []string{ + "content", + "bytes", + "file", + "data", + "path", + "body", + "payload", + "blob", + "archive", +} + +var ( + hex64 = regexp.MustCompile(`^[0-9a-f]{64}$`) + forbiddenLower = func() map[string]struct{} { + m := make(map[string]struct{}, len(ForbiddenKeys)) + for _, k := range ForbiddenKeys { + m[strings.ToLower(k)] = struct{}{} + } + return m + }() +) + +// NormalizeSHA256 strips an optional "sha256:" prefix, lowercases, and +// requires exactly 64 hexadecimal characters. +func NormalizeSHA256(s string) (string, error) { + raw := strings.TrimSpace(s) + if strings.HasPrefix(strings.ToLower(raw), "sha256:") { + raw = strings.TrimSpace(raw[len("sha256:"):]) + } + raw = strings.ToLower(raw) + if !hex64.MatchString(raw) { + return "", fmt.Errorf("sha256 must be 64 hexadecimal characters") + } + return raw, nil +} + +// AssertDigestOnly rejects maps that include any ForbiddenKeys (case-insensitive). +func AssertDigestOnly(args map[string]any) error { + if args == nil { + return nil + } + var bad []string + seen := make(map[string]struct{}) + for k := range args { + lk := strings.ToLower(k) + if _, ok := forbiddenLower[lk]; ok { + if _, dup := seen[lk]; !dup { + seen[lk] = struct{}{} + bad = append(bad, k) + } + } + } + if len(bad) == 0 { + return nil + } + sort.Strings(bad) + return fmt.Errorf( + "client refuses to send content/bytes to stamp/verify/hash_report; got: %s", + strings.Join(bad, ", "), + ) +} diff --git a/pkg/digest/digest_test.go b/pkg/digest/digest_test.go new file mode 100644 index 0000000..38a3877 --- /dev/null +++ b/pkg/digest/digest_test.go @@ -0,0 +1,72 @@ +package digest + +import ( + "strings" + "testing" +) + +func TestNormalizeSHA256_StripPrefix(t *testing.T) { + h := strings.Repeat("a", 64) + got, err := NormalizeSHA256("sha256:" + h) + if err != nil || got != h { + t.Fatalf("got %q err %v", got, err) + } +} + +func TestNormalizeSHA256_LowerHex(t *testing.T) { + h := strings.Repeat("A", 64) + want := strings.Repeat("a", 64) + got, err := NormalizeSHA256(h) + if err != nil || got != want { + t.Fatalf("got %q err %v", got, err) + } +} + +func TestNormalizeSHA256_RejectsShort(t *testing.T) { + _, err := NormalizeSHA256("abcd") + if err == nil { + t.Fatal("expected error") + } +} + +func TestNormalizeSHA256_MixedCasePrefix(t *testing.T) { + h := strings.Repeat("b", 64) + got, err := NormalizeSHA256("SHA256:" + h) + if err != nil || got != h { + t.Fatalf("got %q err %v", got, err) + } +} + +func TestAssertDigestOnly_RejectsContent(t *testing.T) { + err := AssertDigestOnly(map[string]any{"sha256": strings.Repeat("a", 64), "content": "x"}) + if err == nil { + t.Fatal("expected error") + } +} + +func TestAssertDigestOnly_CaseInsensitive(t *testing.T) { + err := AssertDigestOnly(map[string]any{"CONTENT": "x"}) + if err == nil { + t.Fatal("expected error") + } +} + +func TestAssertDigestOnly_OK(t *testing.T) { + err := AssertDigestOnly(map[string]any{"sha256": strings.Repeat("a", 64), "scope": "local"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestForbiddenKeys_ContainsContent(t *testing.T) { + found := false + for _, k := range ForbiddenKeys { + if k == "content" { + found = true + break + } + } + if !found { + t.Fatalf("ForbiddenKeys missing content: %v", ForbiddenKeys) + } +}