Recording the full mcp-lifecycle fixture against real isolated PHP (08-09-PLAN.md Task 2) uncovered three byte-level gaps between wristband's assumed contract and actual production PHP behavior: - Every explicit "Cache-Control: no-store" PHP sets is actually delivered as "no-store, private" (Laravel's session-cookie default merges "private" onto any explicit value); wristband's own default for unheadered JSON error responses is "no-cache, private" (matching the house convention already used elsewhere), not empty. - PHP's redirect responses (authorize success and every error redirect) render Symfony's default HTML redirect body with Content-Type "text/html; charset=utf-8"; Go's bare 302 with no body never matched. wristband/redirect_html.go ports that exact byte template, including PHP's htmlspecialchars(ENT_QUOTES) escaping (Go's html.EscapeString uses different quote entities). tide/normalize.go: isIDKey now also masks "_ids" plural array fields (e.g. collection_ids), a latent parity-corpus gap no prior fixture had exercised with a literal, non-empty, non-placeholder array value.
157 lines
3.6 KiB
Go
157 lines
3.6 KiB
Go
package tide
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
var carbonOffsetRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$`)
|
|
|
|
const (
|
|
maskDatetime = "<datetime>"
|
|
maskID = "<id>"
|
|
)
|
|
|
|
func normalizeJSON(raw []byte, step Step) ([]byte, []Diff) {
|
|
if len(strings.TrimSpace(string(raw))) == 0 {
|
|
return raw, nil
|
|
}
|
|
val, err := decodeJSON(raw)
|
|
if err != nil {
|
|
return raw, nil
|
|
}
|
|
var diffs []Diff
|
|
masked := maskValue("$", val, step, &diffs)
|
|
out, err := json.Marshal(masked)
|
|
if err != nil {
|
|
return raw, diffs
|
|
}
|
|
return out, diffs
|
|
}
|
|
|
|
func maskValue(path string, val any, step Step, diffs *[]Diff) any {
|
|
switch v := val.(type) {
|
|
case map[string]any:
|
|
out := make(map[string]any, len(v))
|
|
for k, child := range v {
|
|
out[k] = maskValue(pathJoin(path, k), child, step, diffs)
|
|
}
|
|
return out
|
|
case []any:
|
|
out := make([]any, len(v))
|
|
for i, child := range v {
|
|
out[i] = maskValue(fmt.Sprintf("%s[%d]", path, i), child, step, diffs)
|
|
}
|
|
return out
|
|
default:
|
|
return maskLeaf(path, val, step, diffs)
|
|
}
|
|
}
|
|
|
|
func maskLeaf(path string, val any, step Step, diffs *[]Diff) any {
|
|
key := lastPathKey(path)
|
|
if key == "slug" || disabledPath(step, path, key) {
|
|
return val
|
|
}
|
|
if key == "collection_key" || key == "client_id" {
|
|
if val == nil {
|
|
return nil
|
|
}
|
|
if _, ok := val.(string); !ok {
|
|
*diffs = append(*diffs, Diff{Path: path, Expected: "string " + key, Actual: formatValue(val)})
|
|
return val
|
|
}
|
|
return maskID
|
|
}
|
|
if strings.HasSuffix(key, "_issued_at") {
|
|
return maskIDValue(path, val, diffs)
|
|
}
|
|
if isDateKey(key) {
|
|
return maskDate(path, val, diffs)
|
|
}
|
|
if isIDKey(key) {
|
|
return maskIDValue(path, val, diffs)
|
|
}
|
|
return val
|
|
}
|
|
|
|
func maskDate(path string, val any, diffs *[]Diff) any {
|
|
if val == nil {
|
|
return nil
|
|
}
|
|
s, ok := val.(string)
|
|
if !ok {
|
|
*diffs = append(*diffs, Diff{Path: path, Expected: "Carbon +00:00 string or null", Actual: formatValue(val)})
|
|
return val
|
|
}
|
|
if !carbonOffsetRe.MatchString(s) {
|
|
*diffs = append(*diffs, Diff{Path: path, Expected: "Carbon +00:00", Actual: strconvQuote(s)})
|
|
return val
|
|
}
|
|
return maskDatetime
|
|
}
|
|
|
|
func maskIDValue(path string, val any, diffs *[]Diff) any {
|
|
if val == nil {
|
|
return nil
|
|
}
|
|
n, ok := val.(json.Number)
|
|
if !ok {
|
|
*diffs = append(*diffs, Diff{Path: path, Expected: "integer id", Actual: formatValue(val)})
|
|
return val
|
|
}
|
|
if strings.Contains(string(n), ".") {
|
|
*diffs = append(*diffs, Diff{Path: path, Expected: "integer id", Actual: "number " + string(n)})
|
|
return val
|
|
}
|
|
return maskID
|
|
}
|
|
|
|
func isDateKey(key string) bool {
|
|
return strings.HasSuffix(key, "_at") || key == "checkpoint"
|
|
}
|
|
|
|
func isIDKey(key string) bool {
|
|
if key == "id" {
|
|
return true
|
|
}
|
|
if strings.HasSuffix(key, "_at") {
|
|
return false
|
|
}
|
|
// "_ids" covers plural raw-integer-array fields such as collection_ids:
|
|
// each array element still reaches maskLeaf individually (maskValue
|
|
// recurses into []any before calling maskLeaf), so this masks every
|
|
// element the same way a singular "_id" scalar would be masked.
|
|
return strings.HasSuffix(key, "_id") || strings.HasSuffix(key, "_ids")
|
|
}
|
|
|
|
func lastPathKey(path string) string {
|
|
path = strings.TrimPrefix(path, "$.")
|
|
if i := strings.LastIndex(path, "."); i >= 0 {
|
|
path = path[i+1:]
|
|
}
|
|
if i := strings.IndexByte(path, '['); i >= 0 {
|
|
path = path[:i]
|
|
}
|
|
return path
|
|
}
|
|
|
|
func disabledPath(step Step, jsonPath, key string) bool {
|
|
for _, rule := range step.Normalize {
|
|
if !rule.Disable {
|
|
continue
|
|
}
|
|
p := strings.TrimSpace(rule.Path)
|
|
if p == jsonPath || p == key || strings.TrimPrefix(p, "$.") == strings.TrimPrefix(jsonPath, "$.") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func strconvQuote(s string) string {
|
|
return `"` + s + `"`
|
|
}
|