mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-27 06:47:07 +00:00
c17865462f
* feat: built-ins record their origin, and an item made from one can be offered Pad's current text (TASK-3462 U1+U2)
U1: every convention and playbook Pad ships has a stable key and a content
hash; template seeding and the new server-side POST /library/activate record
{key, seed hash, seed text} in item_builtin_origin (migration 128 / pg 102).
CLI, remote MCP and the web Library page activate through the endpoint;
seeded and activated copies of an entry store the same fields. Export/import
carry the origin; copies get none; a request body cannot claim one.
U2: GET /items/{ref}/builtin derives current / update_available / diverged /
unknown_origin / unknown_entry; POST /items/{ref}/builtin/update takes the
library text through the ordinary PATCH path (expected_seq required, status
and title untouched, a forced version row keeps a diverged item's edits);
GET /builtins lists states in one join, showing only fully visible
collections.
* fix: the origin's seed text is NUL-protected (trigger migration 129); the MCP activate test asserts the endpoint call; activation follows the declared kind server-side (TASK-3462)
* fix: the built-in seed moves in the update's own transaction; a NUL-bearing imported origin is skipped; the listing computes visibility once; numbers hash by value (TASK-3462, codex r1)
* fix: on the applier path the built-in seed is recorded only after the apply confirms; a library change that only drops a field is offered (TASK-3462, codex r2)
* fix: a field the library added that the user already set reads diverged; an update clears convention metadata the library dropped (TASK-3462, codex r3)
* test: the lifecycle test finds the imported item by slug (the copy shares its title); ScanNUL's cost figures count the two new columns (TASK-3462)
* test(e2e): Library activate posts the entry to /library/activate and the item records its built-in origin (TASK-3462)
90 lines
3.3 KiB
Go
90 lines
3.3 KiB
Go
package models
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// BuiltinOrigin is the built-in convention or playbook an item was made from
|
|
// (TASK-3462): the entry's key, the hash of the entry's text the item was
|
|
// given, and that text. Empty seed members mean the version is unknown.
|
|
type BuiltinOrigin struct {
|
|
Key string `json:"key"`
|
|
SeedHash string `json:"seed_hash,omitempty"`
|
|
SeedContent string `json:"seed_content,omitempty"`
|
|
SeedFields string `json:"seed_fields,omitempty"`
|
|
}
|
|
|
|
var (
|
|
builtinKeyShape = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*(?:/[a-z0-9]+(?:-[a-z0-9]+)*){1,2}$`)
|
|
builtinHashShape = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
|
)
|
|
|
|
// MaxBuiltinKeyLen bounds a stored key. The longest compiled-in key is under
|
|
// 100 bytes; this leaves room without letting an import store an essay.
|
|
const MaxBuiltinKeyLen = 200
|
|
|
|
// MaxBuiltinSeedBytes bounds the stored seed text, body and fields each. The
|
|
// largest compiled-in body is under 40 KB.
|
|
const MaxBuiltinSeedBytes = 256 * 1024
|
|
|
|
// Validate refuses an origin whose key is not a lowercase path, whose hash is
|
|
// not a sha256 hex digest, whose seed text is oversized or whose seed fields
|
|
// are not a JSON object, or which carries seed text without the hash it is
|
|
// the text of. It does not require the key to name a built-in this binary
|
|
// knows: an imported origin may name one a newer Pad shipped. Whether the
|
|
// text hashes to SeedHash is the caller's check (collections owns the hash).
|
|
func (o BuiltinOrigin) Validate() error {
|
|
if len(o.Key) > MaxBuiltinKeyLen || !builtinKeyShape.MatchString(o.Key) {
|
|
return fmt.Errorf("built-in origin key %q is not a lowercase path like playbook/ship", o.Key)
|
|
}
|
|
if o.SeedHash != "" && !builtinHashShape.MatchString(o.SeedHash) {
|
|
return fmt.Errorf("built-in origin seed_hash for %q is not a sha256 hex digest", o.Key)
|
|
}
|
|
if (o.SeedContent != "" || o.SeedFields != "") && o.SeedHash == "" {
|
|
return fmt.Errorf("built-in origin for %q carries seed text without its hash", o.Key)
|
|
}
|
|
if len(o.SeedContent) > MaxBuiltinSeedBytes || len(o.SeedFields) > MaxBuiltinSeedBytes {
|
|
return fmt.Errorf("built-in origin seed text for %q is over %d bytes", o.Key, MaxBuiltinSeedBytes)
|
|
}
|
|
if o.SeedFields != "" {
|
|
var m map[string]any
|
|
if err := json.Unmarshal([]byte(o.SeedFields), &m); err != nil || m == nil {
|
|
return fmt.Errorf("built-in origin seed_fields for %q is not a JSON object", o.Key)
|
|
}
|
|
// An escape decoding to NUL is refused like a raw one (codex r1):
|
|
// Postgres's jsonb refuses it, and SQLite's trigger 129 refuses both,
|
|
// and a refused insert there poisons the whole import.
|
|
if jsonHoldsNUL(m) {
|
|
return fmt.Errorf("built-in origin seed_fields for %q holds a NUL", o.Key)
|
|
}
|
|
}
|
|
if strings.ContainsRune(o.SeedContent, 0) || strings.ContainsRune(o.SeedFields, 0) {
|
|
return fmt.Errorf("built-in origin seed text for %q holds a NUL", o.Key)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// jsonHoldsNUL reports whether any decoded string or key in v contains NUL.
|
|
func jsonHoldsNUL(v any) bool {
|
|
switch t := v.(type) {
|
|
case string:
|
|
return strings.ContainsRune(t, 0)
|
|
case map[string]any:
|
|
for k, x := range t {
|
|
if strings.ContainsRune(k, 0) || jsonHoldsNUL(x) {
|
|
return true
|
|
}
|
|
}
|
|
case []any:
|
|
for _, x := range t {
|
|
if jsonHoldsNUL(x) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|