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/
|
.worktrees/
|
||||||
.superpowers/
|
.superpowers/
|
||||||
bin/
|
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
|
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
|
## 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