diff --git a/packages/sandbox/daemon-go/internal/content/errors.go b/packages/sandbox/daemon-go/internal/content/errors.go new file mode 100644 index 0000000000..36c1777da8 --- /dev/null +++ b/packages/sandbox/daemon-go/internal/content/errors.go @@ -0,0 +1,120 @@ +package content + +import "strconv" + +// Error codes: JSON-RPC 2.0's, then the content protocol's. Ported from +// `@decocms/blocks/protocol` errors.ts. +const ( + CodeParseError = -32700 + CodeInvalidRequest = -32600 + CodeMethodNotFound = -32601 + CodeInvalidParams = -32602 + CodeInternalError = -32603 + CodeNotFound = -32001 + CodeConflict = -32002 + CodeInvalidBlock = -32003 + CodeReadOnly = -32005 + CodeUnsupported = -32006 + CodeLimitExceeded = -32007 + CodeUnavailable = -32008 +) + +// ProtocolError is a method failure: the JSON-RPC `error` object. +type ProtocolError struct { + Code int + Message string + Data any // nil: no `data` member +} + +func (e *ProtocolError) Error() string { return e.Message } + +// JSON is the error object, `{code, message, data?}`. +func (e *ProtocolError) JSON() *Object { + o := NewObject("code", float64(e.Code), "message", e.Message) + if e.Data != nil { + o.Set("data", e.Data) + } + return o +} + +// Violation is one rule a blocks.apply breaks. +type Violation struct { + Name string + Pointer *string // set for a violation about one value + Rule string + Message string +} + +func (v Violation) json() *Object { + o := NewObject("name", v.Name) + if v.Pointer != nil { + o.Set("pointer", *v.Pointer) + } + o.Set("rule", v.Rule) + o.Set("message", v.Message) + return o +} + +func errNotFound(message string) *ProtocolError { + return &ProtocolError{Code: CodeNotFound, Message: message} +} + +func errConflict(entries *Object) *ProtocolError { + return &ProtocolError{Code: CodeConflict, Message: "a precondition failed", Data: NewObject("entries", entries)} +} + +func errInvalidBlock(violations []Violation) *ProtocolError { + message := strconv.Itoa(len(violations)) + " invalid blocks" + if len(violations) == 1 { + message = `invalid block "` + violations[0].Name + `": ` + violations[0].Message + } + list := make([]any, len(violations)) + for i, v := range violations { + list[i] = v.json() + } + return &ProtocolError{Code: CodeInvalidBlock, Message: message, Data: NewObject("violations", list)} +} + +func errReadOnly() *ProtocolError { + return &ProtocolError{Code: CodeReadOnly, Message: "this endpoint is read-only"} +} + +func errUnsupported(message string) *ProtocolError { + return &ProtocolError{Code: CodeUnsupported, Message: message} +} + +func errLimitExceeded(message string, data *Object) *ProtocolError { + e := &ProtocolError{Code: CodeLimitExceeded, Message: message} + if data != nil { + e.Data = data + } + return e +} + +func errUnavailable(message string, retryAfterMs int) *ProtocolError { + e := &ProtocolError{Code: CodeUnavailable, Message: message} + if retryAfterMs >= 0 { + e.Data = NewObject("retryAfterMs", float64(retryAfterMs)) + } + return e +} + +func errInvalidParams(message string) *ProtocolError { + return &ProtocolError{Code: CodeInvalidParams, Message: message} +} + +func errInvalidRequest(message string) *ProtocolError { + return &ProtocolError{Code: CodeInvalidRequest, Message: message} +} + +func errMethodNotFound(method string) *ProtocolError { + return &ProtocolError{Code: CodeMethodNotFound, Message: `unknown method "` + method + `"`} +} + +func errParse() *ProtocolError { + return &ProtocolError{Code: CodeParseError, Message: "invalid JSON"} +} + +func errInternal() *ProtocolError { + return &ProtocolError{Code: CodeInternalError, Message: "internal error"} +} diff --git a/packages/sandbox/daemon-go/internal/content/keys.go b/packages/sandbox/daemon-go/internal/content/keys.go new file mode 100644 index 0000000000..5a5cdcd260 --- /dev/null +++ b/packages/sandbox/daemon-go/internal/content/keys.go @@ -0,0 +1,365 @@ +package content + +// The one file-name rule for saved blocks, ported from +// `@decocms/blocks/protocol/keys` (keys.ts): +// +// - name to file: encodeURIComponent(name) + ".json", directly in .deco/blocks; +// - file to name: the stem decoded exactly once (the raw stem when that fails); +// - spellings of one name (files that decode to the same name after repeated +// decoding): the file whose entry has a `path` wins, then the one that took +// more decoding, then the lowest file name; +// - file content: JSON.stringify(entry, null, 2) + "\n". + +import ( + "sort" + "strings" + "unicode" + "unicode/utf8" +) + +const ( + blockFileExtension = ".json" + // MaxEncodedNameBytes keeps `.json` under a 255-byte file name. + MaxEncodedNameBytes = 250 +) + +var windowsDeviceNames = func() map[string]bool { + m := map[string]bool{"CON": true, "PRN": true, "AUX": true, "NUL": true} + for i := 1; i <= 9; i++ { + m["COM"+string(rune('0'+i))] = true + m["LPT"+string(rune('0'+i))] = true + } + return m +}() + +var sourceExtensions = []string{".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"} + +// uriError is JS's URIError: thrown (panicked) by encodeURIComponent on a lone +// surrogate. Like any unexpected throw in the TS server, it surfaces as an +// Internal error (-32603) for the call. +type uriError struct{} + +func isURIUnreserved(c byte) bool { + return c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || + strings.IndexByte("-_.!~*'()", c) >= 0 +} + +const upperHex = "0123456789ABCDEF" + +// encodeURIComponent is JS's encodeURIComponent; it panics with uriError on a +// lone surrogate, as JS throws. +func encodeURIComponent(s string) string { + var sb strings.Builder + for i := 0; i < len(s); { + c := s[i] + if c < 0x80 { + if isURIUnreserved(c) { + sb.WriteByte(c) + } else { + sb.WriteByte('%') + sb.WriteByte(upperHex[c>>4]) + sb.WriteByte(upperHex[c&0xF]) + } + i++ + continue + } + r, size := nextCodePoint(s, i) + if (r >= 0xD800 && r <= 0xDFFF) || (r == utf8.RuneError && size == 1) { + panic(uriError{}) + } + for k := 0; k < size; k++ { + b := s[i+k] + sb.WriteByte('%') + sb.WriteByte(upperHex[b>>4]) + sb.WriteByte(upperHex[b&0xF]) + } + i += size + } + return sb.String() +} + +// decodeURIComponent is JS's decodeURIComponent; ok is false where JS throws. +func decodeURIComponent(s string) (string, bool) { + if strings.IndexByte(s, '%') < 0 { + return s, true + } + var sb strings.Builder + octet := func(i int) (byte, bool) { + if i+2 >= len(s) || s[i] != '%' { + return 0, false + } + h, l := hexVal(s[i+1]), hexVal(s[i+2]) + if h < 0 || l < 0 { + return 0, false + } + return byte(h<<4 | l), true + } + for i := 0; i < len(s); { + if s[i] != '%' { + sb.WriteByte(s[i]) + i++ + continue + } + b, ok := octet(i) + if !ok { + return "", false + } + i += 3 + if b < 0x80 { + sb.WriteByte(b) + continue + } + n := 0 + for mask := byte(0x80); b&mask != 0 && n < 8; mask >>= 1 { + n++ + } + if n == 1 || n > 4 { + return "", false + } + octets := []byte{b} + for k := 1; k < n; k++ { + c, ok := octet(i) + if !ok || c&0xC0 != 0x80 { + return "", false + } + octets = append(octets, c) + i += 3 + } + r, size := utf8.DecodeRune(octets) + if size != n || (r == utf8.RuneError && size == 1) { + return "", false + } + sb.Write(octets) + } + return sb.String(), true +} + +// BlockFileName is the file a saved block named name is stored in. +func BlockFileName(name string) string { + return encodeURIComponent(name) + blockFileExtension +} + +// IsBlockFileName reports a saved-block file name: `.json`, a stem, no folder, +// not a dotfile. +func IsBlockFileName(file string) bool { + return strings.HasSuffix(file, blockFileExtension) && + !strings.HasPrefix(file, ".") && + len(file) > len(blockFileExtension) && + !strings.Contains(file, "/") && + !strings.Contains(file, `\`) +} + +func stem(file string) string { + return strings.TrimSuffix(file, blockFileExtension) +} + +// BlockNameFromFile is the entry name stored in file: the stem decoded once. +func BlockNameFromFile(file string) string { + raw := stem(file) + if name, ok := decodeURIComponent(raw); ok { + return name + } + return raw +} + +// FullyDecodeFileName decodes file's stem until it stops changing: the +// spelling-group key, and how many decodes it took. +func FullyDecodeFileName(file string) (string, int) { + name := stem(file) + passes := 0 + for strings.Contains(name, "%") { + next, ok := decodeURIComponent(name) + if !ok || next == name { + break + } + name = next + passes++ + } + return name, passes +} + +// SpellingKey is the spelling-group key of a saved-block name. +func SpellingKey(name string) string { + key, _ := FullyDecodeFileName(BlockFileName(name)) + return key +} + +// SerializeBlock is the bytes a saved block is stored as. +func SerializeBlock(entry any) string { + return Stringify(entry, 2) + "\n" +} + +// entryHasPath reports a page-like entry: a non-empty string `path`. +func entryHasPath(entry any) bool { + o, ok := entry.(*Object) + if !ok { + return false + } + p, ok := o.Get("path") + s, isString := p.(string) + return ok && isString && len(s) > 0 +} + +// spellingCandidate is one file holding (a spelling of) a saved block. +type spellingCandidate struct { + File string + Version string + HasPath bool + Value *Object // nil when the body wasn't read +} + +func compareSpellings(a, b *spellingCandidate) int { + if a.HasPath != b.HasPath { + if a.HasPath { + return -1 + } + return 1 + } + _, passesA := FullyDecodeFileName(a.File) + _, passesB := FullyDecodeFileName(b.File) + if passesA != passesB { + return passesB - passesA + } + return compareJS(a.File, b.File) +} + +type resolvedSpelling struct { + Name string + Winner *spellingCandidate + Shadowed []*spellingCandidate +} + +// resolveSpellings groups candidates by spelling key and picks one winner per +// group, returned in file-name order of the winners. +func resolveSpellings(candidates []*spellingCandidate) []resolvedSpelling { + var order []string + groups := map[string][]*spellingCandidate{} + for _, c := range candidates { + key, _ := FullyDecodeFileName(c.File) + if _, ok := groups[key]; !ok { + order = append(order, key) + } + groups[key] = append(groups[key], c) + } + winners := make([]resolvedSpelling, 0, len(order)) + for _, key := range order { + group := append([]*spellingCandidate(nil), groups[key]...) + sort.SliceStable(group, func(i, j int) bool { return compareSpellings(group[i], group[j]) < 0 }) + winners = append(winners, resolvedSpelling{ + Name: BlockNameFromFile(group[0].File), + Winner: group[0], + Shadowed: group[1:], + }) + } + sort.SliceStable(winners, func(i, j int) bool { + return compareJS(winners[i].Winner.File, winners[j].Winner.File) < 0 + }) + // A Map keyed by name: a later winner with the same name replaces the + // earlier one in place (as `new Map(entries)` does). + out := make([]resolvedSpelling, 0, len(winners)) + index := map[string]int{} + for _, w := range winners { + if i, ok := index[w.Name]; ok { + out[i] = w + continue + } + index[w.Name] = len(out) + out = append(out, w) + } + return out +} + +// NameViolation is a rule a name breaks. +type NameViolation struct { + Reason string + Message string +} + +// jsLower is String.prototype.toLowerCase, close enough: per-code-point +// lowercase that leaves lone surrogates alone, with U+0130's special mapping. +// OPEN: the context-sensitive final-sigma rule isn't ported. +func jsLower(s string) string { + ascii := true + for i := 0; i < len(s) && ascii; i++ { + ascii = s[i] < 0x80 + } + if ascii { + return strings.ToLower(s) + } + var sb strings.Builder + for i := 0; i < len(s); { + r, size := nextCodePoint(s, i) + switch { + case r >= 0xD800 && r <= 0xDFFF, r == utf8.RuneError && size == 1: + sb.WriteString(s[i : i+size]) + case r == 0x130: + sb.WriteString("i̇") + default: + sb.WriteRune(unicode.ToLower(r)) + } + i += size + } + return sb.String() +} + +// checkBlockName reports every rule a name to save breaks. existing, when +// non-nil, refuses a new name differing from an existing one only in case. +func checkBlockName(name string, existing []string) []NameViolation { + if name == "" { + return []NameViolation{{"empty", "the name is empty"}} + } + var out []NameViolation + if strings.Contains(name, `\`) || strings.Contains(name, "\x00") { + out = append(out, NameViolation{"invalid-character", "the name contains a backslash or a NUL character"}) + } + if strings.Contains(name, "..") { + out = append(out, NameViolation{"dot-dot", `the name contains ".."`}) + } + if strings.HasPrefix(name, ".") { + out = append(out, NameViolation{"leading-dot", `the name starts with ".", which would make its file hidden`}) + } + if name == "__proto__" { + out = append(out, NameViolation{"reserved", `the name "__proto__" is reserved`}) + } + encoded := encodeURIComponent(name) + if len(encoded) > MaxEncodedNameBytes { + out = append(out, NameViolation{"too-long", "the encoded name is over 250 bytes"}) + } + deviceStem := strings.ToUpper(strings.SplitN(encoded, ".", 2)[0]) + if windowsDeviceNames[deviceStem] { + out = append(out, NameViolation{"device-name", `"` + deviceStem + `" is a Windows device name`}) + } + lower := jsLower(name) + for _, ext := range sourceExtensions { + if strings.HasSuffix(lower, ext) { + out = append(out, NameViolation{"source-extension", `the name ends in "` + ext + `", which would shadow a source module`}) + break + } + } + if existing != nil { + isNew := true + collidesWith := "" + found := false + for _, e := range existing { + if e == name { + isNew = false + break + } + if !found && jsLower(e) == lower { + collidesWith, found = e, true + } + } + if isNew && found { + out = append(out, NameViolation{"case-collision", `the name differs from the existing entry "` + collidesWith + `" only in letter case`}) + } + } + return out +} + +// checkDeletedName: anything non-empty can be deleted. +func checkDeletedName(name string) []NameViolation { + if name == "" { + return []NameViolation{{"empty", "the name is empty"}} + } + return nil +} diff --git a/packages/sandbox/daemon-go/internal/content/keys_test.go b/packages/sandbox/daemon-go/internal/content/keys_test.go new file mode 100644 index 0000000000..2f677626d1 --- /dev/null +++ b/packages/sandbox/daemon-go/internal/content/keys_test.go @@ -0,0 +1,186 @@ +package content + +import ( + "reflect" + "strings" + "testing" +) + +// Ported from keys.test.ts. + +func TestBlockFileName(t *testing.T) { + cases := map[string]string{ + "pages-Home%20Page-6f1e": "pages-Home%2520Page-6f1e.json", + "collections/blog/posts/abc": "collections%2Fblog%2Fposts%2Fabc.json", + "Header": "Header.json", + "pages-Home Page": "pages-Home%20Page.json", + "Cores dos preços": "Cores%20dos%20pre%C3%A7os.json", + "50% off": "50%25%20off.json", + "a!~*'()b": "a!~*'()b.json", + } + for name, file := range cases { + if got := BlockFileName(name); got != file { + t.Errorf("BlockFileName(%q) = %q, want %q", name, got, file) + } + } +} + +func TestEncodeURIComponentPanicsOnALoneSurrogate(t *testing.T) { + defer func() { + if _, ok := recover().(uriError); !ok { + t.Error("expected a uriError panic") + } + }() + encodeURIComponent("x\xed\xa0\x80") +} + +func TestBlockNameFromFileDecodesOnce(t *testing.T) { + cases := map[string]string{ + "pages-Home%2520Page-6f1e.json": "pages-Home%20Page-6f1e", + "collections%2Fblog%2Fposts%2Fabc.json": "collections/blog/posts/abc", + "pages-Home%20Page.json": "pages-Home Page", + "Header.json": "Header", + "50% off.json": "50% off", + "bad%E0%A4%A.json": "bad%E0%A4%A", + "bad%C0%80.json": "bad%C0%80", // overlong: URIError in JS + "sur%ED%A0%80.json": "sur%ED%A0%80", + } + for file, name := range cases { + if got := BlockNameFromFile(file); got != name { + t.Errorf("BlockNameFromFile(%q) = %q, want %q", file, got, name) + } + } + for _, name := range []string{"a", "pages-Home%20Page-6f1e", "x/y", "50% off", "é ü 世界", "%", "%%25"} { + if got := BlockNameFromFile(BlockFileName(name)); got != name { + t.Errorf("round trip of %q gave %q", name, got) + } + } +} + +func TestSpellings(t *testing.T) { + check := func(file, name string, passes int) { + t.Helper() + n, p := FullyDecodeFileName(file) + if n != name || p != passes { + t.Errorf("FullyDecodeFileName(%q) = %q, %d; want %q, %d", file, n, p, name, passes) + } + } + check("pages-Home%2520Page.json", "pages-Home Page", 2) + check("pages-Home%20Page.json", "pages-Home Page", 1) + check("Header.json", "Header", 0) + check("50% off.json", "50% off", 0) + if SpellingKey("pages-Home%20Page") != SpellingKey("pages-Home Page") || SpellingKey("A%2520B") != "A B" { + t.Error("spelling keys") + } + + names := func(rs []resolvedSpelling) []string { + var out []string + for _, r := range rs { + out = append(out, r.Name) + } + return out + } + // A path wins, then more decoding, then the lowest file name. + a := &spellingCandidate{File: "pages-Home%2520Page.json"} + b := &spellingCandidate{File: "pages-Home%20Page.json", HasPath: true} + if r := resolveSpellings([]*spellingCandidate{a, b}); r[0].Winner != b { + t.Error("the file with a path must win") + } + bot := &spellingCandidate{File: "pages-Home%2520Page.json", HasPath: true} + legacy := &spellingCandidate{File: "pages-Home%20Page.json", HasPath: true} + r := resolveSpellings([]*spellingCandidate{legacy, bot}) + if !reflect.DeepEqual(names(r), []string{"pages-Home%20Page"}) || r[0].Winner != bot || r[0].Shadowed[0] != legacy { + t.Errorf("more decoding must win: %+v", r) + } + upper := &spellingCandidate{File: "A%2FB.json"} + lower := &spellingCandidate{File: "A%2fB.json"} + if r := resolveSpellings([]*spellingCandidate{lower, upper}); r[0].Winner != upper || r[0].Name != "A/B" { + t.Error("the lowest file name must win") + } + three := resolveSpellings([]*spellingCandidate{{File: "x%2520y.json"}, {File: "x%20y.json"}, {File: "x y.json"}}) + if three[0].Name != "x%20y" || three[0].Shadowed[0].File != "x%20y.json" || three[0].Shadowed[1].File != "x y.json" { + t.Errorf("three spellings: %+v", three) + } + if got := names(resolveSpellings([]*spellingCandidate{{File: "b.json"}, {File: "a.json"}})); !reflect.DeepEqual(got, []string{"a", "b"}) { + t.Errorf("distinct names: %v", got) + } +} + +func TestSerializeBlockAndFileNames(t *testing.T) { + v, _ := ParseJSON(`{"a":1,"b":[true]}`) + if got := SerializeBlock(v); got != "{\n \"a\": 1,\n \"b\": [\n true\n ]\n}\n" { + t.Errorf("SerializeBlock = %q", got) + } + for file, want := range map[string]bool{ + "a.json": true, ".json": false, "a.ts": false, "a/b.json": false, + ".env.json": false, "..json": false, ".DS_Store.json": false, `a\b.json`: false, + } { + if IsBlockFileName(file) != want { + t.Errorf("IsBlockFileName(%q) != %v", file, want) + } + } + for entry, want := range map[string]bool{`{"path":"/"}`: true, `{"path":""}`: false, `{"path":1}`: false, `null`: false, `[]`: false} { + v, _ := ParseJSON(entry) + if entryHasPath(v) != want { + t.Errorf("entryHasPath(%s) != %v", entry, want) + } + } +} + +func reasons(name string, existing []string) []string { + var out []string + for _, v := range checkBlockName(name, existing) { + out = append(out, v.Reason) + } + return out +} + +func TestCheckBlockName(t *testing.T) { + cases := [][2]string{ + {"", "empty"}, {`a\b`, "invalid-character"}, {"a\x00b", "invalid-character"}, + {"a..b", "dot-dot"}, {"..", "dot-dot"}, {".env", "leading-dot"}, {".", "leading-dot"}, + {".hidden page", "leading-dot"}, {"__proto__", "reserved"}, {"CON", "device-name"}, + {"con", "device-name"}, {"nul.backup", "device-name"}, {"COM1", "device-name"}, + {"lpt9", "device-name"}, {"widget.ts", "source-extension"}, {"Widget.TSX", "source-extension"}, + {"helper.js", "source-extension"}, + } + for _, c := range cases { + got := reasons(c[0], nil) + found := false + for _, r := range got { + found = found || r == c[1] + } + if !found { + t.Errorf("checkBlockName(%q) = %v, want %s", c[0], got, c[1]) + } + } + if got := reasons(strings.Repeat("a", 250), nil); got != nil { + t.Errorf("250 bytes: %v", got) + } + if got := reasons(strings.Repeat("a", 251), nil); !reflect.DeepEqual(got, []string{"too-long"}) { + t.Errorf("251 bytes: %v", got) + } + if got := reasons(strings.Repeat("é", 41), nil); got != nil { + t.Errorf("41 é: %v", got) + } + if got := reasons(strings.Repeat("é", 42), nil); !reflect.DeepEqual(got, []string{"too-long"}) { + t.Errorf("42 é: %v", got) + } + if got := reasons("header", []string{"Header"}); !reflect.DeepEqual(got, []string{"case-collision"}) { + t.Errorf("case collision: %v", got) + } + if reasons("Header", []string{"Header"}) != nil || reasons("Footer", []string{"Header"}) != nil { + t.Error("an update or another name isn't a collision") + } + for _, name := range []string{"Header", "pages-Home Page-6f1e", "collections/blog/posts/abc", "COM", "CONsole", "a.json", "a.b", "end."} { + if got := reasons(name, nil); got != nil { + t.Errorf("%q: %v", name, got) + } + } + if got := reasons(`x..y\z.ts`, nil); !reflect.DeepEqual(got, []string{"invalid-character", "dot-dot", "source-extension"}) { + t.Errorf("every rule: %v", got) + } + if checkDeletedName("widget.ts") != nil || checkDeletedName("CON") != nil || checkDeletedName("")[0].Reason != "empty" { + t.Error("deleted names") + } +}