feat: module scaffold and digest normalize/guard
pkg/digest (not internal/) so Task 2 pkg/tsnats can import helpers.
This commit is contained in:
parent
43cc0a60ae
commit
e4432fc555
5 changed files with 150 additions and 2 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -1,4 +1,5 @@
|
|||
|
||||
.worktrees/
|
||||
.superpowers/
|
||||
bin/
|
||||
*.exe
|
||||
.venv/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
3
go.mod
Normal file
3
go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module git.georgelambert.org/marchon/verae-mcp-timestamping-server-go
|
||||
|
||||
go 1.22
|
||||
72
pkg/digest/digest.go
Normal file
72
pkg/digest/digest.go
Normal file
|
|
@ -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, ", "),
|
||||
)
|
||||
}
|
||||
72
pkg/digest/digest_test.go
Normal file
72
pkg/digest/digest_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue