diff --git a/.forgejo/workflows/test-and-deploy.yml b/.forgejo/workflows/test-and-deploy.yml new file mode 100644 index 0000000..d38656b --- /dev/null +++ b/.forgejo/workflows/test-and-deploy.yml @@ -0,0 +1,24 @@ +name: test-and-deploy + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + test: + runs-on: 138 + steps: + - uses: actions/checkout@v4 + - name: Unit tests + run: bash scripts/test-138.sh + + deploy-138: + needs: test + if: success() && github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + runs-on: 138 + steps: + - uses: actions/checkout@v4 + - name: Deploy the host checkout + run: bash scripts/deploy-138.sh diff --git a/internal/metadatahist/history.go b/internal/metadatahist/history.go new file mode 100644 index 0000000..a04fa54 --- /dev/null +++ b/internal/metadatahist/history.go @@ -0,0 +1,341 @@ +// Package metadatahist builds verae-meta-git/1 documents the same way as +// node-red-contrib-verae lib/metadata-history.js. Blank values do not delete +// fields. A document that already uses the schema is stored as sent. +package metadatahist + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "sort" + "time" +) + +const Schema = "verae-meta-git/1" + +func IsBlank(value any) bool { + if value == nil { + return true + } + switch v := value.(type) { + case string: + for _, r := range v { + if r != ' ' && r != '\t' && r != '\n' && r != '\r' { + return false + } + } + return true + case []any: + return len(v) == 0 + case map[string]any: + return len(v) == 0 + default: + return false + } +} + +func isObject(value any) (map[string]any, bool) { + m, ok := value.(map[string]any) + return m, ok +} + +func clone(value any) any { + switch v := value.(type) { + case []any: + out := make([]any, len(v)) + for i := range v { + out[i] = clone(v[i]) + } + return out + case map[string]any: + out := make(map[string]any, len(v)) + for k, item := range v { + out[k] = clone(item) + } + return out + default: + return v + } +} + +func jsonScalar(value any) string { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + _ = enc.Encode(value) + b := bytes.TrimRight(buf.Bytes(), "\n") + return string(b) +} + +func canonical(value any) string { + switch v := value.(type) { + case []any: + parts := make([]string, len(v)) + for i := range v { + parts[i] = canonical(v[i]) + } + return "[" + join(parts) + "]" + case map[string]any: + keys := make([]string, 0, len(v)) + for k := range v { + keys = append(keys, k) + } + sort.Strings(keys) + parts := make([]string, len(keys)) + for i, k := range keys { + parts[i] = jsonScalar(k) + ":" + canonical(v[k]) + } + return "{" + join(parts) + "}" + default: + return jsonScalar(value) + } +} + +func join(parts []string) string { + out := "" + for i, p := range parts { + if i > 0 { + out += "," + } + out += p + } + return out +} + +func same(a, b any) bool { return canonical(a) == canonical(b) } + +func ExtendSnapshot(base, patch map[string]any) map[string]any { + out := map[string]any{} + if base != nil { + if cloned, ok := clone(base).(map[string]any); ok { + out = cloned + } + } + if patch == nil { + return out + } + for key, next := range patch { + if IsBlank(next) { + continue + } + prev, has := out[key] + prevObj, prevIsObj := isObject(prev) + nextObj, nextIsObj := isObject(next) + prevArr, prevIsArr := prev.([]any) + nextArr, nextIsArr := next.([]any) + switch { + case has && prevIsObj && nextIsObj: + out[key] = ExtendSnapshot(prevObj, nextObj) + case prevIsArr && nextIsArr: + extra := nonBlank(nextArr) + if len(extra) > 0 { + out[key] = append(append([]any{}, prevArr...), extra...) + } + case nextIsArr: + extra := nonBlank(nextArr) + if len(extra) == 0 { + continue + } + if prevIsArr { + out[key] = append(append([]any{}, prevArr...), extra...) + } else { + out[key] = extra + } + case nextIsObj: + nestedBase := map[string]any{} + if prevIsObj { + nestedBase = prevObj + } + nested := ExtendSnapshot(nestedBase, nextObj) + if !IsBlank(nested) { + out[key] = nested + } + default: + out[key] = clone(next) + } + } + return out +} + +func nonBlank(items []any) []any { + out := make([]any, 0, len(items)) + for _, item := range items { + if !IsBlank(item) { + out = append(out, clone(item)) + } + } + return out +} + +func ChangesBetween(before, after map[string]any) map[string]any { + added := map[string]any{} + extended := map[string]any{} + updated := map[string]any{} + if before == nil { + before = map[string]any{} + } + if after == nil { + after = map[string]any{} + } + keys := map[string]struct{}{} + for k := range before { + keys[k] = struct{}{} + } + for k := range after { + keys[k] = struct{}{} + } + ordered := make([]string, 0, len(keys)) + for k := range keys { + ordered = append(ordered, k) + } + sort.Strings(ordered) + for _, key := range ordered { + next, hasNext := after[key] + if !hasNext { + continue + } + prev, hasPrev := before[key] + if !hasPrev { + added[key] = clone(next) + continue + } + if same(prev, next) { + continue + } + prevObj, prevIsObj := isObject(prev) + nextObj, nextIsObj := isObject(next) + prevArr, prevIsArr := prev.([]any) + nextArr, nextIsArr := next.([]any) + switch { + case prevIsObj && nextIsObj: + nested := ChangesBetween(prevObj, nextObj) + if !IsBlank(nested["added"]) || !IsBlank(nested["extended"]) || !IsBlank(nested["updated"]) { + extended[key] = nested + } + case prevIsArr && nextIsArr && len(nextArr) >= len(prevArr): + appended := nextArr[len(prevArr):] + if len(appended) > 0 { + extended[key] = map[string]any{"appended": clone(appended)} + } + default: + updated[key] = map[string]any{"from": clone(prev), "to": clone(next)} + } + } + return map[string]any{"added": added, "extended": extended, "updated": updated} +} + +func commitID(commit map[string]any) string { + material := canonical(map[string]any{ + "parent": commit["parent"], + "at": commit["at"], + "message": commit["message"], + "snapshot": commit["snapshot"], + }) + sum := sha256.Sum256([]byte(material)) + return hex.EncodeToString(sum[:]) +} + +func IsHistory(value any) bool { + m, ok := isObject(value) + if !ok || m["schema"] != Schema { + return false + } + if _, ok := m["commits"].([]any); !ok { + return false + } + _, ok = isObject(m["snapshot"]) + return ok +} + +func historyFromSnapshot(snapshot map[string]any, at string) map[string]any { + stored, _ := clone(snapshot).(map[string]any) + commit := map[string]any{ + "parent": nil, + "at": at, + "message": "stamp", + "snapshot": stored, + "changes": ChangesBetween(map[string]any{}, stored), + } + commit["id"] = commitID(commit) + return map[string]any{ + "schema": Schema, + "head": commit["id"], + "snapshot": clone(stored), + "commits": []any{commit}, + } +} + +func coerceHistory(existing any, at string) map[string]any { + if IsHistory(existing) { + cloned, _ := clone(existing).(map[string]any) + return cloned + } + if m, ok := isObject(existing); ok && !IsBlank(m) { + return historyFromSnapshot(m, at) + } + return map[string]any{ + "schema": Schema, + "head": nil, + "snapshot": map[string]any{}, + "commits": []any{}, + } +} + +func hasChanges(changes map[string]any) bool { + return !IsBlank(changes["added"]) || !IsBlank(changes["extended"]) || !IsBlank(changes["updated"]) +} + +// Append builds the next history document. A nil result means the caller +// should omit metadata. restamp with a blank patch still appends one commit. +func Append(existing any, patch map[string]any, restamp bool, at, priorAt, message string) map[string]any { + if at == "" { + at = time.Now().UTC().Format(time.RFC3339Nano) + } + blankPatch := patch == nil || IsBlank(patch) || IsBlank(ExtendSnapshot(map[string]any{}, patch)) + if !restamp && blankPatch && !IsHistory(existing) && (existing == nil || IsBlank(existing)) { + return nil + } + if priorAt == "" { + priorAt = at + } + doc := coerceHistory(existing, priorAt) + head, _ := doc["head"].(string) + if !restamp && blankPatch { + if head != "" { + return doc + } + return nil + } + base, _ := doc["snapshot"].(map[string]any) + snapshot := ExtendSnapshot(base, patch) + changes := ChangesBetween(base, snapshot) + if !restamp && !hasChanges(changes) && head != "" { + return doc + } + msg := message + if msg == "" { + if head != "" { + msg = "restamp" + } else { + msg = "stamp" + } + } + var parent any + if head != "" { + parent = head + } + commit := map[string]any{ + "parent": parent, + "at": at, + "message": msg, + "snapshot": snapshot, + "changes": changes, + } + commit["id"] = commitID(commit) + commits, _ := doc["commits"].([]any) + doc["commits"] = append(commits, commit) + doc["head"] = commit["id"] + doc["snapshot"] = snapshot + return doc +} diff --git a/internal/metadatahist/history_test.go b/internal/metadatahist/history_test.go new file mode 100644 index 0000000..6bd84e2 --- /dev/null +++ b/internal/metadatahist/history_test.go @@ -0,0 +1,52 @@ +package metadatahist + +import "testing" + +func TestAppendMatchesNodeReference(t *testing.T) { + first := Append(nil, map[string]any{"title": "deed", "tags": []any{"a"}}, false, "2026-10-11T01:00:00.000Z", "", "stamp") + if first["head"] != "161ff3344ba776ae7201b73914c0e7500116ac9dde02e1967a761b7089c41fa7" { + t.Fatalf("head1=%v", first["head"]) + } + second := Append(first, map[string]any{"title": "deed-2", "tags": []any{"b"}, "place": "office"}, true, "2026-10-11T02:00:00.000Z", "", "correct the title") + if second["head"] != "c606e41ccbf4c0f9f653b21759df83ecda25262621075db3a153b592270fd918" { + t.Fatalf("head2=%v", second["head"]) + } + snap := second["snapshot"].(map[string]any) + if snap["title"] != "deed-2" || snap["place"] != "office" { + t.Fatalf("snapshot=%v", snap) + } + tags := snap["tags"].([]any) + if len(tags) != 2 || tags[0] != "a" || tags[1] != "b" { + t.Fatalf("tags=%v", tags) + } + commits := second["commits"].([]any) + tip := commits[1].(map[string]any) + if tip["parent"] != first["head"] { + t.Fatalf("parent=%v", tip["parent"]) + } + if _, ok := tip["changes"].(map[string]any)["removed"]; ok { + t.Fatal("removed key") + } +} + +func TestBlankRestampKeepsSnapshot(t *testing.T) { + base := map[string]any{"title": "deed", "note": "keep", "tags": []any{"a"}} + next := Append(base, map[string]any{"title": "", "note": nil, "tags": []any{}}, true, "2026-10-11T01:47:00.000Z", "2026-10-11T01:00:00.000Z", "") + snap := next["snapshot"].(map[string]any) + if snap["title"] != "deed" || snap["note"] != "keep" { + t.Fatalf("snapshot=%v", snap) + } + commits := next["commits"].([]any) + if len(commits) != 2 { + t.Fatalf("commits=%d", len(commits)) + } +} + +func TestOmitWhenEmpty(t *testing.T) { + if Append(nil, nil, false, "2026-10-11T01:00:00.000Z", "", "") != nil { + t.Fatal("expected omit") + } + if Append(nil, map[string]any{}, false, "2026-10-11T01:00:00.000Z", "", "") != nil { + t.Fatal("expected omit") + } +} diff --git a/internal/veraeapi/client.go b/internal/veraeapi/client.go index e227571..1a8ccff 100644 --- a/internal/veraeapi/client.go +++ b/internal/veraeapi/client.go @@ -12,6 +12,7 @@ import ( "time" "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth" + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/metadatahist" ) const ( @@ -73,18 +74,30 @@ func (c *Client) Stamp(digest, documentName string) (Receipt, error) { case auth.ChannelZapier: return c.stampZapier(digest, documentName) default: - return c.stampVerae(digest) + return c.stampVerae(digest, documentName) } } -func (c *Client) stampVerae(digest string) (Receipt, error) { +func historyForTitle(documentName string) map[string]any { + title := strings.TrimSpace(documentName) + if title == "" { + return nil + } + return metadatahist.Append(nil, map[string]any{"title": title}, false, "", "", "stamp") +} + +func (c *Client) stampVerae(digest, documentName string) (Receipt, error) { base := c.veraeBase() - body, _ := json.Marshal(map[string]any{ + payload := map[string]any{ "data": digest, "digest": digest, "hashAlg": "SHA256", "restamp": false, - }) + } + if doc := historyForTitle(documentName); doc != nil { + payload["metadata"] = doc + } + body, _ := json.Marshal(payload) req, err := http.NewRequest(http.MethodPost, base+"/api/timestamp", bytes.NewReader(body)) if err != nil { return Receipt{}, err @@ -287,12 +300,16 @@ func (c *Client) enrichFromHash(base string, rec Receipt) (Receipt, error) { func (c *Client) stampZapier(digest, documentName string) (Receipt, error) { base := c.zapierBase() - body, _ := json.Marshal(map[string]any{ + payload := map[string]any{ "sha256": digest, "attachPdf": true, "wait": true, "documentName": documentName, - }) + } + if doc := historyForTitle(documentName); doc != nil { + payload["metadata"] = doc + } + body, _ := json.Marshal(payload) req, err := http.NewRequest(http.MethodPost, base+"/v1/timestamp", bytes.NewReader(body)) if err != nil { return Receipt{}, err diff --git a/internal/veraeapi/client_test.go b/internal/veraeapi/client_test.go index 4ae0f54..c20ca06 100644 --- a/internal/veraeapi/client_test.go +++ b/internal/veraeapi/client_test.go @@ -2,16 +2,93 @@ package veraeapi import ( "encoding/base64" + "encoding/json" "io" "net/http" "strings" "testing" + "time" "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth" ) const digest64 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +func TestStampVeraeSendsTitleHistory(t *testing.T) { + var posted map[string]any + c := &Client{ + Channel: auth.ChannelVerae, + VeraeBase: "https://api.example.test", + HTTPDo: func(req *http.Request) (*http.Response, error) { + if req.URL.Path == "/api/timestamp" { + raw, _ := io.ReadAll(req.Body) + _ = json.Unmarshal(raw, &posted) + body := `{"jobId":"job-1","certificate":"QmHist","existing":false}` + return &http.Response{StatusCode: 202, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil + } + if strings.HasPrefix(req.URL.Path, "/api/status/") { + body := `{"status":"completed","result":"QmHist","completedAt":"2026-10-11T01:00:00Z"}` + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil + } + if strings.HasPrefix(req.URL.Path, "/api/hash/") || req.URL.Path == "/api/verify" { + return &http.Response{StatusCode: 404, Body: io.NopCloser(strings.NewReader(`{}`)), Header: make(http.Header)}, nil + } + t.Fatalf("path %s", req.URL.Path) + return nil, nil + }, + } + c.PollInterval = time.Millisecond + if _, err := c.Stamp(digest64, "Deed.pdf"); err != nil { + t.Fatal(err) + } + meta, _ := posted["metadata"].(map[string]any) + if meta["schema"] != "verae-meta-git/1" { + t.Fatalf("metadata=%v", posted["metadata"]) + } + snap, _ := meta["snapshot"].(map[string]any) + if snap["title"] != "Deed.pdf" { + t.Fatalf("snapshot=%v", snap) + } + if posted["restamp"] != false { + t.Fatalf("restamp=%v", posted["restamp"]) + } +} + +func TestStampVeraeOmitsMetadataWithoutTitle(t *testing.T) { + var posted map[string]any + c := &Client{ + Channel: auth.ChannelVerae, + VeraeBase: "https://api.example.test", + HTTPDo: func(req *http.Request) (*http.Response, error) { + if req.URL.Path == "/api/timestamp" { + raw, _ := io.ReadAll(req.Body) + _ = json.Unmarshal(raw, &posted) + body := `{"jobId":"job-1","certificate":"QmPlain","existing":false}` + return &http.Response{StatusCode: 202, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil + } + if strings.HasPrefix(req.URL.Path, "/api/status/") { + body := `{"status":"completed","result":"QmPlain","completedAt":"2026-10-11T01:00:00Z"}` + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil + } + if strings.HasPrefix(req.URL.Path, "/api/hash/") || req.URL.Path == "/api/verify" { + return &http.Response{StatusCode: 404, Body: io.NopCloser(strings.NewReader(`{}`)), Header: make(http.Header)}, nil + } + t.Fatalf("path %s", req.URL.Path) + return nil, nil + }, + } + c.PollInterval = time.Millisecond + if _, err := c.Stamp(digest64, " "); err != nil { + t.Fatal(err) + } + if _, ok := posted["metadata"]; ok { + t.Fatalf("metadata=%v", posted["metadata"]) + } + if posted["restamp"] != false { + t.Fatalf("restamp=%v", posted["restamp"]) + } +} + func TestStampVeraeImmediateCertificate(t *testing.T) { c := &Client{ Channel: auth.ChannelVerae, diff --git a/scripts/deploy-138.sh b/scripts/deploy-138.sh new file mode 100755 index 0000000..2d4206b --- /dev/null +++ b/scripts/deploy-138.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# Test, build, and install the Linux verae-print binary on ns1. +# Does not install a CUPS queue, and does not restart verae-timestamping-svc +# or forgejo-runner. Does not read secret files. +set -euo pipefail + +LOCK="${LOCK:-/tmp/verae-print-deploy.lock}" +DEST="${DEST:-/opt/verae/printtoveraetimestamp}" +BIN_LINK="${BIN_LINK:-/usr/local/bin/verae-print}" +BACKUP_ROOT="${BACKUP_ROOT:-/SSD2/verae-deploy-backups}" +GO_ROOT="${GO_ROOT:-/usr/local/lib/go1.26.9}" +GO_TARBALL="${GO_TARBALL:-go1.26.9.linux-amd64.tar.gz}" +GO_SHA256="${GO_SHA256:-42d158b4d8f7b61ac0a830567c940a86098fb7aac52e467a5ebec03ef5cc2f8d}" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +mkdir -p "$(dirname "$LOCK")" +exec 9>"$LOCK" +if ! flock -w 900 9; then + echo "another print deploy holds the lock" >&2 + exit 1 +fi + +prepare_cache() { + sudo mkdir -p "$BACKUP_ROOT/go-cache" "$BACKUP_ROOT/go-mod" + sudo chmod 1777 "$BACKUP_ROOT" "$BACKUP_ROOT/go-cache" "$BACKUP_ROOT/go-mod" + export GOCACHE="$BACKUP_ROOT/go-cache" + export GOMODCACHE="$BACKUP_ROOT/go-mod" + export GOPROXY="${GOPROXY:-https://proxy.golang.org,direct}" +} + +ensure_go() { + if [[ -x "$GO_ROOT/bin/go" ]] && "$GO_ROOT/bin/go" version | grep -q 'go1.26.9 '; then + export PATH="$GO_ROOT/bin:$PATH" + return + fi + local tmp + tmp="$(mktemp -d)" + curl -fsSL -o "$tmp/go.tgz" "https://go.dev/dl/${GO_TARBALL}" + echo "${GO_SHA256} ${tmp}/go.tgz" | sha256sum -c - + tar -C "$tmp" -xzf "$tmp/go.tgz" + sudo rm -rf "$GO_ROOT" + sudo mv "$tmp/go" "$GO_ROOT" + rm -rf "$tmp" + export PATH="$GO_ROOT/bin:$PATH" +} + +run_tests() { + prepare_cache + ensure_go + cd "$ROOT" + go test ./... -count=1 +} + +install_tree() { + local built stage previous ts sha + ts="$(date -u +%Y%m%dT%H%M%SZ)" + sha="$(git -C "$ROOT" rev-parse HEAD)" + cd "$ROOT" + built="$(mktemp)" + go build -o "$built" ./cmd/verae-print + if ! grep -a -q 'verae-meta-git/1' "$built"; then + echo "built binary is missing verae-meta-git/1" >&2 + rm -f "$built" + exit 1 + fi + if ! "$built" version >/dev/null; then + echo "built binary failed version" >&2 + rm -f "$built" + exit 1 + fi + stage="$(mktemp -d "$BACKUP_ROOT/print-stage.XXXXXX")" + sudo rsync -a \ + --exclude '.git/' \ + --exclude 'bin/' \ + "$ROOT/" "$stage/" + sudo mkdir -p "$stage/bin" + sudo cp "$built" "$stage/bin/verae-print" + sudo chmod 755 "$stage/bin/verae-print" + printf '%s\n' "$sha" | sudo tee "$stage/DEPLOYED_SHA" >/dev/null + rm -f "$built" + if ! sudo "$stage/bin/verae-print" version >/dev/null; then + sudo rm -rf "$stage" + echo "staged print binary failed version" >&2 + return 1 + fi + previous="" + if [[ -d "$DEST" ]]; then + previous="$BACKUP_ROOT/print-src-${ts}" + sudo mv "$DEST" "$previous" + fi + if ! sudo mv "$stage" "$DEST"; then + if [[ -n "$previous" ]]; then + sudo mv "$previous" "$DEST" + fi + echo "could not move the print checkout into place" >&2 + return 1 + fi + sudo ln -sfn "$DEST/bin/verae-print" "$BIN_LINK" + if ! "$BIN_LINK" version >/dev/null; then + echo "installed verae-print failed version" >&2 + if [[ -n "$previous" ]]; then + sudo rm -rf "$DEST" + sudo mv "$previous" "$DEST" + sudo ln -sfn "$DEST/bin/verae-print" "$BIN_LINK" + fi + return 1 + fi + echo "deployed ${ts} ${sha} version ok" +} + +run_tests +if [[ "${1:-}" == "--test-only" ]]; then + echo "tests ok" + exit 0 +fi + +install_tree diff --git a/scripts/test-138.sh b/scripts/test-138.sh new file mode 100755 index 0000000..76f6210 --- /dev/null +++ b/scripts/test-138.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Unit-test PrintToVeraeTimeStamp. Does not install the binary. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +exec bash "$ROOT/scripts/deploy-138.sh" --test-only