confdiff 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +587 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +449 -0
- package/dist/diff.d.ts +57 -0
- package/dist/diff.js +430 -0
- package/dist/dirdiff.d.ts +28 -0
- package/dist/dirdiff.js +99 -0
- package/dist/gitdriver.d.ts +25 -0
- package/dist/gitdriver.js +84 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +19 -0
- package/dist/parse.d.ts +64 -0
- package/dist/parse.js +566 -0
- package/dist/redact.d.ts +39 -0
- package/dist/redact.js +172 -0
- package/dist/render.d.ts +13 -0
- package/dist/render.js +105 -0
- package/package.json +72 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export { diff, formatPath, typeOf } from "./diff.js";
|
|
2
|
+
export { parseContent, parseEnv, parseCsv, parseXml, keyRowsByColumn, detectFormat, sniff, } from "./parse.js";
|
|
3
|
+
export { renderText, renderJson } from "./render.js";
|
|
4
|
+
import { diff as _diff } from "./diff.js";
|
|
5
|
+
import { parseContent, keyRowsByColumn, detectFormat } from "./parse.js";
|
|
6
|
+
/** High-level helper: compare two raw strings of (possibly different) formats. */
|
|
7
|
+
export function compare(a, b, opts = {}) {
|
|
8
|
+
const fa = opts.formatA ?? detectFormat(opts.filenameA, a);
|
|
9
|
+
const fb = opts.formatB ?? detectFormat(opts.filenameB, b);
|
|
10
|
+
let va = parseContent(a, fa);
|
|
11
|
+
let vb = parseContent(b, fb);
|
|
12
|
+
if (opts.csvKey) {
|
|
13
|
+
if (fa === "csv")
|
|
14
|
+
va = keyRowsByColumn(va, opts.csvKey);
|
|
15
|
+
if (fb === "csv")
|
|
16
|
+
vb = keyRowsByColumn(vb, opts.csvKey);
|
|
17
|
+
}
|
|
18
|
+
return _diff(va, vb, opts);
|
|
19
|
+
}
|
package/dist/parse.d.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strip `//` line and block comments and trailing commas from a JSON document
|
|
3
|
+
* so JSON-with-comments config files parse cleanly: `tsconfig.json`, VS Code
|
|
4
|
+
* `settings.json`, `devcontainer.json`, `.eslintrc.json`, `.babelrc`, and any
|
|
5
|
+
* `.jsonc`/`.json5` file that only uses comments + trailing commas. Comments and
|
|
6
|
+
* trailing commas are never valid in strict JSON, so this is a NO-OP on any
|
|
7
|
+
* conforming JSON document — behaviour is byte-for-byte unchanged for ordinary
|
|
8
|
+
* input. String contents (including `//`, `/*` and commas inside double-quoted
|
|
9
|
+
* strings) are preserved. Comments are replaced with equal-length whitespace and
|
|
10
|
+
* a trailing comma with a single space, so parse-error line/column numbers still
|
|
11
|
+
* line up with the source.
|
|
12
|
+
*/
|
|
13
|
+
export declare function stripJsonc(text: string): string;
|
|
14
|
+
export type Format = "json" | "yaml" | "toml" | "ini" | "env" | "properties" | "csv" | "xml";
|
|
15
|
+
export type Value = unknown;
|
|
16
|
+
/** Extensions confdiff recognizes as structured-config files (for directory diffs). */
|
|
17
|
+
export declare const KNOWN_EXTENSIONS: readonly string[];
|
|
18
|
+
/** True if a bare filename looks like a config file confdiff can parse by extension. */
|
|
19
|
+
export declare function isConfigFilename(name: string): boolean;
|
|
20
|
+
/** Detect the format from a filename, falling back to content sniffing. */
|
|
21
|
+
export declare function detectFormat(filename: string | undefined, content: string): Format;
|
|
22
|
+
/** Best-effort content sniffing when the extension is unknown. */
|
|
23
|
+
export declare function sniff(content: string): Format;
|
|
24
|
+
export declare function parseEnv(content: string): Record<string, string>;
|
|
25
|
+
/**
|
|
26
|
+
* Parse a Java `.properties` file (as consumed by `java.util.Properties.load`).
|
|
27
|
+
*
|
|
28
|
+
* Unlike `.env`, a properties file accepts THREE key/value separators — `=`,
|
|
29
|
+
* `:`, or whitespace — plus `#`/`!` comments, backslash line continuations, and
|
|
30
|
+
* `\uXXXX`/`\t`/`\:` style escapes. Treating `.properties` as `.env` (only `=`)
|
|
31
|
+
* silently dropped every `key: value` or `key value` line, so comparing two real
|
|
32
|
+
* Spring/log4j property files produced wrong, incomplete diffs. This parser
|
|
33
|
+
* handles the full format so those lines are compared instead of vanishing.
|
|
34
|
+
*/
|
|
35
|
+
export declare function parseProperties(content: string): Record<string, string>;
|
|
36
|
+
/**
|
|
37
|
+
* Parse CSV/TSV into an array of row objects keyed by the header row.
|
|
38
|
+
*
|
|
39
|
+
* Zero-config: the delimiter (`,` `\t` `;` `|`) is auto-detected from the header
|
|
40
|
+
* line unless `delimiter` is given. Handles RFC 4180 quoting — quoted fields may
|
|
41
|
+
* contain the delimiter, newlines, and `""`-escaped quotes. All cell values are
|
|
42
|
+
* strings, so pair with `--loose` to compare `"80"` against `80`, or with
|
|
43
|
+
* `--csv-key <col>` (in the CLI) to match rows by a key column instead of by
|
|
44
|
+
* position.
|
|
45
|
+
*/
|
|
46
|
+
export declare function parseCsv(content: string, delimiter?: string): Record<string, string>[];
|
|
47
|
+
/**
|
|
48
|
+
* Re-key an array of row objects into an object keyed by `column`, so rows are
|
|
49
|
+
* matched by that key rather than by position. Throws on missing column or
|
|
50
|
+
* duplicate keys (which would silently drop rows).
|
|
51
|
+
*/
|
|
52
|
+
export declare function keyRowsByColumn(rows: Record<string, string>[], column: string): Record<string, Record<string, string>>;
|
|
53
|
+
/**
|
|
54
|
+
* Parse XML into a plain nested object so it can be diffed semantically —
|
|
55
|
+
* element/attribute order and insignificant whitespace are ignored, and only
|
|
56
|
+
* structural or value changes are reported.
|
|
57
|
+
*
|
|
58
|
+
* Attributes are keyed with an `@_` prefix (`@_id`), an element's own text
|
|
59
|
+
* becomes `#text`, and repeated child elements become arrays. Scalar text and
|
|
60
|
+
* attribute values are type-coerced (so `<port>80</port>` compares equal to a
|
|
61
|
+
* JSON `"port": 80`); use `--loose` if you'd rather not coerce.
|
|
62
|
+
*/
|
|
63
|
+
export declare function parseXml(content: string): Value;
|
|
64
|
+
export declare function parseContent(content: string, format: Format): Value;
|
package/dist/parse.js
ADDED
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
import { extname } from "node:path";
|
|
2
|
+
import { parse as parseYaml, parseAllDocuments } from "yaml";
|
|
3
|
+
import { parse as parseToml } from "smol-toml";
|
|
4
|
+
/**
|
|
5
|
+
* Integers outside JS's safe range (|n| > 2^53-1) lose precision when parsed
|
|
6
|
+
* into a plain `number` — e.g. Discord/Twitter "snowflake" IDs or 64-bit
|
|
7
|
+
* counters. That silently makes two *different* IDs compare EQUAL, the worst
|
|
8
|
+
* failure mode for a diff tool. We preserve such integers losslessly as
|
|
9
|
+
* `BigInt` (JSON via a reviver, YAML/TOML via their bigint options) and
|
|
10
|
+
* normalise safe-range bigints back to plain numbers so ordinary values keep
|
|
11
|
+
* their usual type and cross-format compares still line up (a `1` is a `1`).
|
|
12
|
+
*/
|
|
13
|
+
function normalizeBigInts(value) {
|
|
14
|
+
if (typeof value === "bigint") {
|
|
15
|
+
return isSafeBig(value) ? Number(value) : value;
|
|
16
|
+
}
|
|
17
|
+
if (Array.isArray(value)) {
|
|
18
|
+
for (let i = 0; i < value.length; i++)
|
|
19
|
+
value[i] = normalizeBigInts(value[i]);
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
if (value !== null && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) {
|
|
23
|
+
const obj = value;
|
|
24
|
+
for (const k of Object.keys(obj))
|
|
25
|
+
obj[k] = normalizeBigInts(obj[k]);
|
|
26
|
+
return obj;
|
|
27
|
+
}
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
function isSafeBig(n) {
|
|
31
|
+
return n >= BigInt(Number.MIN_SAFE_INTEGER) && n <= BigInt(Number.MAX_SAFE_INTEGER);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Strip `//` line and block comments and trailing commas from a JSON document
|
|
35
|
+
* so JSON-with-comments config files parse cleanly: `tsconfig.json`, VS Code
|
|
36
|
+
* `settings.json`, `devcontainer.json`, `.eslintrc.json`, `.babelrc`, and any
|
|
37
|
+
* `.jsonc`/`.json5` file that only uses comments + trailing commas. Comments and
|
|
38
|
+
* trailing commas are never valid in strict JSON, so this is a NO-OP on any
|
|
39
|
+
* conforming JSON document — behaviour is byte-for-byte unchanged for ordinary
|
|
40
|
+
* input. String contents (including `//`, `/*` and commas inside double-quoted
|
|
41
|
+
* strings) are preserved. Comments are replaced with equal-length whitespace and
|
|
42
|
+
* a trailing comma with a single space, so parse-error line/column numbers still
|
|
43
|
+
* line up with the source.
|
|
44
|
+
*/
|
|
45
|
+
export function stripJsonc(text) {
|
|
46
|
+
const out = [];
|
|
47
|
+
const n = text.length;
|
|
48
|
+
let inString = false;
|
|
49
|
+
for (let i = 0; i < n; i++) {
|
|
50
|
+
const c = text[i];
|
|
51
|
+
if (inString) {
|
|
52
|
+
out.push(c);
|
|
53
|
+
if (c === "\\" && i + 1 < n) {
|
|
54
|
+
out.push(text[i + 1]);
|
|
55
|
+
i++;
|
|
56
|
+
}
|
|
57
|
+
else if (c === '"') {
|
|
58
|
+
inString = false;
|
|
59
|
+
}
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (c === '"') {
|
|
63
|
+
inString = true;
|
|
64
|
+
out.push(c);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (c === "/" && text[i + 1] === "/") {
|
|
68
|
+
i += 2;
|
|
69
|
+
out.push(" ");
|
|
70
|
+
while (i < n && text[i] !== "\n") {
|
|
71
|
+
out.push(text[i] === "\t" ? "\t" : " ");
|
|
72
|
+
i++;
|
|
73
|
+
}
|
|
74
|
+
if (i < n)
|
|
75
|
+
out.push("\n"); // keep the newline
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (c === "/" && text[i + 1] === "*") {
|
|
79
|
+
i += 2;
|
|
80
|
+
out.push(" ");
|
|
81
|
+
while (i < n && !(text[i] === "*" && text[i + 1] === "/")) {
|
|
82
|
+
out.push(text[i] === "\n" ? "\n" : text[i] === "\t" ? "\t" : " ");
|
|
83
|
+
i++;
|
|
84
|
+
}
|
|
85
|
+
if (i + 1 < n) {
|
|
86
|
+
out.push(" ");
|
|
87
|
+
i++; // the loop's i++ consumes the second char
|
|
88
|
+
}
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
out.push(c);
|
|
92
|
+
}
|
|
93
|
+
// Remove trailing commas: a comma whose next non-whitespace char is } or ].
|
|
94
|
+
const s = out;
|
|
95
|
+
let inStr2 = false;
|
|
96
|
+
for (let i = 0; i < s.length; i++) {
|
|
97
|
+
const c = s[i];
|
|
98
|
+
if (inStr2) {
|
|
99
|
+
if (c === "\\")
|
|
100
|
+
i++;
|
|
101
|
+
else if (c === '"')
|
|
102
|
+
inStr2 = false;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (c === '"') {
|
|
106
|
+
inStr2 = true;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (c === ",") {
|
|
110
|
+
let j = i + 1;
|
|
111
|
+
while (j < s.length && /\s/.test(s[j]))
|
|
112
|
+
j++;
|
|
113
|
+
if (j < s.length && (s[j] === "}" || s[j] === "]"))
|
|
114
|
+
s[i] = " ";
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return s.join("");
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Parse JSON while preserving integers that exceed JS's safe range as BigInt.
|
|
121
|
+
* The fast path (the vast majority of documents) is plain `JSON.parse`, so
|
|
122
|
+
* behaviour is byte-for-byte unchanged. Only when the text contains a run of 16+
|
|
123
|
+
* digits — the shortest that can exceed 2^53-1 — do we re-parse losslessly via
|
|
124
|
+
* the (already-bundled, battle-tested) YAML reader, which is a strict superset
|
|
125
|
+
* of JSON and supports `intAsBigInt`. `normalizeBigInts` then demotes any
|
|
126
|
+
* safe-range bigints back to plain numbers, so ordinary values are untouched.
|
|
127
|
+
* This works on every supported Node version (unlike the Node 21+ JSON reviver
|
|
128
|
+
* `context.source`) and in the browser playground. JSON-with-comments (`.jsonc`
|
|
129
|
+
* / tsconfig-style) input is tolerated via `stripJsonc` first.
|
|
130
|
+
*/
|
|
131
|
+
function parseJsonContent(content) {
|
|
132
|
+
const text = stripJsonc(content);
|
|
133
|
+
if (!/\d{16,}/.test(text))
|
|
134
|
+
return JSON.parse(text);
|
|
135
|
+
// Validate as JSON first so malformed input still yields a JSON-style error.
|
|
136
|
+
JSON.parse(text);
|
|
137
|
+
return normalizeBigInts(parseYaml(text, { intAsBigInt: true, uniqueKeys: false }));
|
|
138
|
+
}
|
|
139
|
+
import ini from "ini";
|
|
140
|
+
import { XMLParser, XMLValidator } from "fast-xml-parser";
|
|
141
|
+
const EXT_MAP = {
|
|
142
|
+
".json": "json",
|
|
143
|
+
".jsonc": "json",
|
|
144
|
+
".json5": "json",
|
|
145
|
+
".yaml": "yaml",
|
|
146
|
+
".yml": "yaml",
|
|
147
|
+
".toml": "toml",
|
|
148
|
+
".ini": "ini",
|
|
149
|
+
".cfg": "ini",
|
|
150
|
+
".conf": "ini",
|
|
151
|
+
".env": "env",
|
|
152
|
+
".properties": "properties",
|
|
153
|
+
".csv": "csv",
|
|
154
|
+
".tsv": "csv",
|
|
155
|
+
".xml": "xml",
|
|
156
|
+
".xhtml": "xml",
|
|
157
|
+
".svg": "xml",
|
|
158
|
+
".plist": "xml",
|
|
159
|
+
".xsd": "xml",
|
|
160
|
+
};
|
|
161
|
+
/** Extensions confdiff recognizes as structured-config files (for directory diffs). */
|
|
162
|
+
export const KNOWN_EXTENSIONS = Object.keys(EXT_MAP);
|
|
163
|
+
/** True if a bare filename looks like a config file confdiff can parse by extension. */
|
|
164
|
+
export function isConfigFilename(name) {
|
|
165
|
+
const base = name.toLowerCase();
|
|
166
|
+
if (base === ".env" || base.startsWith(".env."))
|
|
167
|
+
return true;
|
|
168
|
+
const ext = extname(base);
|
|
169
|
+
return !!(ext && EXT_MAP[ext]);
|
|
170
|
+
}
|
|
171
|
+
/** Detect the format from a filename, falling back to content sniffing. */
|
|
172
|
+
export function detectFormat(filename, content) {
|
|
173
|
+
if (filename) {
|
|
174
|
+
const base = filename.toLowerCase();
|
|
175
|
+
// .env, .env.local, .env.production ...
|
|
176
|
+
if (base === ".env" || base.startsWith(".env.") || base.includes("/.env"))
|
|
177
|
+
return "env";
|
|
178
|
+
const ext = extname(base);
|
|
179
|
+
if (ext && EXT_MAP[ext])
|
|
180
|
+
return EXT_MAP[ext];
|
|
181
|
+
}
|
|
182
|
+
return sniff(content);
|
|
183
|
+
}
|
|
184
|
+
/** Best-effort content sniffing when the extension is unknown. */
|
|
185
|
+
export function sniff(content) {
|
|
186
|
+
const trimmed = content.trim();
|
|
187
|
+
if (!trimmed)
|
|
188
|
+
return "json";
|
|
189
|
+
if (trimmed[0] === "<")
|
|
190
|
+
return "xml";
|
|
191
|
+
if (trimmed[0] === "{" || trimmed[0] === "[") {
|
|
192
|
+
try {
|
|
193
|
+
JSON.parse(stripJsonc(trimmed));
|
|
194
|
+
return "json";
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
/* not strict json, fall through */
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// env: lines of KEY=VALUE, no nesting, no leading spaces on keys
|
|
201
|
+
const lines = trimmed.split(/\r?\n/).filter((l) => l.trim() && !l.trim().startsWith("#"));
|
|
202
|
+
const envLike = lines.length > 0 && lines.every((l) => /^[A-Za-z_][A-Za-z0-9_.]*\s*=/.test(l));
|
|
203
|
+
const hasSection = lines.some((l) => /^\s*\[[^\]]+\]\s*$/.test(l));
|
|
204
|
+
if (hasSection)
|
|
205
|
+
return "ini";
|
|
206
|
+
if (envLike)
|
|
207
|
+
return "env";
|
|
208
|
+
return "yaml"; // YAML is a superset of JSON and forgiving
|
|
209
|
+
}
|
|
210
|
+
export function parseEnv(content) {
|
|
211
|
+
const out = {};
|
|
212
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
213
|
+
const line = rawLine.trim();
|
|
214
|
+
if (!line || line.startsWith("#"))
|
|
215
|
+
continue;
|
|
216
|
+
const m = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_.]*)\s*=\s*(.*)$/);
|
|
217
|
+
if (!m)
|
|
218
|
+
continue;
|
|
219
|
+
let val = m[2];
|
|
220
|
+
// strip inline comments only for unquoted values
|
|
221
|
+
if (!(val.startsWith('"') || val.startsWith("'"))) {
|
|
222
|
+
const hash = val.indexOf(" #");
|
|
223
|
+
if (hash !== -1)
|
|
224
|
+
val = val.slice(0, hash);
|
|
225
|
+
val = val.trim();
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
const quote = val[0];
|
|
229
|
+
const end = val.indexOf(quote, 1);
|
|
230
|
+
if (end !== -1)
|
|
231
|
+
val = val.slice(1, end);
|
|
232
|
+
}
|
|
233
|
+
out[m[1]] = val;
|
|
234
|
+
}
|
|
235
|
+
return out;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Parse a Java `.properties` file (as consumed by `java.util.Properties.load`).
|
|
239
|
+
*
|
|
240
|
+
* Unlike `.env`, a properties file accepts THREE key/value separators — `=`,
|
|
241
|
+
* `:`, or whitespace — plus `#`/`!` comments, backslash line continuations, and
|
|
242
|
+
* `\uXXXX`/`\t`/`\:` style escapes. Treating `.properties` as `.env` (only `=`)
|
|
243
|
+
* silently dropped every `key: value` or `key value` line, so comparing two real
|
|
244
|
+
* Spring/log4j property files produced wrong, incomplete diffs. This parser
|
|
245
|
+
* handles the full format so those lines are compared instead of vanishing.
|
|
246
|
+
*/
|
|
247
|
+
export function parseProperties(content) {
|
|
248
|
+
const out = {};
|
|
249
|
+
// 1. Split into logical lines, honouring backslash continuation. A line
|
|
250
|
+
// continues onto the next when it ends with an ODD number of backslashes.
|
|
251
|
+
const physical = content.split(/\r\n|\r|\n/);
|
|
252
|
+
const logical = [];
|
|
253
|
+
let buf = null;
|
|
254
|
+
for (const raw of physical) {
|
|
255
|
+
// Leading whitespace of a *continuation* line is stripped; of a fresh line
|
|
256
|
+
// it is stripped when we locate the key below. Strip here for both.
|
|
257
|
+
const line = buf === null ? raw : raw.replace(/^[ \t\f]+/, "");
|
|
258
|
+
const joined = buf === null ? line : buf + line;
|
|
259
|
+
// Count trailing backslashes to decide if THIS logical line continues.
|
|
260
|
+
let bs = 0;
|
|
261
|
+
for (let i = joined.length - 1; i >= 0 && joined[i] === "\\"; i--)
|
|
262
|
+
bs++;
|
|
263
|
+
if (bs % 2 === 1) {
|
|
264
|
+
buf = joined.slice(0, -1); // drop the escaping backslash, keep accumulating
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
logical.push(joined);
|
|
268
|
+
buf = null;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (buf !== null)
|
|
272
|
+
logical.push(buf);
|
|
273
|
+
for (const logLine of logical) {
|
|
274
|
+
// 2. Skip leading whitespace, then blank/comment lines (# or !).
|
|
275
|
+
const s = logLine.replace(/^[ \t\f]+/, "");
|
|
276
|
+
if (s === "" || s[0] === "#" || s[0] === "!")
|
|
277
|
+
continue;
|
|
278
|
+
// 3. Find the key: characters up to the first UNESCAPED separator, which is
|
|
279
|
+
// whitespace, `=`, or `:`.
|
|
280
|
+
let i = 0;
|
|
281
|
+
let keyEnd = s.length;
|
|
282
|
+
for (; i < s.length; i++) {
|
|
283
|
+
const c = s[i];
|
|
284
|
+
if (c === "\\") {
|
|
285
|
+
i++; // skip the escaped char (it belongs to the key)
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (c === " " || c === "\t" || c === "\f" || c === "=" || c === ":") {
|
|
289
|
+
keyEnd = i;
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
const rawKey = s.slice(0, keyEnd);
|
|
294
|
+
// 4. Skip whitespace after the key; then an optional `=`/`:`; then more ws.
|
|
295
|
+
let j = keyEnd;
|
|
296
|
+
while (j < s.length && (s[j] === " " || s[j] === "\t" || s[j] === "\f"))
|
|
297
|
+
j++;
|
|
298
|
+
if (j < s.length && (s[j] === "=" || s[j] === ":")) {
|
|
299
|
+
j++;
|
|
300
|
+
while (j < s.length && (s[j] === " " || s[j] === "\t" || s[j] === "\f"))
|
|
301
|
+
j++;
|
|
302
|
+
}
|
|
303
|
+
const rawVal = s.slice(j);
|
|
304
|
+
out[unescapeProperties(rawKey)] = unescapeProperties(rawVal);
|
|
305
|
+
}
|
|
306
|
+
return out;
|
|
307
|
+
}
|
|
308
|
+
/** Process `\uXXXX`, `\t\n\r\f`, and `\<char>` escapes in a properties token. */
|
|
309
|
+
function unescapeProperties(str) {
|
|
310
|
+
let out = "";
|
|
311
|
+
for (let i = 0; i < str.length; i++) {
|
|
312
|
+
const c = str[i];
|
|
313
|
+
if (c !== "\\") {
|
|
314
|
+
out += c;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
const n = str[++i];
|
|
318
|
+
if (n === undefined)
|
|
319
|
+
break;
|
|
320
|
+
switch (n) {
|
|
321
|
+
case "t":
|
|
322
|
+
out += "\t";
|
|
323
|
+
break;
|
|
324
|
+
case "n":
|
|
325
|
+
out += "\n";
|
|
326
|
+
break;
|
|
327
|
+
case "r":
|
|
328
|
+
out += "\r";
|
|
329
|
+
break;
|
|
330
|
+
case "f":
|
|
331
|
+
out += "\f";
|
|
332
|
+
break;
|
|
333
|
+
case "u": {
|
|
334
|
+
const hex = str.slice(i + 1, i + 5);
|
|
335
|
+
if (/^[0-9a-fA-F]{4}$/.test(hex)) {
|
|
336
|
+
out += String.fromCharCode(parseInt(hex, 16));
|
|
337
|
+
i += 4;
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
out += "u";
|
|
341
|
+
}
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
default:
|
|
345
|
+
// \= \: \ \\ and any other -> the literal following character
|
|
346
|
+
out += n;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return out;
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Parse CSV/TSV into an array of row objects keyed by the header row.
|
|
353
|
+
*
|
|
354
|
+
* Zero-config: the delimiter (`,` `\t` `;` `|`) is auto-detected from the header
|
|
355
|
+
* line unless `delimiter` is given. Handles RFC 4180 quoting — quoted fields may
|
|
356
|
+
* contain the delimiter, newlines, and `""`-escaped quotes. All cell values are
|
|
357
|
+
* strings, so pair with `--loose` to compare `"80"` against `80`, or with
|
|
358
|
+
* `--csv-key <col>` (in the CLI) to match rows by a key column instead of by
|
|
359
|
+
* position.
|
|
360
|
+
*/
|
|
361
|
+
export function parseCsv(content, delimiter) {
|
|
362
|
+
const src = content.replace(/^\uFEFF/, "");
|
|
363
|
+
const delim = delimiter ?? sniffDelimiter(src);
|
|
364
|
+
const rows = tokenizeCsv(src, delim);
|
|
365
|
+
// drop trailing fully-empty rows produced by a final newline
|
|
366
|
+
while (rows.length && rows[rows.length - 1].every((c) => c === ""))
|
|
367
|
+
rows.pop();
|
|
368
|
+
if (rows.length === 0)
|
|
369
|
+
return [];
|
|
370
|
+
const header = rows[0];
|
|
371
|
+
const out = [];
|
|
372
|
+
for (let r = 1; r < rows.length; r++) {
|
|
373
|
+
const cells = rows[r];
|
|
374
|
+
const obj = {};
|
|
375
|
+
for (let c = 0; c < header.length; c++) {
|
|
376
|
+
obj[header[c]] = cells[c] ?? "";
|
|
377
|
+
}
|
|
378
|
+
out.push(obj);
|
|
379
|
+
}
|
|
380
|
+
return out;
|
|
381
|
+
}
|
|
382
|
+
function sniffDelimiter(content) {
|
|
383
|
+
const firstLine = content.slice(0, content.search(/\r?\n/) === -1 ? content.length : content.search(/\r?\n/));
|
|
384
|
+
const candidates = ["\t", ";", "|", ","];
|
|
385
|
+
let best = ",";
|
|
386
|
+
let bestCount = -1;
|
|
387
|
+
for (const d of candidates) {
|
|
388
|
+
const count = firstLine.split(d).length - 1;
|
|
389
|
+
if (count > bestCount) {
|
|
390
|
+
bestCount = count;
|
|
391
|
+
best = d;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return best;
|
|
395
|
+
}
|
|
396
|
+
/** RFC 4180-ish tokenizer supporting quoted fields with embedded delimiters/newlines. */
|
|
397
|
+
function tokenizeCsv(content, delim) {
|
|
398
|
+
const rows = [];
|
|
399
|
+
let field = "";
|
|
400
|
+
let row = [];
|
|
401
|
+
let inQuotes = false;
|
|
402
|
+
let i = 0;
|
|
403
|
+
const n = content.length;
|
|
404
|
+
while (i < n) {
|
|
405
|
+
const ch = content[i];
|
|
406
|
+
if (inQuotes) {
|
|
407
|
+
if (ch === '"') {
|
|
408
|
+
if (content[i + 1] === '"') {
|
|
409
|
+
field += '"';
|
|
410
|
+
i += 2;
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
inQuotes = false;
|
|
414
|
+
i++;
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
field += ch;
|
|
418
|
+
i++;
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
if (ch === '"') {
|
|
422
|
+
inQuotes = true;
|
|
423
|
+
i++;
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
if (ch === delim) {
|
|
427
|
+
row.push(field);
|
|
428
|
+
field = "";
|
|
429
|
+
i++;
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
if (ch === "\n" || ch === "\r") {
|
|
433
|
+
row.push(field);
|
|
434
|
+
field = "";
|
|
435
|
+
rows.push(row);
|
|
436
|
+
row = [];
|
|
437
|
+
if (ch === "\r" && content[i + 1] === "\n")
|
|
438
|
+
i++;
|
|
439
|
+
i++;
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
field += ch;
|
|
443
|
+
i++;
|
|
444
|
+
}
|
|
445
|
+
// flush trailing field/row (no final newline)
|
|
446
|
+
if (field !== "" || row.length > 0) {
|
|
447
|
+
row.push(field);
|
|
448
|
+
rows.push(row);
|
|
449
|
+
}
|
|
450
|
+
return rows;
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Re-key an array of row objects into an object keyed by `column`, so rows are
|
|
454
|
+
* matched by that key rather than by position. Throws on missing column or
|
|
455
|
+
* duplicate keys (which would silently drop rows).
|
|
456
|
+
*/
|
|
457
|
+
export function keyRowsByColumn(rows, column) {
|
|
458
|
+
const out = {};
|
|
459
|
+
for (let i = 0; i < rows.length; i++) {
|
|
460
|
+
const row = rows[i];
|
|
461
|
+
if (!Object.prototype.hasOwnProperty.call(row, column)) {
|
|
462
|
+
throw new Error(`csv key column "${column}" not found in header`);
|
|
463
|
+
}
|
|
464
|
+
const key = row[column];
|
|
465
|
+
if (Object.prototype.hasOwnProperty.call(out, key)) {
|
|
466
|
+
throw new Error(`duplicate csv key "${key}" in column "${column}"; use positional compare (drop --csv-key)`);
|
|
467
|
+
}
|
|
468
|
+
out[key] = row;
|
|
469
|
+
}
|
|
470
|
+
return out;
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Parse XML into a plain nested object so it can be diffed semantically —
|
|
474
|
+
* element/attribute order and insignificant whitespace are ignored, and only
|
|
475
|
+
* structural or value changes are reported.
|
|
476
|
+
*
|
|
477
|
+
* Attributes are keyed with an `@_` prefix (`@_id`), an element's own text
|
|
478
|
+
* becomes `#text`, and repeated child elements become arrays. Scalar text and
|
|
479
|
+
* attribute values are type-coerced (so `<port>80</port>` compares equal to a
|
|
480
|
+
* JSON `"port": 80`); use `--loose` if you'd rather not coerce.
|
|
481
|
+
*/
|
|
482
|
+
export function parseXml(content) {
|
|
483
|
+
const valid = XMLValidator.validate(content);
|
|
484
|
+
if (valid !== true) {
|
|
485
|
+
const err = valid.err;
|
|
486
|
+
const where = err?.line ? ` (line ${err.line})` : "";
|
|
487
|
+
throw new Error(`invalid XML${where}: ${err?.msg ?? "malformed document"}`);
|
|
488
|
+
}
|
|
489
|
+
const parser = new XMLParser({
|
|
490
|
+
ignoreAttributes: false,
|
|
491
|
+
attributeNamePrefix: "@_",
|
|
492
|
+
textNodeName: "#text",
|
|
493
|
+
parseTagValue: true,
|
|
494
|
+
parseAttributeValue: true,
|
|
495
|
+
trimValues: true,
|
|
496
|
+
ignoreDeclaration: true,
|
|
497
|
+
ignorePiTags: true,
|
|
498
|
+
processEntities: true,
|
|
499
|
+
});
|
|
500
|
+
return parser.parse(content);
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Parse YAML, transparently supporting multi-document streams (`---`
|
|
504
|
+
* separators) as used by Kubernetes manifests, `kubectl get -o yaml`, and
|
|
505
|
+
* Helm renders. A single-document stream returns the document directly (so
|
|
506
|
+
* existing behaviour and cross-format compares are unchanged); a multi-document
|
|
507
|
+
* stream returns an array of documents (positional). Empty documents (e.g. a
|
|
508
|
+
* trailing `---`) are dropped so cosmetic separators don't create phantom diffs.
|
|
509
|
+
*/
|
|
510
|
+
const YAML_OPTS = { merge: true, intAsBigInt: true };
|
|
511
|
+
function parseYamlContent(content) {
|
|
512
|
+
const docs = parseAllDocuments(content, YAML_OPTS);
|
|
513
|
+
if (docs.length <= 1) {
|
|
514
|
+
// Preserve exact single-doc semantics (including empty/blank input).
|
|
515
|
+
return parseYaml(content, YAML_OPTS);
|
|
516
|
+
}
|
|
517
|
+
const values = [];
|
|
518
|
+
for (const doc of docs) {
|
|
519
|
+
if (doc.errors.length > 0) {
|
|
520
|
+
throw doc.errors[0];
|
|
521
|
+
}
|
|
522
|
+
const js = doc.toJS();
|
|
523
|
+
// Skip truly empty documents (null/undefined from bare `---`).
|
|
524
|
+
if (js === null || js === undefined)
|
|
525
|
+
continue;
|
|
526
|
+
values.push(js);
|
|
527
|
+
}
|
|
528
|
+
// Collapse to single-document semantics when only zero/one real document
|
|
529
|
+
// remains after dropping empties, so cosmetic `---` separators never change
|
|
530
|
+
// the diff shape.
|
|
531
|
+
if (values.length === 0)
|
|
532
|
+
return parseYaml(content);
|
|
533
|
+
if (values.length === 1)
|
|
534
|
+
return values[0];
|
|
535
|
+
return values;
|
|
536
|
+
}
|
|
537
|
+
export function parseContent(content, format) {
|
|
538
|
+
// An empty or whitespace-only input is treated as an empty document, not a
|
|
539
|
+
// parse error. This mirrors how a git diff driver sees a newly-added or
|
|
540
|
+
// just-emptied config file (old side empty), so `confdiff empty.json full.json`
|
|
541
|
+
// cleanly reports every key as added instead of crashing. Behaviour is now
|
|
542
|
+
// consistent across every format (previously empty JSON threw).
|
|
543
|
+
if (content.trim() === "") {
|
|
544
|
+
return format === "csv" ? [] : {};
|
|
545
|
+
}
|
|
546
|
+
switch (format) {
|
|
547
|
+
case "json":
|
|
548
|
+
return parseJsonContent(content);
|
|
549
|
+
case "yaml":
|
|
550
|
+
return normalizeBigInts(parseYamlContent(content));
|
|
551
|
+
case "toml":
|
|
552
|
+
return normalizeBigInts(parseToml(content, { integersAsBigInt: true }));
|
|
553
|
+
case "ini":
|
|
554
|
+
return ini.parse(content);
|
|
555
|
+
case "env":
|
|
556
|
+
return parseEnv(content);
|
|
557
|
+
case "properties":
|
|
558
|
+
return parseProperties(content);
|
|
559
|
+
case "csv":
|
|
560
|
+
return parseCsv(content);
|
|
561
|
+
case "xml":
|
|
562
|
+
return parseXml(content);
|
|
563
|
+
default:
|
|
564
|
+
throw new Error(`unsupported format: ${format}`);
|
|
565
|
+
}
|
|
566
|
+
}
|
package/dist/redact.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type Path } from "./diff.js";
|
|
2
|
+
/** Does a key name look like it holds a secret, by built-in heuristics? */
|
|
3
|
+
export declare function looksSecret(seg: string): boolean;
|
|
4
|
+
/**
|
|
5
|
+
* Shannon entropy (bits per character) of a string. A uniformly random
|
|
6
|
+
* high-entropy string (API key, JWT, base64 token) scores high (~4–6);
|
|
7
|
+
* repetitive or natural-language text scores low.
|
|
8
|
+
*/
|
|
9
|
+
export declare function shannonEntropy(s: string): number;
|
|
10
|
+
/**
|
|
11
|
+
* Content-based secret heuristic: does a VALUE *look* like a random credential,
|
|
12
|
+
* regardless of its key name? Catches secrets stored under non-obvious keys
|
|
13
|
+
* (`x`, `data`, `value`) that the key-name heuristics miss.
|
|
14
|
+
*
|
|
15
|
+
* Deliberately conservative to avoid masking ordinary config: only long,
|
|
16
|
+
* whitespace-free, tokenish strings with high per-character entropy qualify.
|
|
17
|
+
* This *complements* the key-name heuristics — it does NOT replace them: a
|
|
18
|
+
* short weak password like `Letmein` under a `password:` key has low entropy
|
|
19
|
+
* and is only caught by the key-name check, while a 40-char API token under a
|
|
20
|
+
* bland key is only caught here. Enable both for the widest coverage.
|
|
21
|
+
*/
|
|
22
|
+
export declare function looksHighEntropy(v: unknown): boolean;
|
|
23
|
+
export interface RedactMatcher {
|
|
24
|
+
(path: Path, value?: unknown): boolean;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Build a predicate deciding whether a given path's VALUE should be redacted.
|
|
28
|
+
* @param builtins use the built-in secret-key heuristics
|
|
29
|
+
* @param globs extra key-name substrings / path globs (matched via matchAnyGlob)
|
|
30
|
+
* @param entropy also redact values that *look* like high-entropy secrets,
|
|
31
|
+
* regardless of key name (complements, doesn't replace, the above)
|
|
32
|
+
*/
|
|
33
|
+
export declare function makeRedactMatcher(builtins: boolean, globs: string[], entropy?: boolean): RedactMatcher;
|
|
34
|
+
/**
|
|
35
|
+
* Stable, non-reversible fingerprint of a value. Equal values -> equal token,
|
|
36
|
+
* so an *unchanged* redacted value never shows up as a spurious diff, while a
|
|
37
|
+
* *changed* one shows two visibly different tokens.
|
|
38
|
+
*/
|
|
39
|
+
export declare function redactToken(v: unknown): string;
|