← 中文资源

简体中文版本

安全解析 RFC 6901 JSON Pointer

在已解码 JSON 文档上解析 JSON Pointer,严格校验转义并给出遍历错误。

阅读英文原文

功能说明

JSON Pointer 用类似 /items/1/name 的路径定位 JSON 文档中的节点,常用于 JSON Patch、配置覆盖和调试工具。这段代码实现了 RFC 6901 的解析与求值:支持对象键、数组下标、空指针(指向整个文档),以及 ~0、~1 两种转义。

实现会严格拒绝不以 / 开头的指针、非法转义、缺失键、越界下标和向标量继续下钻,避免返回模糊的零值掩盖数据问题。

关键代码

escapeToken 按规范先替换 ~ 为 ~0,再替换 / 为 ~1;顺序不能颠倒,否则会把新生成的 ~0 再次改写。decodeToken 逐字节扫描,遇到 ~ 时只接受 0 和 1 两种后续字符。

parsePointer 用 / 切分后逐段解码;根指针直接返回空 token 列表。Resolve 根据当前节点的类型分支:对象按键取值,数组用 strconv.Atoi 转换下标并检查边界,其他类型立即报错。

使用注意

解码后的 JSON 数字都是 float64,大整数可能丢失精度;需要精确数值时应使用 json.Decoder.UseNumber。数组下标接受 01 这类带前导零的写法,这是相对规范的简化。

- 在 JSON Patch 中表示“数组末尾追加”,但作为读取指针是非法的,代码会明确拒绝。指针中的键名必须已经转义,用户输入不能直接拼接。空字符串指针返回整个文档,调用方应确认这是期望行为。

验证方式

示例从 /items/1/name 取到 second,从 /a~1b/m~0n 取到 42,证明数组下标和转义键都能正确处理。escapeToken("a/b~c") 输出 a~1b~0c。越界下标 /items/9 和非法转义 /bad~2escape 都会返回错误并触发断言。

代码示例

package main

import (
	"encoding/json"
	"fmt"
	"strconv"
	"strings"
)

func escapeToken(token string) string {
	token = strings.ReplaceAll(token, "~", "~0")
	return strings.ReplaceAll(token, "/", "~1")
}

func decodeToken(token string) (string, error) {
	var builder strings.Builder
	for index := 0; index < len(token); index++ {
		if token[index] != '~' {
			builder.WriteByte(token[index])
			continue
		}
		if index+1 >= len(token) {
			return "", fmt.Errorf("dangling ~ in token %q", token)
		}
		switch token[index+1] {
		case '0':
			builder.WriteByte('~')
		case '1':
			builder.WriteByte('/')
		default:
			return "", fmt.Errorf("invalid escape ~%c in token %q", token[index+1], token)
		}
		index++
	}
	return builder.String(), nil
}

func parsePointer(pointer string) ([]string, error) {
	if pointer == "" {
		return nil, nil
	}
	if !strings.HasPrefix(pointer, "/") {
		return nil, fmt.Errorf("pointer must start with /: %q", pointer)
	}
	rawParts := strings.Split(pointer[1:], "/")
	tokens := make([]string, 0, len(rawParts))
	for _, part := range rawParts {
		token, err := decodeToken(part)
		if err != nil {
			return nil, err
		}
		tokens = append(tokens, token)
	}
	return tokens, nil
}

func Resolve(document any, pointer string) (any, error) {
	tokens, err := parsePointer(pointer)
	if err != nil {
		return nil, err
	}
	current := document
	for _, token := range tokens {
		switch node := current.(type) {
		case map[string]any:
			value, ok := node[token]
			if !ok {
				return nil, fmt.Errorf("missing key %q", token)
			}
			current = value
		case []any:
			if token == "-" {
				return nil, fmt.Errorf("'-' is only valid when appending")
			}
			index, err := strconv.Atoi(token)
			if err != nil || index < 0 || index >= len(node) {
				return nil, fmt.Errorf("invalid array index %q", token)
			}
			current = node[index]
		default:
			return nil, fmt.Errorf("cannot descend into %T at %q", current, token)
		}
	}
	return current, nil
}

func main() {
	payload := `{"items":[{"name":"first"},{"name":"second"}],"a/b":{"m~n":42}}`
	document := map[string]any{}
	if err := json.Unmarshal([]byte(payload), &document); err != nil {
		panic(err)
	}
	name, err := Resolve(document, "/items/1/name")
	if err != nil || name != "second" {
		panic("failed to resolve array element")
	}
	value, err := Resolve(document, "/a~1b/m~0n")
	if err != nil || value != float64(42) {
		panic("failed to resolve escaped keys")
	}
	fmt.Println(name, value, escapeToken("a/b~c"))
	if _, err := Resolve(document, "/items/9"); err == nil {
		panic("out-of-range index should fail")
	}
	if _, err := Resolve(document, "/bad~2escape"); err == nil {
		panic("invalid escape should fail")
	}
}