@intentius/chant 0.46.0 → 0.49.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/dist/audit/core.d.ts +21 -3
- package/dist/audit/core.d.ts.map +1 -1
- package/dist/audit/discover.d.ts +3 -2
- package/dist/audit/discover.d.ts.map +1 -1
- package/dist/audit/rules-doc.d.ts.map +1 -1
- package/dist/build.d.ts +3 -3
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
- package/dist/cli/mcp/tools/explain.d.ts +6 -0
- package/dist/cli/mcp/tools/explain.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts +1 -1
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/cli/reporters/stylish.d.ts +15 -1
- package/dist/cli/reporters/stylish.d.ts.map +1 -1
- package/dist/components/auto-release.d.ts +4 -0
- package/dist/components/auto-release.d.ts.map +1 -1
- package/dist/components/starter-plugin.d.ts +2 -0
- package/dist/components/starter-plugin.d.ts.map +1 -1
- package/dist/components/verbs/ensure-secret.d.ts +50 -0
- package/dist/components/verbs/ensure-secret.d.ts.map +1 -0
- package/dist/components/verbs/index.d.ts +8 -0
- package/dist/components/verbs/index.d.ts.map +1 -1
- package/dist/components/verbs/r2-sync.d.ts +76 -0
- package/dist/components/verbs/r2-sync.d.ts.map +1 -0
- package/dist/components/verbs/wrangler.d.ts +108 -0
- package/dist/components/verbs/wrangler.d.ts.map +1 -0
- package/dist/config.d.ts +26 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +14 -0
- package/dist/deep-observation.d.ts.map +1 -1
- package/dist/effect-receipt.d.ts +177 -0
- package/dist/effect-receipt.d.ts.map +1 -0
- package/dist/fold/subset.d.ts +15 -2
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/lexicon.d.ts +44 -3
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/change-set.d.ts +33 -5
- package/dist/lifecycle/change-set.d.ts.map +1 -1
- package/dist/lifecycle/index.d.ts +2 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/observation-baseline.d.ts +21 -3
- package/dist/lifecycle/observation-baseline.d.ts.map +1 -1
- package/dist/lifecycle/receipt-plan.d.ts +62 -0
- package/dist/lifecycle/receipt-plan.d.ts.map +1 -0
- package/dist/lifecycle/release-ledger.d.ts +20 -0
- package/dist/lifecycle/release-ledger.d.ts.map +1 -1
- package/dist/lifecycle/teardown.d.ts +6 -4
- package/dist/lifecycle/teardown.d.ts.map +1 -1
- package/dist/lifecycle/unobserved-gate.d.ts +67 -0
- package/dist/lifecycle/unobserved-gate.d.ts.map +1 -0
- package/dist/lint/knowledge-checks.d.ts +48 -0
- package/dist/lint/knowledge-checks.d.ts.map +1 -0
- package/dist/lint/output-checks.d.ts +5 -0
- package/dist/lint/output-checks.d.ts.map +1 -0
- package/dist/lint/pipeline-change-gate.d.ts +101 -0
- package/dist/lint/pipeline-change-gate.d.ts.map +1 -0
- package/dist/lint/post-synth.d.ts +12 -0
- package/dist/lint/post-synth.d.ts.map +1 -1
- package/dist/lint/receipt-checks.d.ts +9 -0
- package/dist/lint/receipt-checks.d.ts.map +1 -0
- package/dist/lint/rules/cor022-receipt-leaf.d.ts +13 -0
- package/dist/lint/rules/cor022-receipt-leaf.d.ts.map +1 -0
- package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts +3 -0
- package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts.map +1 -0
- package/dist/lint/rules/evl001-non-literal-expression.d.ts.map +1 -1
- package/dist/lint/rules/index.d.ts +3 -1
- package/dist/lint/rules/index.d.ts.map +1 -1
- package/dist/okf-read.d.ts +78 -0
- package/dist/okf-read.d.ts.map +1 -0
- package/dist/op/builders.d.ts +98 -1
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/index.d.ts +4 -2
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +2 -1
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/receipt-store.d.ts +138 -0
- package/dist/op/receipt-store.d.ts.map +1 -0
- package/dist/op/types.d.ts +31 -1
- package/dist/op/types.d.ts.map +1 -1
- package/dist/secret-materialization.d.ts +138 -0
- package/dist/secret-materialization.d.ts.map +1 -0
- package/dist/secret-provenance.d.ts +218 -0
- package/dist/secret-provenance.d.ts.map +1 -0
- package/dist/serializer.d.ts +11 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/yaml.d.ts.map +1 -1
- package/package.json +4 -1
- package/src/audit/core.test.ts +57 -0
- package/src/audit/core.ts +0 -0
- package/src/audit/detect-bundle.test.ts +1 -1
- package/src/audit/discover.test.ts +24 -0
- package/src/audit/discover.ts +11 -2
- package/src/audit/rules-doc.ts +11 -1
- package/src/build.test.ts +41 -0
- package/src/build.ts +34 -6
- package/src/cli/commands/__fixtures__/audit-fountain/agents/fleet.yaml +27 -0
- package/src/cli/commands/__fixtures__/audit-fountain/k8s/deploy.yaml +16 -0
- package/src/cli/commands/__fixtures__/audit-fountain-clean/fleet.yaml +20 -0
- package/src/cli/commands/audit.test.ts +53 -0
- package/src/cli/commands/audit.ts +1 -1
- package/src/cli/commands/build.test.ts +80 -0
- package/src/cli/commands/build.ts +106 -8
- package/src/cli/commands/lint.ts +15 -3
- package/src/cli/handlers/explain.test.ts +70 -1
- package/src/cli/handlers/graph.ts +2 -2
- package/src/cli/handlers/lifecycle.test.ts +115 -1
- package/src/cli/handlers/lifecycle.ts +84 -11
- package/src/cli/mcp/resource-handlers.ts +38 -1
- package/src/cli/mcp/server.test.ts +58 -1
- package/src/cli/mcp/tools/explain.ts +51 -2
- package/src/cli/plugins.ts +4 -2
- package/src/cli/reporters/stylish.test.ts +154 -0
- package/src/cli/reporters/stylish.ts +154 -33
- package/src/components/auto-release.ts +6 -0
- package/src/components/registry.test.ts +7 -2
- package/src/components/starter-plugin.ts +17 -0
- package/src/components/verbs/ensure-secret.test.ts +130 -0
- package/src/components/verbs/ensure-secret.ts +79 -0
- package/src/components/verbs/index.ts +8 -0
- package/src/components/verbs/r2-sync.test.ts +107 -0
- package/src/components/verbs/r2-sync.ts +124 -0
- package/src/components/verbs/wrangler.test.ts +170 -0
- package/src/components/verbs/wrangler.ts +241 -0
- package/src/config.test.ts +15 -0
- package/src/config.ts +30 -0
- package/src/deep-observation.test.ts +19 -0
- package/src/deep-observation.ts +17 -0
- package/src/effect-receipt-exclusion.test.ts +190 -0
- package/src/effect-receipt.test.ts +419 -0
- package/src/effect-receipt.ts +412 -0
- package/src/fold/subset.test.ts +26 -0
- package/src/fold/subset.ts +45 -19
- package/src/index.ts +4 -0
- package/src/lexicon.ts +48 -3
- package/src/lifecycle/change-set.ts +46 -7
- package/src/lifecycle/index.ts +2 -0
- package/src/lifecycle/observation-baseline.test.ts +46 -0
- package/src/lifecycle/observation-baseline.ts +33 -1
- package/src/lifecycle/receipt-plan.test.ts +250 -0
- package/src/lifecycle/receipt-plan.ts +249 -0
- package/src/lifecycle/release-ledger.ts +20 -0
- package/src/lifecycle/teardown.test.ts +31 -0
- package/src/lifecycle/teardown.ts +6 -4
- package/src/lifecycle/unobserved-gate.test.ts +109 -0
- package/src/lifecycle/unobserved-gate.ts +102 -0
- package/src/lint/knowledge-checks.test.ts +80 -0
- package/src/lint/knowledge-checks.ts +74 -0
- package/src/lint/output-checks.test.ts +85 -0
- package/src/lint/output-checks.ts +99 -0
- package/src/lint/pipeline-change-gate.test.ts +144 -0
- package/src/lint/pipeline-change-gate.ts +153 -0
- package/src/lint/post-synth.ts +15 -0
- package/src/lint/receipt-checks.test.ts +101 -0
- package/src/lint/receipt-checks.ts +93 -0
- package/src/lint/rules/cor022-receipt-leaf.test.ts +116 -0
- package/src/lint/rules/cor022-receipt-leaf.ts +130 -0
- package/src/lint/rules/cor024-receipt-secret-pointer.test.ts +121 -0
- package/src/lint/rules/cor024-receipt-secret-pointer.ts +218 -0
- package/src/lint/rules/evl001-non-literal-expression.test.ts +27 -0
- package/src/lint/rules/evl001-non-literal-expression.ts +8 -1
- package/src/lint/rules/index.ts +7 -1
- package/src/okf-read.test.ts +149 -0
- package/src/okf-read.ts +197 -0
- package/src/op/builders.ts +139 -1
- package/src/op/effect-step.test.ts +311 -0
- package/src/op/index.ts +10 -3
- package/src/op/local-executor.ts +172 -25
- package/src/op/op.test.ts +25 -2
- package/src/op/receipt-store.ts +211 -0
- package/src/op/types.ts +33 -1
- package/src/secret-materialization.test.ts +199 -0
- package/src/secret-materialization.ts +235 -0
- package/src/secret-provenance.test.ts +388 -0
- package/src/secret-provenance.ts +475 -0
- package/src/serializer.ts +12 -0
- package/src/yaml.test.ts +88 -0
- package/src/yaml.ts +76 -6
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret provenance vocabulary (chant #1828, epic #1365).
|
|
3
|
+
*
|
|
4
|
+
* A secret's PROVENANCE is where its value comes from — never what the value
|
|
5
|
+
* is. chant's constitutional line on secrets: no code path may hold, log,
|
|
6
|
+
* hash, or compare a secret value. This module makes that structural: the
|
|
7
|
+
* declaration types have no field that could carry material, the factory
|
|
8
|
+
* rejects material-shaped fields at both the type level (`never` fields) and
|
|
9
|
+
* at runtime (own-property check, naming only the offending KEY), and
|
|
10
|
+
* "mismatch" anywhere downstream means presence, declared key-set, and
|
|
11
|
+
* metadata — never a value or a value-derived hash (#1365 decision 6).
|
|
12
|
+
*
|
|
13
|
+
* The closed kind set:
|
|
14
|
+
*
|
|
15
|
+
* - `referenced` — the value exists out of band (a human or an external
|
|
16
|
+
* process put it where consumers read it). chant records only that the
|
|
17
|
+
* estate depends on it, which is what lets the consumed-but-unproduced
|
|
18
|
+
* check (#1382) ship as an error instead of a guess.
|
|
19
|
+
* - `from-provider` — a declared provider binding materializes it (the
|
|
20
|
+
* operator/CRD seam; `K8s::Infisical::InfisicalSecret` from #1321 is the
|
|
21
|
+
* exemplar). The declaration POINTS at that binding, it does not re-model
|
|
22
|
+
* it.
|
|
23
|
+
* - `generated-once` — minted on first materialization, then never
|
|
24
|
+
* regenerated (present means done). The declaration carries contract flags
|
|
25
|
+
* only — e.g. the declared key-set — never mint parameters holding
|
|
26
|
+
* material. `generated-once` secrets never enter the prunable set (#1365
|
|
27
|
+
* decision 5).
|
|
28
|
+
* - `committed-encrypted` — sops-style ciphertext committed in the repo,
|
|
29
|
+
* decrypted by the delivery system (Flux) straight into the target. The
|
|
30
|
+
* declaration records a repo-relative PATH to the ciphertext, never the
|
|
31
|
+
* bytes: the factory is pure and touches no filesystem, so it still folds
|
|
32
|
+
* under `--sandbox`. The bytes are read at `buildRoots()` — the one
|
|
33
|
+
* sanctioned impure seam — and emitted as a sidecar file, never as a
|
|
34
|
+
* document in the primary output. See
|
|
35
|
+
* `docs/design/committed-encrypted-sops-provenance.md`.
|
|
36
|
+
*
|
|
37
|
+
* Declarations are serializer-neutral: discovery collects them like any
|
|
38
|
+
* entity (they are Declarables), but `partitionByLexicon` (../build.ts)
|
|
39
|
+
* excludes them from every serializer partition, so no lexicon ever emits
|
|
40
|
+
* them. They are data that lint rules and lexicons READ (via
|
|
41
|
+
* {@link collectSecretDeclarations}), not output.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { DECLARABLE_MARKER, type Declarable } from "./declarable";
|
|
45
|
+
|
|
46
|
+
/** The closed union of secret origins. */
|
|
47
|
+
export type SecretProvenance =
|
|
48
|
+
| "referenced"
|
|
49
|
+
| "from-provider"
|
|
50
|
+
| "generated-once"
|
|
51
|
+
| "committed-encrypted";
|
|
52
|
+
|
|
53
|
+
/** Every provenance kind, for exhaustiveness checks. */
|
|
54
|
+
export const SECRET_PROVENANCE_KINDS: readonly SecretProvenance[] = [
|
|
55
|
+
"referenced",
|
|
56
|
+
"from-provider",
|
|
57
|
+
"generated-once",
|
|
58
|
+
"committed-encrypted",
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Encryption tools a {@link CommittedEncryptedSecretDeclaration} understands.
|
|
63
|
+
* A closed union with one member, not a free string: a second tool later is a
|
|
64
|
+
* deliberate widening with a detection rule attached, not an unvalidated
|
|
65
|
+
* string that silently means nothing.
|
|
66
|
+
*/
|
|
67
|
+
export type SecretEncryption = "sops";
|
|
68
|
+
|
|
69
|
+
/** Every encryption tool, for exhaustiveness checks. */
|
|
70
|
+
export const SECRET_ENCRYPTION_TOOLS: readonly SecretEncryption[] = ["sops"];
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* File extensions a committed ciphertext path may carry (v1).
|
|
74
|
+
*
|
|
75
|
+
* The CLI writer round-trips every additional file through `JSON.parse` and
|
|
76
|
+
* re-emits it key-sorted when the parse succeeds
|
|
77
|
+
* (`./cli/commands/build.ts`), which would silently rewrite a `.sops.json`
|
|
78
|
+
* file and break the byte-for-byte guarantee. v1 refuses anything but YAML;
|
|
79
|
+
* the writer additionally skips the round trip for these files, so byte
|
|
80
|
+
* identity is structural rather than a happy accident of JSON.parse failing.
|
|
81
|
+
*/
|
|
82
|
+
const CIPHERTEXT_FILE_EXTENSIONS = [".yaml", ".yml"] as const;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Patterns that mean a PRIVATE key was pasted where a public recipient
|
|
86
|
+
* belongs. Matched against `recipients` entries; the thrown message names the
|
|
87
|
+
* field and the index, never the value.
|
|
88
|
+
*/
|
|
89
|
+
const PRIVATE_KEY_MARKERS = [/AGE-SECRET-KEY-/i, /-----BEGIN [A-Z ]*PRIVATE KEY-----/];
|
|
90
|
+
|
|
91
|
+
/** Marker symbol identifying a secret provenance declaration. `Symbol.for` so
|
|
92
|
+
* it survives the entity-wire codec (../discovery/entity-wire-codec.ts). */
|
|
93
|
+
export const SECRET_DECLARATION_MARKER = Symbol.for("chant.secret-declaration");
|
|
94
|
+
|
|
95
|
+
/** The `entityType` every secret declaration carries. */
|
|
96
|
+
export const SECRET_DECLARATION_ENTITY_TYPE = "Chant::SecretProvenance";
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Points a `from-provider` declaration at the provider binding that
|
|
100
|
+
* materializes the secret — the CRD seam, not a re-model of it. The binding
|
|
101
|
+
* itself is an ordinary declarable in its own lexicon (e.g. an
|
|
102
|
+
* `InfisicalSecret` instance, #1321); this ref names it so lint can resolve
|
|
103
|
+
* the pair without core depending on any lexicon.
|
|
104
|
+
*/
|
|
105
|
+
export interface SecretProviderRef {
|
|
106
|
+
/** Entity name (export name) of the provider binding declarable in this project. */
|
|
107
|
+
readonly binding: string;
|
|
108
|
+
/**
|
|
109
|
+
* Expected `entityType` of the binding (e.g.
|
|
110
|
+
* `"K8s::Infisical::InfisicalSecret"`). Optional; when present, lint can
|
|
111
|
+
* verify the named binding is actually the kind of seam the declaration
|
|
112
|
+
* claims.
|
|
113
|
+
*/
|
|
114
|
+
readonly entityType?: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Fields that would carry secret material, forbidden by construction. Typed
|
|
119
|
+
* as `never` on every factory input so they fail to compile even when the
|
|
120
|
+
* input object was widened or spread (plain excess-property checks only catch
|
|
121
|
+
* object literals). The runtime check in {@link declareSecret} is the same
|
|
122
|
+
* list, for untyped callers.
|
|
123
|
+
*/
|
|
124
|
+
interface NoSecretMaterial {
|
|
125
|
+
readonly value?: never;
|
|
126
|
+
readonly data?: never;
|
|
127
|
+
readonly stringData?: never;
|
|
128
|
+
readonly material?: never;
|
|
129
|
+
readonly plaintext?: never;
|
|
130
|
+
readonly ciphertext?: never;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const FORBIDDEN_MATERIAL_FIELDS = [
|
|
134
|
+
"value",
|
|
135
|
+
"data",
|
|
136
|
+
"stringData",
|
|
137
|
+
"material",
|
|
138
|
+
"plaintext",
|
|
139
|
+
"ciphertext",
|
|
140
|
+
] as const;
|
|
141
|
+
|
|
142
|
+
/** Fields shared by every secret declaration. */
|
|
143
|
+
interface SecretDeclarationBase extends Declarable {
|
|
144
|
+
readonly [SECRET_DECLARATION_MARKER]: true;
|
|
145
|
+
readonly lexicon: "chant";
|
|
146
|
+
readonly entityType: typeof SECRET_DECLARATION_ENTITY_TYPE;
|
|
147
|
+
/** The secret's name as consumers know it (e.g. the k8s Secret name). */
|
|
148
|
+
readonly name: string;
|
|
149
|
+
/** Which of the closed kind set this declaration is. */
|
|
150
|
+
readonly provenance: SecretProvenance;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** A secret whose value exists out of band. */
|
|
154
|
+
export interface ReferencedSecretDeclaration extends SecretDeclarationBase {
|
|
155
|
+
readonly provenance: "referenced";
|
|
156
|
+
/** Where consumers find it — free-form (a namespace, a vault path, a doc link). */
|
|
157
|
+
readonly scope?: string;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** A secret a declared provider binding materializes. */
|
|
161
|
+
export interface FromProviderSecretDeclaration extends SecretDeclarationBase {
|
|
162
|
+
readonly provenance: "from-provider";
|
|
163
|
+
readonly provider: SecretProviderRef;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** A secret minted on first materialization and never regenerated. */
|
|
167
|
+
export interface GeneratedOnceSecretDeclaration extends SecretDeclarationBase {
|
|
168
|
+
readonly provenance: "generated-once";
|
|
169
|
+
/**
|
|
170
|
+
* The declared key-set of the materialized secret. This is the contract a
|
|
171
|
+
* materializer (#1830) checks for mismatch — presence and keys, never
|
|
172
|
+
* values.
|
|
173
|
+
*/
|
|
174
|
+
readonly keys?: readonly string[];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* A secret whose ciphertext is committed to the repo and decrypted by the
|
|
179
|
+
* delivery system on the way into the target.
|
|
180
|
+
*
|
|
181
|
+
* The declaration is a POINTER: `file` is a repo-relative path, and nothing
|
|
182
|
+
* here carries bytes. That is what keeps the factory pure — the ciphertext is
|
|
183
|
+
* read at `buildRoots()` and emitted as a sidecar, never inlined into the
|
|
184
|
+
* primary output an applier reads.
|
|
185
|
+
*/
|
|
186
|
+
export interface CommittedEncryptedSecretDeclaration extends SecretDeclarationBase {
|
|
187
|
+
readonly provenance: "committed-encrypted";
|
|
188
|
+
/** Repo-relative path to the committed ciphertext file. */
|
|
189
|
+
readonly file: string;
|
|
190
|
+
/** Encryption tool. Defaults to `"sops"` when omitted. */
|
|
191
|
+
readonly encryption: SecretEncryption;
|
|
192
|
+
/**
|
|
193
|
+
* Public recipient identifiers — age recipients or PGP fingerprints. Public
|
|
194
|
+
* by definition, which is the opposite of material; a private key here is
|
|
195
|
+
* refused by the factory.
|
|
196
|
+
*/
|
|
197
|
+
readonly recipients?: readonly string[];
|
|
198
|
+
/**
|
|
199
|
+
* The declared key-set of the decrypted Secret — the same contract meaning
|
|
200
|
+
* as {@link GeneratedOnceSecretDeclaration.keys}: presence and key names,
|
|
201
|
+
* never values. SOPS leaves key NAMES cleartext, so this is checkable
|
|
202
|
+
* against the file itself.
|
|
203
|
+
*/
|
|
204
|
+
readonly keys?: readonly string[];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** A secret provenance declaration — the discriminant is `provenance`. */
|
|
208
|
+
export type SecretDeclaration =
|
|
209
|
+
| ReferencedSecretDeclaration
|
|
210
|
+
| FromProviderSecretDeclaration
|
|
211
|
+
| GeneratedOnceSecretDeclaration
|
|
212
|
+
| CommittedEncryptedSecretDeclaration;
|
|
213
|
+
|
|
214
|
+
/** Narrow a declaration to the committed-encrypted kind. */
|
|
215
|
+
export function isCommittedEncryptedSecret(
|
|
216
|
+
decl: SecretDeclaration,
|
|
217
|
+
): decl is CommittedEncryptedSecretDeclaration {
|
|
218
|
+
return decl.provenance === "committed-encrypted";
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Factory input for a `referenced` secret. */
|
|
222
|
+
export interface ReferencedSecretInput extends NoSecretMaterial {
|
|
223
|
+
readonly name: string;
|
|
224
|
+
readonly provenance: "referenced";
|
|
225
|
+
readonly scope?: string;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Factory input for a `from-provider` secret. */
|
|
229
|
+
export interface FromProviderSecretInput extends NoSecretMaterial {
|
|
230
|
+
readonly name: string;
|
|
231
|
+
readonly provenance: "from-provider";
|
|
232
|
+
readonly provider: SecretProviderRef;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Factory input for a `generated-once` secret. */
|
|
236
|
+
export interface GeneratedOnceSecretInput extends NoSecretMaterial {
|
|
237
|
+
readonly name: string;
|
|
238
|
+
readonly provenance: "generated-once";
|
|
239
|
+
readonly keys?: readonly string[];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Factory input for a `committed-encrypted` secret. */
|
|
243
|
+
export interface CommittedEncryptedSecretInput extends NoSecretMaterial {
|
|
244
|
+
readonly name: string;
|
|
245
|
+
readonly provenance: "committed-encrypted";
|
|
246
|
+
/**
|
|
247
|
+
* Repo-relative path to the committed ciphertext file — `file`, never
|
|
248
|
+
* `ciphertext`: the declaration points at bytes, it does not carry them,
|
|
249
|
+
* and `ciphertext` is a forbidden material field.
|
|
250
|
+
*/
|
|
251
|
+
readonly file: string;
|
|
252
|
+
/** Encryption tool. Closed union; `"sops"` is its only member today. */
|
|
253
|
+
readonly encryption?: SecretEncryption;
|
|
254
|
+
/** Public recipient identifiers — age recipients or PGP fingerprints. */
|
|
255
|
+
readonly recipients?: readonly string[];
|
|
256
|
+
/** The declared key-set of the decrypted Secret. Names only, never values. */
|
|
257
|
+
readonly keys?: readonly string[];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export type SecretDeclarationInput =
|
|
261
|
+
| ReferencedSecretInput
|
|
262
|
+
| FromProviderSecretInput
|
|
263
|
+
| GeneratedOnceSecretInput
|
|
264
|
+
| CommittedEncryptedSecretInput;
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Declare a secret's provenance. The returned object is a locked Declarable:
|
|
268
|
+
* discovery collects it like any entity, `chant list` shows it, lint and
|
|
269
|
+
* lexicons read it — and no serializer ever emits it.
|
|
270
|
+
*
|
|
271
|
+
* Only the fields the kind defines are copied onto the declaration; anything
|
|
272
|
+
* else on the input — in particular anything that could carry material — is
|
|
273
|
+
* either a compile error ({@link NoSecretMaterial}) or a thrown error here.
|
|
274
|
+
* The thrown message names the offending KEY only, never its value.
|
|
275
|
+
*/
|
|
276
|
+
export function declareSecret(input: ReferencedSecretInput): ReferencedSecretDeclaration;
|
|
277
|
+
export function declareSecret(input: FromProviderSecretInput): FromProviderSecretDeclaration;
|
|
278
|
+
export function declareSecret(input: GeneratedOnceSecretInput): GeneratedOnceSecretDeclaration;
|
|
279
|
+
export function declareSecret(
|
|
280
|
+
input: CommittedEncryptedSecretInput,
|
|
281
|
+
): CommittedEncryptedSecretDeclaration;
|
|
282
|
+
export function declareSecret(input: SecretDeclarationInput): SecretDeclaration {
|
|
283
|
+
if (typeof input.name !== "string" || input.name.length === 0) {
|
|
284
|
+
throw new Error("declareSecret: `name` must be a non-empty string");
|
|
285
|
+
}
|
|
286
|
+
if (!SECRET_PROVENANCE_KINDS.includes(input.provenance)) {
|
|
287
|
+
throw new Error(
|
|
288
|
+
`declareSecret("${input.name}"): unknown provenance "${String(input.provenance)}" — ` +
|
|
289
|
+
`expected one of ${SECRET_PROVENANCE_KINDS.join(", ")}`,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
for (const field of FORBIDDEN_MATERIAL_FIELDS) {
|
|
293
|
+
if (Object.prototype.hasOwnProperty.call(input, field)) {
|
|
294
|
+
// Name the key only. Never read, log, or echo the value.
|
|
295
|
+
throw new Error(
|
|
296
|
+
`declareSecret("${input.name}"): field "${field}" is not allowed — ` +
|
|
297
|
+
`a secret declaration records provenance, never material`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const base = {
|
|
303
|
+
[DECLARABLE_MARKER]: true,
|
|
304
|
+
[SECRET_DECLARATION_MARKER]: true,
|
|
305
|
+
lexicon: "chant",
|
|
306
|
+
entityType: SECRET_DECLARATION_ENTITY_TYPE,
|
|
307
|
+
name: input.name,
|
|
308
|
+
} as const;
|
|
309
|
+
|
|
310
|
+
// Copy known fields per kind explicitly — never spread `input`, so a field
|
|
311
|
+
// that slipped past the type system still cannot land on the declaration.
|
|
312
|
+
// The top-level object stays extensible (discovery stamps logical-name and
|
|
313
|
+
// provenance symbols onto entities); the declared fields themselves are
|
|
314
|
+
// defined non-writable below, and nested structures are frozen.
|
|
315
|
+
switch (input.provenance) {
|
|
316
|
+
case "referenced": {
|
|
317
|
+
const decl: ReferencedSecretDeclaration = {
|
|
318
|
+
...base,
|
|
319
|
+
provenance: "referenced",
|
|
320
|
+
...(input.scope !== undefined ? { scope: input.scope } : {}),
|
|
321
|
+
};
|
|
322
|
+
return lockDeclaredFields(decl);
|
|
323
|
+
}
|
|
324
|
+
case "from-provider": {
|
|
325
|
+
if (typeof input.provider?.binding !== "string" || input.provider.binding.length === 0) {
|
|
326
|
+
throw new Error(
|
|
327
|
+
`declareSecret("${input.name}"): from-provider requires \`provider.binding\` ` +
|
|
328
|
+
`naming the provider binding entity`,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
const decl: FromProviderSecretDeclaration = {
|
|
332
|
+
...base,
|
|
333
|
+
provenance: "from-provider",
|
|
334
|
+
provider: Object.freeze({
|
|
335
|
+
binding: input.provider.binding,
|
|
336
|
+
...(input.provider.entityType !== undefined ? { entityType: input.provider.entityType } : {}),
|
|
337
|
+
}),
|
|
338
|
+
};
|
|
339
|
+
return lockDeclaredFields(decl);
|
|
340
|
+
}
|
|
341
|
+
case "generated-once": {
|
|
342
|
+
const decl: GeneratedOnceSecretDeclaration = {
|
|
343
|
+
...base,
|
|
344
|
+
provenance: "generated-once",
|
|
345
|
+
...(input.keys !== undefined ? { keys: Object.freeze([...input.keys]) } : {}),
|
|
346
|
+
};
|
|
347
|
+
return lockDeclaredFields(decl);
|
|
348
|
+
}
|
|
349
|
+
case "committed-encrypted": {
|
|
350
|
+
const decl: CommittedEncryptedSecretDeclaration = {
|
|
351
|
+
...base,
|
|
352
|
+
provenance: "committed-encrypted",
|
|
353
|
+
file: validateCiphertextPath(input.name, input.file),
|
|
354
|
+
encryption: validateEncryption(input.name, input.encryption),
|
|
355
|
+
...(input.recipients !== undefined
|
|
356
|
+
? { recipients: Object.freeze(validateRecipients(input.name, input.recipients)) }
|
|
357
|
+
: {}),
|
|
358
|
+
...(input.keys !== undefined ? { keys: Object.freeze([...input.keys]) } : {}),
|
|
359
|
+
};
|
|
360
|
+
return lockDeclaredFields(decl);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Validate the repo-relative ciphertext path. Pure and offline — the factory
|
|
367
|
+
* never stats the file, because discovery folds project source statically
|
|
368
|
+
* under `--sandbox` and a factory that touched the filesystem would either
|
|
369
|
+
* break folding or reintroduce the trust boundary that suite defends. The
|
|
370
|
+
* file's existence and shape are checked at `buildRoots()` instead.
|
|
371
|
+
*/
|
|
372
|
+
function validateCiphertextPath(name: string, file: unknown): string {
|
|
373
|
+
if (typeof file !== "string" || file.length === 0) {
|
|
374
|
+
throw new Error(
|
|
375
|
+
`declareSecret("${name}"): committed-encrypted requires \`file\`, a non-empty ` +
|
|
376
|
+
`repo-relative path to the committed ciphertext`,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
if (file.startsWith("/") || /^[A-Za-z]:[\\/]/.test(file)) {
|
|
380
|
+
throw new Error(
|
|
381
|
+
`declareSecret("${name}"): \`file\` must be repo-relative, not absolute — got "${file}"`,
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
const segments = file.split(/[\\/]/);
|
|
385
|
+
if (segments.includes("..")) {
|
|
386
|
+
throw new Error(
|
|
387
|
+
`declareSecret("${name}"): \`file\` must not escape the project with a ".." segment — ` +
|
|
388
|
+
`got "${file}"`,
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
const dot = file.lastIndexOf(".");
|
|
392
|
+
const extension = dot === -1 ? "" : file.slice(dot).toLowerCase();
|
|
393
|
+
if (!(CIPHERTEXT_FILE_EXTENSIONS as readonly string[]).includes(extension)) {
|
|
394
|
+
throw new Error(
|
|
395
|
+
`declareSecret("${name}"): \`file\` must be a YAML file ` +
|
|
396
|
+
`(${CIPHERTEXT_FILE_EXTENSIONS.join(", ")}) — got "${extension || "no extension"}". ` +
|
|
397
|
+
`Other formats are not emitted byte-for-byte yet.`,
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
return file;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Validate the encryption tool, defaulting to `"sops"`. */
|
|
404
|
+
function validateEncryption(name: string, encryption: unknown): SecretEncryption {
|
|
405
|
+
if (encryption === undefined) return "sops";
|
|
406
|
+
if (!(SECRET_ENCRYPTION_TOOLS as readonly unknown[]).includes(encryption)) {
|
|
407
|
+
throw new Error(
|
|
408
|
+
`declareSecret("${name}"): unknown encryption "${String(encryption)}" — ` +
|
|
409
|
+
`expected one of ${SECRET_ENCRYPTION_TOOLS.join(", ")}`,
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
return encryption as SecretEncryption;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Copy `recipients`, refusing a private key pasted where a public recipient
|
|
417
|
+
* belongs. The message names the field and the index — never the value, the
|
|
418
|
+
* same discipline the forbidden-material check above uses.
|
|
419
|
+
*/
|
|
420
|
+
function validateRecipients(name: string, recipients: readonly string[]): string[] {
|
|
421
|
+
const copied = [...recipients];
|
|
422
|
+
copied.forEach((recipient, index) => {
|
|
423
|
+
if (typeof recipient !== "string" || recipient.length === 0) {
|
|
424
|
+
throw new Error(
|
|
425
|
+
`declareSecret("${name}"): \`recipients[${index}]\` must be a non-empty string`,
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
if (PRIVATE_KEY_MARKERS.some((pattern) => pattern.test(recipient))) {
|
|
429
|
+
throw new Error(
|
|
430
|
+
`declareSecret("${name}"): \`recipients[${index}]\` looks like a PRIVATE key — ` +
|
|
431
|
+
`recipients are public identifiers (age recipients, PGP fingerprints). ` +
|
|
432
|
+
`Rotate it: it has been in a source file.`,
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
return copied;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Make every declared (string-keyed) field non-writable and non-configurable
|
|
441
|
+
* without sealing the object — discovery still stamps its own symbol-keyed
|
|
442
|
+
* metadata (logical name, build provenance) onto entities, which a frozen
|
|
443
|
+
* object would reject.
|
|
444
|
+
*/
|
|
445
|
+
function lockDeclaredFields<T extends object>(decl: T): T {
|
|
446
|
+
for (const key of Object.keys(decl)) {
|
|
447
|
+
Object.defineProperty(decl, key, { writable: false, configurable: false });
|
|
448
|
+
}
|
|
449
|
+
return decl;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Type guard for a secret provenance declaration. */
|
|
453
|
+
export function isSecretDeclaration(value: unknown): value is SecretDeclaration {
|
|
454
|
+
return (
|
|
455
|
+
typeof value === "object" &&
|
|
456
|
+
value !== null &&
|
|
457
|
+
SECRET_DECLARATION_MARKER in value &&
|
|
458
|
+
(value as Record<symbol, unknown>)[SECRET_DECLARATION_MARKER] === true
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Extract the secret declarations from a discovered entity map — the read
|
|
464
|
+
* surface for lint rules (#1382) and lexicon materializers (#1830). Keyed by
|
|
465
|
+
* entity name (export name), the same key `DiscoveryResult.entities` uses.
|
|
466
|
+
*/
|
|
467
|
+
export function collectSecretDeclarations(
|
|
468
|
+
entities: ReadonlyMap<string, Declarable>,
|
|
469
|
+
): Map<string, SecretDeclaration> {
|
|
470
|
+
const out = new Map<string, SecretDeclaration>();
|
|
471
|
+
for (const [name, entity] of entities) {
|
|
472
|
+
if (isSecretDeclaration(entity)) out.set(name, entity);
|
|
473
|
+
}
|
|
474
|
+
return out;
|
|
475
|
+
}
|
package/src/serializer.ts
CHANGED
|
@@ -20,6 +20,18 @@ export interface SerializeContext {
|
|
|
20
20
|
* ad-hoc builds (e.g. context tools) that pass no config.
|
|
21
21
|
*/
|
|
22
22
|
config?: Record<string, unknown>;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Effect receipts (#1832, epic #1703) declared in this lexicon's partition.
|
|
26
|
+
* Withheld from the `entities` map by the build: receipts are observe-only
|
|
27
|
+
* to the generic apply path, and the serialized output is what feeds
|
|
28
|
+
* appliers, so a receipt must never enter the apply-bound document. A
|
|
29
|
+
* serializer that wants to render receipts for visibility (#1835) reads
|
|
30
|
+
* them here and puts them anywhere BUT the section an applier writes from;
|
|
31
|
+
* a serializer that ignores this field emits nothing for them, which is the
|
|
32
|
+
* safe default.
|
|
33
|
+
*/
|
|
34
|
+
receipts?: ReadonlyMap<string, Declarable>;
|
|
23
35
|
}
|
|
24
36
|
|
|
25
37
|
/**
|
package/src/yaml.test.ts
CHANGED
|
@@ -226,6 +226,94 @@ describe("parseScalar", () => {
|
|
|
226
226
|
expect(parseScalar("hello")).toBe("hello");
|
|
227
227
|
});
|
|
228
228
|
});
|
|
229
|
+
// Quoted scalars (#1860) — stripping the delimiters is only half the job; the
|
|
230
|
+
// escapes inside them have to be decoded too. A double-quoted `\n` that stayed
|
|
231
|
+
// two literal characters is what sent a seven-line setup script to a shell as
|
|
232
|
+
// one line.
|
|
233
|
+
describe("parseScalar quoted scalars (#1860)", () => {
|
|
234
|
+
test("double-quoted escapes decode", () => {
|
|
235
|
+
expect(parseScalar(String.raw`"a\nb"`)).toBe("a\nb");
|
|
236
|
+
expect(parseScalar(String.raw`"a\tb"`)).toBe("a\tb");
|
|
237
|
+
expect(parseScalar(String.raw`"say \"hi\""`)).toBe('say "hi"');
|
|
238
|
+
expect(parseScalar(String.raw`"back\\slash"`)).toBe("back\\slash");
|
|
239
|
+
expect(parseScalar(String.raw`"a\/b"`)).toBe("a/b");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("hex, unicode and 32-bit escapes", () => {
|
|
243
|
+
expect(parseScalar(String.raw`"\x41"`)).toBe("A");
|
|
244
|
+
expect(parseScalar(String.raw`"A"`)).toBe("A");
|
|
245
|
+
expect(parseScalar(String.raw`"\U0001F600"`)).toBe("\u{1F600}");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("an escaped line break folds away with its indentation", () => {
|
|
249
|
+
expect(parseScalar('"a\\\n b"')).toBe("ab");
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("single-quoted '' is one quote", () => {
|
|
253
|
+
expect(parseScalar("'it''s'")).toBe("it's");
|
|
254
|
+
expect(parseScalar("'''quoted'''")).toBe("'quoted'");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("a lone quote character is not a quoted scalar", () => {
|
|
258
|
+
expect(parseScalar('"')).toBe('"');
|
|
259
|
+
expect(parseScalar("'")).toBe("'");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("a body with a bare quote keeps the old strip-only behaviour", () => {
|
|
263
|
+
expect(parseScalar('"a" + "b"')).toBe('a" + "b');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("an unknown or truncated escape keeps the old strip-only behaviour", () => {
|
|
267
|
+
expect(parseScalar(String.raw`"a\qb"`)).toBe(String.raw`a\qb`);
|
|
268
|
+
expect(parseScalar(String.raw`"\x4"`)).toBe(String.raw`\x4`);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// Round-trip parity for the scalars `emitYAML` quotes: what it writes,
|
|
273
|
+
// `parseYAML` must read back unchanged. The single-quote half of #1860 was
|
|
274
|
+
// invisible until this existed. Multiline strings are excluded on purpose —
|
|
275
|
+
// the emitter sends those through a block scalar, which #910 already covers
|
|
276
|
+
// and which does not preserve trailing newlines.
|
|
277
|
+
describe("emitYAML/parseYAML round trip (#1860)", () => {
|
|
278
|
+
const values = [
|
|
279
|
+
"'quoted'",
|
|
280
|
+
"#hash's",
|
|
281
|
+
"it's a test",
|
|
282
|
+
"plain",
|
|
283
|
+
"yes",
|
|
284
|
+
"a: b",
|
|
285
|
+
"$VAR",
|
|
286
|
+
"",
|
|
287
|
+
];
|
|
288
|
+
|
|
289
|
+
for (const v of values) {
|
|
290
|
+
test(`round-trips ${JSON.stringify(v)}`, () => {
|
|
291
|
+
expect(parseYAML(`k: ${emitYAML(v, 0)}\n`).k).toBe(v);
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// The fountain lexicon emits multiline strings as `JSON.stringify(s)` — a
|
|
297
|
+
// double-quoted scalar, not a block scalar — so this is the shape that
|
|
298
|
+
// actually reached the applier (#1860).
|
|
299
|
+
describe("double-quoted multiline manifest values (#1860)", () => {
|
|
300
|
+
test("a joined shell script keeps its newlines", () => {
|
|
301
|
+
const script = [
|
|
302
|
+
"set -e",
|
|
303
|
+
"sudo service postgresql start || true",
|
|
304
|
+
`sudo -u postgres psql -tc "ALTER USER postgres PASSWORD 'postgres'" || true`,
|
|
305
|
+
"cd /workspace/fountain",
|
|
306
|
+
].join("\n");
|
|
307
|
+
|
|
308
|
+
const yaml = `spec:\n setup_script: ${JSON.stringify(script)}\n`;
|
|
309
|
+
const parsed = parseYAML(yaml) as { spec: { setup_script: string } };
|
|
310
|
+
|
|
311
|
+
expect(parsed.spec.setup_script).toBe(script);
|
|
312
|
+
expect(parsed.spec.setup_script.split("\n")).toHaveLength(4);
|
|
313
|
+
expect(parsed.spec.setup_script).not.toContain(String.raw`\n`);
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
229
317
|
|
|
230
318
|
// Block scalars (#910) — round-trip parity with js-yaml for literal/folded/chomping,
|
|
231
319
|
// including block scalars nested inside array items (the case that mis-parsed).
|
package/src/yaml.ts
CHANGED
|
@@ -559,15 +559,85 @@ export function parseScalar(value: string): unknown {
|
|
|
559
559
|
if (value === "" || value === "~" || value === "null") return null;
|
|
560
560
|
if (value === "true" || value === "yes") return true;
|
|
561
561
|
if (value === "false" || value === "no") return false;
|
|
562
|
-
//
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
)
|
|
567
|
-
|
|
562
|
+
// Quoted scalars carry escapes, and stripping the quotes is only half the
|
|
563
|
+
// job: a double-quoted `\n` is a newline, a single-quoted `''` is one
|
|
564
|
+
// quote. Dropping that step leaves the escape sequence in the value as
|
|
565
|
+
// literal characters, which is how a multiline `setup_script` reached a
|
|
566
|
+
// shell as one line of `set -e\nsudo ...` (#1860).
|
|
567
|
+
if (value.length >= 2) {
|
|
568
|
+
if (value.startsWith('"') && value.endsWith('"')) {
|
|
569
|
+
const unescaped = unescapeDoubleQuoted(value.slice(1, -1));
|
|
570
|
+
// `null` means the body held a bare `"`, so this was never a single
|
|
571
|
+
// well-formed scalar (`"a" + "b"`). Keep the old behaviour there.
|
|
572
|
+
return unescaped ?? value.slice(1, -1);
|
|
573
|
+
}
|
|
574
|
+
if (value.startsWith("'") && value.endsWith("'")) {
|
|
575
|
+
return value.slice(1, -1).replace(/''/g, "'");
|
|
576
|
+
}
|
|
568
577
|
}
|
|
569
578
|
// Number
|
|
570
579
|
const num = Number(value);
|
|
571
580
|
if (!isNaN(num) && value !== "") return num;
|
|
572
581
|
return value;
|
|
573
582
|
}
|
|
583
|
+
|
|
584
|
+
/** YAML double-quoted escapes, per the spec's escape table. */
|
|
585
|
+
const DQ_ESCAPES: Record<string, string> = {
|
|
586
|
+
"0": "\0",
|
|
587
|
+
a: "\x07",
|
|
588
|
+
b: "\b",
|
|
589
|
+
t: "\t",
|
|
590
|
+
"\t": "\t",
|
|
591
|
+
n: "\n",
|
|
592
|
+
v: "\v",
|
|
593
|
+
f: "\f",
|
|
594
|
+
r: "\r",
|
|
595
|
+
e: "\x1b",
|
|
596
|
+
" ": " ",
|
|
597
|
+
'"': '"',
|
|
598
|
+
"/": "/",
|
|
599
|
+
"\\": "\\",
|
|
600
|
+
N: "\x85",
|
|
601
|
+
_: "\xa0",
|
|
602
|
+
L: "\u2028",
|
|
603
|
+
P: "\u2029",
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Decode the body of a double-quoted YAML scalar.
|
|
608
|
+
*
|
|
609
|
+
* Returns `null` when the body holds an unescaped `"`, which means the caller
|
|
610
|
+
* was not looking at one quoted scalar after all.
|
|
611
|
+
*/
|
|
612
|
+
function unescapeDoubleQuoted(body: string): string | null {
|
|
613
|
+
let out = "";
|
|
614
|
+
for (let i = 0; i < body.length; i++) {
|
|
615
|
+
const ch = body[i];
|
|
616
|
+
if (ch === '"') return null;
|
|
617
|
+
if (ch !== "\\") {
|
|
618
|
+
out += ch;
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
const esc = body[++i];
|
|
622
|
+
if (esc === undefined) return null;
|
|
623
|
+
// \xXX, \uXXXX, \UXXXXXXXX
|
|
624
|
+
const width = esc === "x" ? 2 : esc === "u" ? 4 : esc === "U" ? 8 : 0;
|
|
625
|
+
if (width > 0) {
|
|
626
|
+
const hex = body.slice(i + 1, i + 1 + width);
|
|
627
|
+
if (hex.length < width || !/^[0-9a-fA-F]+$/.test(hex)) return null;
|
|
628
|
+
out += String.fromCodePoint(parseInt(hex, 16));
|
|
629
|
+
i += width;
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
// An escaped line break is a line continuation: it and the following
|
|
633
|
+
// indentation fold away to nothing.
|
|
634
|
+
if (esc === "\n") {
|
|
635
|
+
while (body[i + 1] === " " || body[i + 1] === "\t") i++;
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
const mapped = DQ_ESCAPES[esc];
|
|
639
|
+
if (mapped === undefined) return null;
|
|
640
|
+
out += mapped;
|
|
641
|
+
}
|
|
642
|
+
return out;
|
|
643
|
+
}
|