核心是确认「改配置而不弄乱文件」这件事成立:新增一路入站之后,其余 52 行逐字节不变。 搬进来的改写引擎用默认输出参数时会重排整个文件——行尾注释的对齐空格消失、流式数组被加上内边距—— 连根本没改动的段落也一起波及。不修的话「改一处只动一处」这个承诺直接落空,所以关掉了它的自动排版, 并在写回之后补了一步逐行还原。
1085 lines
31 KiB
TypeScript
1085 lines
31 KiB
TypeScript
import { isMap, parseDocument } from 'yaml'
|
||
|
||
export type ConfigValue =
|
||
| null
|
||
| boolean
|
||
| number
|
||
| string
|
||
| ConfigValue[]
|
||
| { [key: string]: ConfigValue }
|
||
|
||
export type ConfigObject = Record<string, ConfigValue>
|
||
|
||
export interface ConfigDiagnostic {
|
||
path: Array<string | number>
|
||
code:
|
||
| 'invalid-root'
|
||
| 'duplicate-name'
|
||
| 'missing-name'
|
||
| 'missing-type'
|
||
| 'missing-reference'
|
||
| 'invalid-rule'
|
||
message: string
|
||
severity: 'error' | 'warning'
|
||
}
|
||
|
||
export interface ConfigDocument {
|
||
data: ConfigObject
|
||
yaml: string
|
||
revision: string
|
||
diagnostics: ConfigDiagnostic[]
|
||
}
|
||
|
||
export interface PatchTarget {
|
||
path: string[]
|
||
/** Field used as semantic identity for entries in an array. */
|
||
key?: string
|
||
/** Identity value for a named resource. */
|
||
id?: string
|
||
}
|
||
|
||
export type ConfigPatchOperation =
|
||
| {
|
||
op: 'set'
|
||
target: PatchTarget
|
||
expectedHash: string
|
||
value: ConfigValue
|
||
}
|
||
| {
|
||
op: 'remove'
|
||
target: PatchTarget
|
||
expectedHash: string
|
||
}
|
||
| {
|
||
op: 'upsert'
|
||
target: Required<PatchTarget>
|
||
expectedHash: string
|
||
value: ConfigObject
|
||
}
|
||
| {
|
||
op: 'delete'
|
||
target: Required<PatchTarget>
|
||
expectedHash: string
|
||
}
|
||
| {
|
||
op: 'order'
|
||
target: PatchTarget
|
||
expectedHash: string
|
||
previousIds: string[]
|
||
ids: string[]
|
||
}
|
||
| {
|
||
op: 'sequence-replace'
|
||
target: PatchTarget & { id: string }
|
||
expectedHash: string
|
||
value: ConfigValue
|
||
}
|
||
| {
|
||
op: 'sequence-delete'
|
||
target: PatchTarget & { id: string }
|
||
expectedHash: string
|
||
}
|
||
| {
|
||
op: 'sequence-insert'
|
||
target: PatchTarget & { id: string }
|
||
expectedHash: string
|
||
value: ConfigValue
|
||
beforeId?: string
|
||
}
|
||
| {
|
||
op: 'sequence-order'
|
||
target: PatchTarget
|
||
expectedHash: string
|
||
previousIds: string[]
|
||
ids: string[]
|
||
}
|
||
|
||
export interface ConfigPatchV1 {
|
||
version: 1
|
||
baseRevision: string
|
||
operations: ConfigPatchOperation[]
|
||
}
|
||
|
||
export interface ConfigPatchConflict {
|
||
operation: ConfigPatchOperation
|
||
path: Array<string | number>
|
||
reason: 'changed' | 'missing' | 'duplicate' | 'invalid-target'
|
||
current?: ConfigValue
|
||
}
|
||
|
||
export interface ApplyPatchResult {
|
||
document: ConfigDocument
|
||
conflicts: ConfigPatchConflict[]
|
||
applied: number
|
||
}
|
||
|
||
export type ConfigResourceReferenceKind =
|
||
| 'proxy-group-member'
|
||
| 'rule-policy'
|
||
| 'dialer-proxy'
|
||
| 'provider-proxy'
|
||
| 'listener-proxy'
|
||
| 'tunnel-proxy'
|
||
|
||
export interface ConfigResourceReference {
|
||
path: Array<string | number>
|
||
kind: ConfigResourceReferenceKind
|
||
name: string
|
||
}
|
||
|
||
export class ConfigDocumentError extends Error {
|
||
constructor(
|
||
message: string,
|
||
readonly errors: string[],
|
||
) {
|
||
super(message)
|
||
this.name = 'ConfigDocumentError'
|
||
}
|
||
}
|
||
|
||
const NAMED_ARRAYS: Record<string, string> = {
|
||
proxies: 'name',
|
||
'proxy-groups': 'name',
|
||
listeners: 'name',
|
||
tunnels: 'network',
|
||
}
|
||
|
||
const NAMED_MAPS = new Set([
|
||
'proxy-providers',
|
||
'rule-providers',
|
||
'sub-rules',
|
||
'hosts',
|
||
])
|
||
|
||
function isObject(value: unknown): value is ConfigObject {
|
||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||
}
|
||
|
||
function clone<T extends ConfigValue>(value: T): T {
|
||
if (Array.isArray(value)) return value.map((item) => clone(item)) as T
|
||
if (isObject(value)) {
|
||
return Object.fromEntries(
|
||
Object.entries(value).map(([key, item]) => [key, clone(item)]),
|
||
) as T
|
||
}
|
||
return value
|
||
}
|
||
|
||
function canonical(value: unknown): string {
|
||
if (value === undefined) return 'undefined'
|
||
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`
|
||
if (isObject(value)) {
|
||
return `{${Object.keys(value)
|
||
.sort()
|
||
.map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`)
|
||
.join(',')}}`
|
||
}
|
||
return JSON.stringify(value)
|
||
}
|
||
|
||
/** Stable, browser-safe FNV-1a hash; used for optimistic concurrency, not crypto. */
|
||
export function hashConfigValue(value: unknown): string {
|
||
const input = canonical(value)
|
||
let hash = 2_166_136_261
|
||
for (let i = 0; i < input.length; i++) {
|
||
hash ^= input.charCodeAt(i)
|
||
hash = Math.imul(hash, 0x01000193)
|
||
}
|
||
return (hash >>> 0).toString(16).padStart(8, '0')
|
||
}
|
||
|
||
function parseYaml(yaml: string): ConfigObject {
|
||
const doc = parseDocument(yaml, { prettyErrors: true, uniqueKeys: true })
|
||
const errors = doc.errors.map((error) => error.message)
|
||
if (errors.length) {
|
||
throw new ConfigDocumentError('Invalid YAML configuration', errors)
|
||
}
|
||
if (!isMap(doc.contents)) {
|
||
throw new ConfigDocumentError('Configuration root must be a mapping', [
|
||
'Configuration root must be a mapping',
|
||
])
|
||
}
|
||
const value = doc.toJS({ mapAsMap: false }) as unknown
|
||
if (!isObject(value)) {
|
||
throw new ConfigDocumentError('Configuration root must be a mapping', [
|
||
'Configuration root must be a mapping',
|
||
])
|
||
}
|
||
return value
|
||
}
|
||
|
||
export function openDocument(yaml: string): ConfigDocument {
|
||
const normalizedYaml = yaml.trim() ? yaml : '{}\n'
|
||
const data = parseYaml(normalizedYaml)
|
||
return {
|
||
data,
|
||
yaml: normalizedYaml,
|
||
revision: hashConfigValue(data),
|
||
diagnostics: validateDocument(data),
|
||
}
|
||
}
|
||
|
||
function getAtPath(
|
||
root: ConfigObject,
|
||
path: string[],
|
||
): ConfigValue | undefined {
|
||
let current: ConfigValue | undefined = root
|
||
for (const segment of path) {
|
||
if (current === undefined || !isObject(current)) return undefined
|
||
current = current[segment]
|
||
if (current === undefined) return undefined
|
||
}
|
||
return current
|
||
}
|
||
|
||
function ensureParent(root: ConfigObject, path: string[]): ConfigObject | null {
|
||
let current: ConfigObject = root
|
||
for (const segment of path.slice(0, -1)) {
|
||
const next = current[segment]
|
||
if (next === undefined) {
|
||
const created: ConfigObject = {}
|
||
current[segment] = created
|
||
current = created
|
||
} else if (isObject(next)) {
|
||
current = next
|
||
} else {
|
||
return null
|
||
}
|
||
}
|
||
return current
|
||
}
|
||
|
||
function valuesEqual(a: unknown, b: unknown): boolean {
|
||
return canonical(a) === canonical(b)
|
||
}
|
||
|
||
function namedArrayDiff(
|
||
section: string,
|
||
key: string,
|
||
before: ConfigValue[],
|
||
after: ConfigValue[],
|
||
): ConfigPatchOperation[] | null {
|
||
const beforeById = new Map<string, ConfigObject>()
|
||
const afterById = new Map<string, ConfigObject>()
|
||
for (const value of before) {
|
||
if (!isObject(value) || typeof value[key] !== 'string') return null
|
||
const id = String(value[key])
|
||
if (beforeById.has(id)) return null
|
||
beforeById.set(id, value)
|
||
}
|
||
for (const value of after) {
|
||
if (!isObject(value) || typeof value[key] !== 'string') return null
|
||
const id = String(value[key])
|
||
if (afterById.has(id)) return null
|
||
afterById.set(id, value)
|
||
}
|
||
|
||
const operations: ConfigPatchOperation[] = []
|
||
for (const [id, previous] of beforeById) {
|
||
const next = afterById.get(id)
|
||
if (!next) {
|
||
operations.push({
|
||
op: 'delete',
|
||
target: { path: [section], key, id },
|
||
expectedHash: hashConfigValue(previous),
|
||
})
|
||
} else if (!valuesEqual(previous, next)) {
|
||
operations.push({
|
||
op: 'upsert',
|
||
target: { path: [section], key, id },
|
||
expectedHash: hashConfigValue(previous),
|
||
value: clone(next),
|
||
})
|
||
}
|
||
}
|
||
for (const [id, next] of afterById) {
|
||
if (beforeById.has(id)) continue
|
||
operations.push({
|
||
op: 'upsert',
|
||
target: { path: [section], key, id },
|
||
expectedHash: hashConfigValue(undefined),
|
||
value: clone(next),
|
||
})
|
||
}
|
||
|
||
const beforeOrder = before.map((value) =>
|
||
String((value as ConfigObject)[key]),
|
||
)
|
||
const afterOrder = after.map((value) => String((value as ConfigObject)[key]))
|
||
if (!valuesEqual(beforeOrder, afterOrder)) {
|
||
operations.push({
|
||
op: 'order',
|
||
target: { path: [section], key },
|
||
expectedHash: hashConfigValue(
|
||
beforeOrder.filter((id) => afterById.has(id)),
|
||
),
|
||
previousIds: beforeOrder.filter((id) => afterById.has(id)),
|
||
ids: afterOrder,
|
||
})
|
||
}
|
||
return operations
|
||
}
|
||
|
||
function namedMapDiff(
|
||
section: string,
|
||
before: ConfigObject,
|
||
after: ConfigObject,
|
||
): ConfigPatchOperation[] {
|
||
const operations: ConfigPatchOperation[] = []
|
||
for (const [id, previous] of Object.entries(before)) {
|
||
const next = after[id]
|
||
if (next === undefined) {
|
||
operations.push({
|
||
op: 'remove',
|
||
target: { path: [section, id] },
|
||
expectedHash: hashConfigValue(previous),
|
||
})
|
||
} else if (!valuesEqual(previous, next)) {
|
||
operations.push({
|
||
op: 'set',
|
||
target: { path: [section, id] },
|
||
expectedHash: hashConfigValue(previous),
|
||
value: clone(next),
|
||
})
|
||
}
|
||
}
|
||
for (const [id, next] of Object.entries(after)) {
|
||
if (before[id] !== undefined) continue
|
||
operations.push({
|
||
op: 'set',
|
||
target: { path: [section, id] },
|
||
expectedHash: hashConfigValue(undefined),
|
||
value: clone(next),
|
||
})
|
||
}
|
||
return operations
|
||
}
|
||
|
||
function sequenceEntries(values: ConfigValue[]) {
|
||
const occurrences = new Map<string, number>()
|
||
return values.map((value) => {
|
||
const hash = hashConfigValue(value)
|
||
const occurrence = occurrences.get(hash) ?? 0
|
||
occurrences.set(hash, occurrence + 1)
|
||
return { id: `${hash}:${occurrence}`, hash, value }
|
||
})
|
||
}
|
||
|
||
function sequenceDiff(
|
||
section: string,
|
||
before: ConfigValue[],
|
||
after: ConfigValue[],
|
||
): ConfigPatchOperation[] {
|
||
const previous = sequenceEntries(before)
|
||
const next = sequenceEntries(after)
|
||
const previousById = new Map(previous.map((entry) => [entry.id, entry]))
|
||
const nextById = new Map(next.map((entry) => [entry.id, entry]))
|
||
const operations: ConfigPatchOperation[] = []
|
||
const consumedPrevious = new Set<string>()
|
||
const consumedNext = new Set<string>()
|
||
|
||
// An in-place edit is represented as replace rather than delete+insert.
|
||
for (let index = 0; index < Math.min(previous.length, next.length); index++) {
|
||
const oldEntry = previous[index]!
|
||
const newEntry = next[index]!
|
||
if (oldEntry.id === newEntry.id) continue
|
||
if (!nextById.has(oldEntry.id) && !previousById.has(newEntry.id)) {
|
||
operations.push({
|
||
op: 'sequence-replace',
|
||
target: { path: [section], id: oldEntry.id },
|
||
expectedHash: oldEntry.hash,
|
||
value: clone(newEntry.value),
|
||
})
|
||
consumedPrevious.add(oldEntry.id)
|
||
consumedNext.add(newEntry.id)
|
||
}
|
||
}
|
||
|
||
for (const entry of previous) {
|
||
if (consumedPrevious.has(entry.id) || nextById.has(entry.id)) continue
|
||
operations.push({
|
||
op: 'sequence-delete',
|
||
target: { path: [section], id: entry.id },
|
||
expectedHash: entry.hash,
|
||
})
|
||
}
|
||
for (let index = 0; index < next.length; index++) {
|
||
const entry = next[index]!
|
||
if (consumedNext.has(entry.id) || previousById.has(entry.id)) continue
|
||
const beforeId = next
|
||
.slice(index + 1)
|
||
.find((candidate) => previousById.has(candidate.id))?.id
|
||
operations.push({
|
||
op: 'sequence-insert',
|
||
target: { path: [section], id: entry.id },
|
||
expectedHash: hashConfigValue(undefined),
|
||
value: clone(entry.value),
|
||
...(beforeId ? { beforeId } : {}),
|
||
})
|
||
}
|
||
|
||
const retainedPrevious = previous
|
||
.filter(
|
||
(entry) => nextById.has(entry.id) && !consumedPrevious.has(entry.id),
|
||
)
|
||
.map((entry) => entry.id)
|
||
const retainedNext = next
|
||
.filter(
|
||
(entry) => previousById.has(entry.id) && !consumedNext.has(entry.id),
|
||
)
|
||
.map((entry) => entry.id)
|
||
if (!valuesEqual(retainedPrevious, retainedNext)) {
|
||
operations.push({
|
||
op: 'sequence-order',
|
||
target: { path: [section] },
|
||
expectedHash: hashConfigValue(retainedPrevious),
|
||
previousIds: retainedPrevious,
|
||
ids: retainedNext,
|
||
})
|
||
}
|
||
return operations
|
||
}
|
||
|
||
export function diffDocument(
|
||
base: ConfigDocument | string,
|
||
draft: ConfigDocument | string,
|
||
): ConfigPatchV1 {
|
||
const before = typeof base === 'string' ? openDocument(base) : base
|
||
const after = typeof draft === 'string' ? openDocument(draft) : draft
|
||
const operations: ConfigPatchOperation[] = []
|
||
const keys = new Set([
|
||
...Object.keys(before.data),
|
||
...Object.keys(after.data),
|
||
])
|
||
|
||
for (const key of keys) {
|
||
const previous = before.data[key]
|
||
const next = after.data[key]
|
||
if (valuesEqual(previous, next)) continue
|
||
const semanticKey = NAMED_ARRAYS[key]
|
||
if (semanticKey && Array.isArray(previous) && Array.isArray(next)) {
|
||
const semantic = namedArrayDiff(key, semanticKey, previous, next)
|
||
if (semantic) {
|
||
operations.push(...semantic)
|
||
continue
|
||
}
|
||
}
|
||
if (NAMED_MAPS.has(key) && isObject(previous) && isObject(next)) {
|
||
operations.push(...namedMapDiff(key, previous, next))
|
||
continue
|
||
}
|
||
if (key === 'rules' && Array.isArray(previous) && Array.isArray(next)) {
|
||
operations.push(...sequenceDiff(key, previous, next))
|
||
continue
|
||
}
|
||
if (next === undefined) {
|
||
operations.push({
|
||
op: 'remove',
|
||
target: { path: [key] },
|
||
expectedHash: hashConfigValue(previous),
|
||
})
|
||
} else {
|
||
operations.push({
|
||
op: 'set',
|
||
target: { path: [key] },
|
||
expectedHash: hashConfigValue(previous),
|
||
value: clone(next),
|
||
})
|
||
}
|
||
}
|
||
|
||
return { version: 1, baseRevision: before.revision, operations }
|
||
}
|
||
|
||
/** Replace the JS view while preserving untouched top-level YAML nodes. */
|
||
export function replaceDocumentData(
|
||
source: ConfigDocument | string,
|
||
nextData: ConfigObject,
|
||
): ConfigDocument {
|
||
const current = typeof source === 'string' ? openDocument(source) : source
|
||
const touched = new Set<string>()
|
||
const keys = new Set([...Object.keys(current.data), ...Object.keys(nextData)])
|
||
for (const key of keys) {
|
||
if (!valuesEqual(current.data[key], nextData[key])) touched.add(key)
|
||
}
|
||
return openDocument(stringifyTouched(current.yaml, nextData, touched))
|
||
}
|
||
|
||
function conflict(
|
||
operation: ConfigPatchOperation,
|
||
reason: ConfigPatchConflict['reason'],
|
||
current?: ConfigValue,
|
||
): ConfigPatchConflict {
|
||
return { operation, path: operation.target.path, reason, current }
|
||
}
|
||
|
||
function applyNamedOperation(
|
||
root: ConfigObject,
|
||
operation: Extract<ConfigPatchOperation, { op: 'upsert' | 'delete' }>,
|
||
): ConfigPatchConflict | undefined {
|
||
const collection = getAtPath(root, operation.target.path)
|
||
if (!Array.isArray(collection)) {
|
||
if (
|
||
operation.op === 'upsert' &&
|
||
operation.expectedHash === hashConfigValue(undefined)
|
||
) {
|
||
const parent = ensureParent(root, operation.target.path)
|
||
const field = operation.target.path.at(-1)
|
||
if (!parent || !field) return conflict(operation, 'invalid-target')
|
||
parent[field] = [clone(operation.value)]
|
||
return
|
||
}
|
||
return conflict(operation, 'invalid-target', collection)
|
||
}
|
||
const matches = collection
|
||
.map((value, index) => ({ value, index }))
|
||
.filter(
|
||
({ value }) =>
|
||
isObject(value) &&
|
||
String(value[operation.target.key]) === operation.target.id,
|
||
)
|
||
if (matches.length > 1) return conflict(operation, 'duplicate')
|
||
const match = matches[0]
|
||
if (!match) {
|
||
if (
|
||
operation.op === 'upsert' &&
|
||
operation.expectedHash === hashConfigValue(undefined)
|
||
) {
|
||
collection.push(clone(operation.value))
|
||
return
|
||
}
|
||
return conflict(operation, 'missing')
|
||
}
|
||
if (hashConfigValue(match.value) !== operation.expectedHash) {
|
||
return conflict(operation, 'changed', match.value)
|
||
}
|
||
if (operation.op === 'delete') collection.splice(match.index, 1)
|
||
else collection[match.index] = clone(operation.value)
|
||
}
|
||
|
||
function applyOrderOperation(
|
||
root: ConfigObject,
|
||
operation: Extract<ConfigPatchOperation, { op: 'order' }>,
|
||
): ConfigPatchConflict | undefined {
|
||
const collection = getAtPath(root, operation.target.path)
|
||
if (!Array.isArray(collection) || !operation.target.key) {
|
||
return conflict(operation, 'invalid-target', collection)
|
||
}
|
||
const keyed = new Map<string, ConfigValue>()
|
||
for (const value of collection) {
|
||
if (!isObject(value) || typeof value[operation.target.key] !== 'string') {
|
||
return conflict(operation, 'invalid-target', collection)
|
||
}
|
||
const id = String(value[operation.target.key])
|
||
if (keyed.has(id)) return conflict(operation, 'duplicate')
|
||
keyed.set(id, value)
|
||
}
|
||
const currentOrder = [...keyed.keys()].filter((id) =>
|
||
operation.previousIds.includes(id),
|
||
)
|
||
if (operation.previousIds.some((id) => !keyed.has(id))) {
|
||
return conflict(operation, 'missing', currentOrder)
|
||
}
|
||
// Unknown resources added by a refreshed subscription remain at the end.
|
||
if (hashConfigValue(currentOrder) !== operation.expectedHash) {
|
||
return conflict(operation, 'changed', currentOrder)
|
||
}
|
||
const ordered = operation.ids
|
||
.map((id) => keyed.get(id))
|
||
.filter((v) => v !== undefined)
|
||
const unknown = collection.filter((value) => {
|
||
const id = String((value as ConfigObject)[operation.target.key!])
|
||
return !operation.ids.includes(id)
|
||
})
|
||
collection.splice(0, collection.length, ...ordered, ...unknown)
|
||
}
|
||
|
||
function applySequenceOperation(
|
||
root: ConfigObject,
|
||
operation: Extract<
|
||
ConfigPatchOperation,
|
||
{
|
||
op:
|
||
| 'sequence-replace'
|
||
| 'sequence-delete'
|
||
| 'sequence-insert'
|
||
| 'sequence-order'
|
||
}
|
||
>,
|
||
): ConfigPatchConflict | undefined {
|
||
const collection = getAtPath(root, operation.target.path)
|
||
if (!Array.isArray(collection)) {
|
||
return conflict(operation, 'invalid-target', collection)
|
||
}
|
||
const entries = sequenceEntries(collection)
|
||
if (operation.op === 'sequence-order') {
|
||
const byId = new Map(entries.map((entry) => [entry.id, entry.value]))
|
||
if (operation.previousIds.some((id) => !byId.has(id))) {
|
||
return conflict(
|
||
operation,
|
||
'missing',
|
||
entries.map((entry) => entry.id),
|
||
)
|
||
}
|
||
const currentOrder = entries
|
||
.map((entry) => entry.id)
|
||
.filter((id) => operation.previousIds.includes(id))
|
||
if (hashConfigValue(currentOrder) !== operation.expectedHash) {
|
||
return conflict(operation, 'changed', currentOrder)
|
||
}
|
||
const ordered = operation.ids
|
||
.map((id) => byId.get(id))
|
||
.filter((value) => value !== undefined)
|
||
const unknown = entries
|
||
.filter((entry) => !operation.ids.includes(entry.id))
|
||
.map((entry) => entry.value)
|
||
collection.splice(0, collection.length, ...ordered, ...unknown)
|
||
return
|
||
}
|
||
|
||
const index = entries.findIndex((entry) => entry.id === operation.target.id)
|
||
if (operation.op === 'sequence-insert') {
|
||
if (index >= 0) return conflict(operation, 'duplicate', collection[index])
|
||
let targetIndex = collection.length
|
||
if (operation.beforeId) {
|
||
targetIndex = entries.findIndex(
|
||
(entry) => entry.id === operation.beforeId,
|
||
)
|
||
if (targetIndex < 0) return conflict(operation, 'missing')
|
||
}
|
||
collection.splice(targetIndex, 0, clone(operation.value))
|
||
return
|
||
}
|
||
if (index < 0) return conflict(operation, 'missing')
|
||
if (entries[index]!.hash !== operation.expectedHash) {
|
||
return conflict(operation, 'changed', collection[index])
|
||
}
|
||
if (operation.op === 'sequence-delete') collection.splice(index, 1)
|
||
else collection[index] = clone(operation.value)
|
||
}
|
||
|
||
function isSequenceOperation(
|
||
operation: ConfigPatchOperation,
|
||
): operation is Extract<
|
||
ConfigPatchOperation,
|
||
{
|
||
op:
|
||
| 'sequence-replace'
|
||
| 'sequence-delete'
|
||
| 'sequence-insert'
|
||
| 'sequence-order'
|
||
}
|
||
> {
|
||
return operation.op.startsWith('sequence-')
|
||
}
|
||
|
||
function stringifyTouched(
|
||
sourceYaml: string,
|
||
data: ConfigObject,
|
||
touched: Set<string>,
|
||
): string {
|
||
const doc = parseDocument(sourceYaml, {
|
||
prettyErrors: true,
|
||
uniqueKeys: true,
|
||
})
|
||
if (!isMap(doc.contents)) return sourceYaml
|
||
for (const key of touched) {
|
||
if (data[key] === undefined) doc.delete(key)
|
||
else doc.set(key, doc.createNode(data[key]))
|
||
}
|
||
// 本地改动(上游是 `return String(doc)`,见 ORIGIN.md):
|
||
// flowCollectionPadding 关掉,保持 `[a, b]` 而不是被改写成 `[ a, b ]`;
|
||
// lineWidth 设 0 关掉自动折行,否则长订阅地址会被折断。
|
||
return doc.toString({ flowCollectionPadding: false, lineWidth: 0 })
|
||
}
|
||
|
||
export function applyPatch(
|
||
current: ConfigDocument | string,
|
||
patch: ConfigPatchV1,
|
||
): ApplyPatchResult {
|
||
if (patch.version !== 1) {
|
||
throw new Error(
|
||
`Unsupported configuration patch version: ${String(patch.version)}`,
|
||
)
|
||
}
|
||
const source = typeof current === 'string' ? openDocument(current) : current
|
||
const data = clone(source.data)
|
||
const conflicts: ConfigPatchConflict[] = []
|
||
const touched = new Set<string>()
|
||
let applied = 0
|
||
|
||
for (const operation of patch.operations) {
|
||
const top = operation.target.path[0]
|
||
if (top) touched.add(top)
|
||
let opConflict: ConfigPatchConflict | undefined
|
||
if (operation.op === 'upsert' || operation.op === 'delete') {
|
||
opConflict = applyNamedOperation(data, operation)
|
||
} else if (operation.op === 'order') {
|
||
opConflict = applyOrderOperation(data, operation)
|
||
} else if (isSequenceOperation(operation)) {
|
||
opConflict = applySequenceOperation(data, operation)
|
||
} else {
|
||
const parent = ensureParent(data, operation.target.path)
|
||
const field = operation.target.path.at(-1)
|
||
if (!parent || !field) {
|
||
opConflict = conflict(operation, 'invalid-target')
|
||
} else {
|
||
const currentValue = getAtPath(data, operation.target.path)
|
||
if (hashConfigValue(currentValue) !== operation.expectedHash) {
|
||
opConflict = conflict(operation, 'changed', currentValue)
|
||
} else if (operation.op === 'remove') {
|
||
delete parent[field]
|
||
} else {
|
||
parent[field] = clone(operation.value)
|
||
}
|
||
}
|
||
}
|
||
if (opConflict) conflicts.push(opConflict)
|
||
else applied++
|
||
}
|
||
|
||
const yaml = stringifyTouched(source.yaml, data, touched)
|
||
return { document: openDocument(yaml), conflicts, applied }
|
||
}
|
||
|
||
function resourceNames(data: ConfigObject): Set<string> {
|
||
const names = new Set([
|
||
'DIRECT',
|
||
'REJECT',
|
||
'REJECT-DROP',
|
||
'REJECT-TINYGIF',
|
||
'PASS',
|
||
'COMPATIBLE',
|
||
'GLOBAL',
|
||
])
|
||
for (const section of ['proxies', 'proxy-groups'] as const) {
|
||
const entries = data[section]
|
||
if (!Array.isArray(entries)) continue
|
||
for (const entry of entries) {
|
||
if (isObject(entry) && typeof entry.name === 'string')
|
||
names.add(entry.name)
|
||
}
|
||
}
|
||
return names
|
||
}
|
||
|
||
function mapNames(data: ConfigObject, section: string): Set<string> {
|
||
const value = data[section]
|
||
return isObject(value) ? new Set(Object.keys(value)) : new Set()
|
||
}
|
||
|
||
/**
|
||
* Split a Mihomo rule at top-level commas only. Logical rules contain nested
|
||
* comma-separated expressions, and quoted payloads may contain commas too, so
|
||
* a bare String.split(',') corrupts both their policy lookup and round-trips.
|
||
*/
|
||
export function splitRuleFields(rule: string): string[] {
|
||
const fields: string[] = []
|
||
let field = ''
|
||
let depth = 0
|
||
let quote: '"' | "'" | null = null
|
||
let escaped = false
|
||
|
||
for (const character of rule) {
|
||
if (escaped) {
|
||
field += character
|
||
escaped = false
|
||
continue
|
||
}
|
||
if (character === '\\') {
|
||
field += character
|
||
escaped = true
|
||
continue
|
||
}
|
||
if (quote) {
|
||
field += character
|
||
if (character === quote) quote = null
|
||
continue
|
||
}
|
||
if (character === '"' || character === "'") {
|
||
quote = character
|
||
field += character
|
||
continue
|
||
}
|
||
if (character === '(' || character === '[' || character === '{') {
|
||
depth++
|
||
field += character
|
||
continue
|
||
}
|
||
if (character === ')' || character === ']' || character === '}') {
|
||
depth = Math.max(0, depth - 1)
|
||
field += character
|
||
continue
|
||
}
|
||
if (character === ',' && depth === 0) {
|
||
fields.push(field.trim())
|
||
field = ''
|
||
continue
|
||
}
|
||
field += character
|
||
}
|
||
fields.push(field.trim())
|
||
return fields
|
||
}
|
||
|
||
function rulePolicy(rule: string): string | undefined {
|
||
const fields = splitRuleFields(rule)
|
||
const type = fields[0]?.toUpperCase()
|
||
if (type === 'MATCH') return fields[1]
|
||
// SUB-RULE's final field is a sub-rule collection name, not a proxy policy.
|
||
if (type === 'SUB-RULE') return undefined
|
||
return fields[2]
|
||
}
|
||
|
||
function collectRuleReferences(
|
||
rules: ConfigValue | undefined,
|
||
path: Array<string | number>,
|
||
): ConfigResourceReference[] {
|
||
if (!Array.isArray(rules)) return []
|
||
return rules.flatMap((rule, index) => {
|
||
if (typeof rule !== 'string') return []
|
||
const policy = rulePolicy(rule)
|
||
return policy
|
||
? [{ path: [...path, index], kind: 'rule-policy' as const, name: policy }]
|
||
: []
|
||
})
|
||
}
|
||
|
||
function collectKeyReferences(
|
||
value: ConfigValue | undefined,
|
||
path: Array<string | number>,
|
||
key: string,
|
||
kind: ConfigResourceReferenceKind,
|
||
): ConfigResourceReference[] {
|
||
if (Array.isArray(value)) {
|
||
return value.flatMap((item, index) =>
|
||
collectKeyReferences(item, [...path, index], key, kind),
|
||
)
|
||
}
|
||
if (!isObject(value)) return []
|
||
|
||
const references: ConfigResourceReference[] = []
|
||
for (const [field, child] of Object.entries(value)) {
|
||
const childPath = [...path, field]
|
||
if (field === key && typeof child === 'string') {
|
||
references.push({ path: childPath, kind, name: child })
|
||
}
|
||
references.push(...collectKeyReferences(child, childPath, key, kind))
|
||
}
|
||
return references
|
||
}
|
||
|
||
/** Return every named proxy/proxy-group reference in a config document. */
|
||
export function listResourceReferences(
|
||
data: ConfigObject,
|
||
): ConfigResourceReference[] {
|
||
const references: ConfigResourceReference[] = []
|
||
const groups = data['proxy-groups']
|
||
if (Array.isArray(groups)) {
|
||
groups.forEach((group, groupIndex) => {
|
||
if (!isObject(group) || !Array.isArray(group.proxies)) return
|
||
group.proxies.forEach((name, memberIndex) => {
|
||
if (typeof name !== 'string') return
|
||
references.push({
|
||
path: ['proxy-groups', groupIndex, 'proxies', memberIndex],
|
||
kind: 'proxy-group-member',
|
||
name,
|
||
})
|
||
})
|
||
})
|
||
}
|
||
|
||
references.push(...collectRuleReferences(data.rules, ['rules']))
|
||
const subRules = data['sub-rules']
|
||
if (isObject(subRules)) {
|
||
for (const [name, rules] of Object.entries(subRules)) {
|
||
references.push(...collectRuleReferences(rules, ['sub-rules', name]))
|
||
}
|
||
}
|
||
|
||
// dialer-proxy has the same named-resource meaning wherever Mihomo accepts
|
||
// it (nodes, providers, overrides and NTP), so scan it across the document.
|
||
references.push(
|
||
...collectKeyReferences(data, [], 'dialer-proxy', 'dialer-proxy'),
|
||
)
|
||
for (const section of ['proxy-providers', 'rule-providers'] as const) {
|
||
references.push(
|
||
...collectKeyReferences(
|
||
data[section],
|
||
[section],
|
||
'proxy',
|
||
'provider-proxy',
|
||
),
|
||
)
|
||
}
|
||
references.push(
|
||
...collectKeyReferences(
|
||
data.listeners,
|
||
['listeners'],
|
||
'proxy',
|
||
'listener-proxy',
|
||
),
|
||
...collectKeyReferences(data.tunnels, ['tunnels'], 'proxy', 'tunnel-proxy'),
|
||
)
|
||
return references
|
||
}
|
||
|
||
export function findResourceReferences(
|
||
data: ConfigObject,
|
||
name: string,
|
||
): ConfigResourceReference[] {
|
||
return listResourceReferences(data).filter(
|
||
(reference) => reference.name === name,
|
||
)
|
||
}
|
||
|
||
export function validateDocument(data: ConfigObject): ConfigDiagnostic[] {
|
||
const diagnostics: ConfigDiagnostic[] = []
|
||
const names = resourceNames(data)
|
||
const proxyProviders = mapNames(data, 'proxy-providers')
|
||
const ruleProviders = mapNames(data, 'rule-providers')
|
||
|
||
for (const [section, identity] of Object.entries(NAMED_ARRAYS)) {
|
||
const entries = data[section]
|
||
if (!Array.isArray(entries)) continue
|
||
const seen = new Set<string>()
|
||
entries.forEach((entry, index) => {
|
||
if (
|
||
!isObject(entry) ||
|
||
typeof entry[identity] !== 'string' ||
|
||
!entry[identity]
|
||
) {
|
||
diagnostics.push({
|
||
path: [section, index, identity],
|
||
code: 'missing-name',
|
||
message: `${section} entry requires ${identity}`,
|
||
severity: 'error',
|
||
})
|
||
return
|
||
}
|
||
const id = String(entry[identity])
|
||
if (seen.has(id)) {
|
||
diagnostics.push({
|
||
path: [section, index, identity],
|
||
code: 'duplicate-name',
|
||
message: `Duplicate ${section} identity: ${id}`,
|
||
severity: 'error',
|
||
})
|
||
}
|
||
seen.add(id)
|
||
if (
|
||
['proxies', 'proxy-groups', 'listeners'].includes(section) &&
|
||
typeof entry.type !== 'string'
|
||
) {
|
||
diagnostics.push({
|
||
path: [section, index, 'type'],
|
||
code: 'missing-type',
|
||
message: `${section} entry requires type`,
|
||
severity: 'error',
|
||
})
|
||
}
|
||
if (section === 'proxy-groups') {
|
||
const providers = entry.use
|
||
if (Array.isArray(providers)) {
|
||
providers.forEach((reference, refIndex) => {
|
||
if (
|
||
typeof reference === 'string' &&
|
||
!proxyProviders.has(reference)
|
||
) {
|
||
diagnostics.push({
|
||
path: [section, index, 'use', refIndex],
|
||
code: 'missing-reference',
|
||
message: `Unknown proxy provider: ${reference}`,
|
||
severity: 'error',
|
||
})
|
||
}
|
||
})
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
for (const reference of listResourceReferences(data)) {
|
||
if (names.has(reference.name)) continue
|
||
diagnostics.push({
|
||
path: reference.path,
|
||
code: 'missing-reference',
|
||
message: `Unknown proxy or group: ${reference.name}`,
|
||
// Composite rules are parsed at top-level only. Keep their policy
|
||
// diagnostics advisory and let Mihomo's validator remain authoritative.
|
||
severity: reference.kind === 'rule-policy' ? 'warning' : 'error',
|
||
})
|
||
}
|
||
|
||
const ruleCollections: Array<{
|
||
path: Array<string | number>
|
||
rules: ConfigValue | undefined
|
||
}> = [{ path: ['rules'], rules: data.rules }]
|
||
const subRules = data['sub-rules']
|
||
if (isObject(subRules)) {
|
||
for (const [name, rules] of Object.entries(subRules)) {
|
||
ruleCollections.push({ path: ['sub-rules', name], rules })
|
||
}
|
||
}
|
||
for (const collection of ruleCollections) {
|
||
if (!Array.isArray(collection.rules)) continue
|
||
collection.rules.forEach((rule, index) => {
|
||
if (typeof rule !== 'string' || !rule.includes(',')) {
|
||
diagnostics.push({
|
||
path: [...collection.path, index],
|
||
code: 'invalid-rule',
|
||
message: 'Rule must be a comma-separated string',
|
||
severity: 'error',
|
||
})
|
||
return
|
||
}
|
||
const parts = splitRuleFields(rule)
|
||
if (parts[0] === 'RULE-SET' && parts[1] && !ruleProviders.has(parts[1])) {
|
||
diagnostics.push({
|
||
path: [...collection.path, index],
|
||
code: 'missing-reference',
|
||
message: `Unknown rule provider: ${parts[1]}`,
|
||
severity: 'error',
|
||
})
|
||
}
|
||
if (
|
||
parts[0]?.toUpperCase() === 'SUB-RULE' &&
|
||
parts[2] &&
|
||
(!isObject(subRules) || !(parts[2] in subRules))
|
||
) {
|
||
diagnostics.push({
|
||
path: [...collection.path, index],
|
||
code: 'missing-reference',
|
||
message: `Unknown sub-rule: ${parts[2]}`,
|
||
severity: 'error',
|
||
})
|
||
}
|
||
})
|
||
}
|
||
return diagnostics
|
||
}
|
||
|
||
export const schemaSections = [
|
||
'general',
|
||
'experimental',
|
||
'profile',
|
||
'iptables',
|
||
'tls',
|
||
'proxies',
|
||
'proxy-providers',
|
||
'proxy-groups',
|
||
'listeners',
|
||
'rule-providers',
|
||
'sub-rules',
|
||
'rules',
|
||
'hosts',
|
||
'ntp',
|
||
'dns',
|
||
'tun',
|
||
'tuic-server',
|
||
'authentication',
|
||
'tunnels',
|
||
'sniffer',
|
||
'clash-for-android',
|
||
] as const
|