opencode-secrets-obfuscation 0.1.0 → 0.1.2
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 -21
- package/README.md +11 -21
- package/package.json +29 -23
- package/plugin.mjs +3847 -0
- package/.editorconfig +0 -12
- package/.github/workflows/ci.yml +0 -75
- package/.github/workflows/release.yml +0 -50
- package/.node-version +0 -1
- package/AGENTS.md +0 -23
- package/INSTALL.md +0 -104
- package/docs/checkpoint.md +0 -68
- package/docs/history/1-spec-review.md +0 -233
- package/docs/history/10-p0-launch-prerequisites-review.md +0 -186
- package/docs/history/11-p0-job-containment-review.md +0 -188
- package/docs/history/12-p0-api-client.md +0 -15
- package/docs/history/13-p0-status-review.md +0 -122
- package/docs/history/14-p0-containment-final-review.md +0 -84
- package/docs/history/15-config-source-implementation.md +0 -27
- package/docs/history/16-sanitizer-implementation.md +0 -65
- package/docs/history/17-store-policy-implementation.md +0 -36
- package/docs/history/18-pure-mvp-review.md +0 -182
- package/docs/history/19-pure-mvp-re-review.md +0 -142
- package/docs/history/2-p0-runtime-prerequisites.md +0 -163
- package/docs/history/20-pure-mvp-final-review.md +0 -116
- package/docs/history/21-pure-mvp-review-final.md +0 -109
- package/docs/history/22-pure-mvp-signoff-review.md +0 -113
- package/docs/history/23-pure-mvp-signoff-review.md +0 -75
- package/docs/history/24-owned-tools-implementation.md +0 -27
- package/docs/history/25-v2-plugin-integration.md +0 -53
- package/docs/history/26-owned-tools-review.md +0 -138
- package/docs/history/27-owned-tools-final-review.md +0 -93
- package/docs/history/28-owned-tools-signoff.md +0 -98
- package/docs/history/3-p0-harness-foundation.md +0 -105
- package/docs/history/4-p0-source-opening.md +0 -78
- package/docs/history/5-qualification-gate-review.md +0 -133
- package/docs/history/6-p0-infrastructure-review.md +0 -275
- package/docs/history/7-p0-job-containment.md +0 -29
- package/docs/history/8-p0-evidence-support.md +0 -52
- package/docs/history/9-p0-launch-plan.md +0 -88
- package/docs/spec/design.md +0 -111
- package/docs/spec/intent.md +0 -81
- package/docs/spec/plan.md +0 -78
- package/docs/testing.md +0 -157
- package/scripts/build-plugin.mjs +0 -84
- package/scripts/install-opencode-ci.ps1 +0 -39
- package/scripts/install-opencode-ci.sh +0 -27
- package/scripts/smoke.mjs +0 -105
- package/scripts/verify-package.mjs +0 -18
- package/src/README.md +0 -22
- package/src/budgets/index.ts +0 -49
- package/src/config/README.md +0 -4
- package/src/config/options.ts +0 -76
- package/src/diagnostics/README.md +0 -4
- package/src/opencode/v2/filter.ts +0 -350
- package/src/opencode/v2/index.ts +0 -86
- package/src/opencode/v2/runtime.ts +0 -207
- package/src/opencode/v2/transports/README.md +0 -5
- package/src/runtime/http-transport.ts +0 -20
- package/src/sanitize/README.md +0 -4
- package/src/sanitize/index.ts +0 -454
- package/src/sources/README.md +0 -5
- package/src/sources/dotenv.ts +0 -133
- package/src/sources/native/README.md +0 -5
- package/src/store/index.ts +0 -64
- package/tests/README.md +0 -26
- package/tests/contract/plugin-v2.test.ts +0 -29
- package/tests/fixtures/README.md +0 -9
- package/tests/fixtures/job-child.mjs +0 -18
- package/tests/fixtures/plugins/README.md +0 -8
- package/tests/fixtures/process-child.mjs +0 -20
- package/tests/fixtures/services/README.md +0 -10
- package/tests/integration/components.test.ts +0 -5
- package/tests/integration/transparent-runtime.test.ts +0 -97
- package/tests/integration/windows-job.test.ts +0 -71
- package/tests/p0-host/README.md +0 -24
- package/tests/p0-host/linux-job.sh +0 -61
- package/tests/p0-host/windows-job.ps1 +0 -51
- package/tests/p0-native/README.md +0 -33
- package/tests/p0-native/windows-handle-loader.tests.ps1 +0 -191
- package/tests/scaffold/repository.test.ts +0 -105
- package/tests/smoke/README.md +0 -37
- package/tests/smoke/cases.json +0 -17
- package/tests/smoke/qualification.json +0 -20
- package/tests/smoke/runtime-candidate.json +0 -111
- package/tests/support/README.md +0 -32
- package/tests/support/child-process.ts +0 -235
- package/tests/support/host-api.ts +0 -123
- package/tests/support/opencode-environment.ts +0 -239
- package/tests/support/qualification.ts +0 -154
- package/tests/support/recording-endpoint.ts +0 -242
- package/tests/support/run-directory.ts +0 -179
- package/tests/support/smoke-cases.ts +0 -289
- package/tests/unit/budgets.test.ts +0 -35
- package/tests/unit/config.test.ts +0 -40
- package/tests/unit/dotenv.test.ts +0 -72
- package/tests/unit/filter-size.test.ts +0 -132
- package/tests/unit/harness.test.ts +0 -228
- package/tests/unit/host-api.test.ts +0 -122
- package/tests/unit/opencode-environment.test.ts +0 -161
- package/tests/unit/qualification.test.ts +0 -128
- package/tests/unit/recording-endpoint.test.ts +0 -188
- package/tests/unit/sanitize.test.ts +0 -159
- package/tests/unit/smoke-cases.test.ts +0 -20
- package/tsconfig.json +0 -20
package/src/sanitize/index.ts
DELETED
|
@@ -1,454 +0,0 @@
|
|
|
1
|
-
/** Pure, bounded sanitization primitives. Host adapters must validate their own final bytes. */
|
|
2
|
-
|
|
3
|
-
export interface Pattern {
|
|
4
|
-
readonly text: string
|
|
5
|
-
readonly replacement?: string
|
|
6
|
-
readonly entry?: string
|
|
7
|
-
/** Additional lexical/transport spellings belonging to this same entry. */
|
|
8
|
-
readonly representations?: readonly string[]
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export interface Interval {
|
|
12
|
-
start: number
|
|
13
|
-
end: number
|
|
14
|
-
patterns: string[]
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export interface Budget {
|
|
18
|
-
readonly maxOutputBytes?: number
|
|
19
|
-
readonly maxDepth?: number
|
|
20
|
-
readonly maxNodes?: number
|
|
21
|
-
readonly maxMatches?: number
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const DEFAULT_MAX_OUTPUT_BYTES = 2 * 1024 * 1024
|
|
25
|
-
const DEFAULT_MAX_DEPTH = 32
|
|
26
|
-
const DEFAULT_MAX_NODES = 50_000
|
|
27
|
-
const DEFAULT_MAX_MATCHES = 50_000
|
|
28
|
-
// These are hard operation ceilings, not merely defaults. Explicit callers may
|
|
29
|
-
// request less, but may not opt out of bounded work by supplying a larger value.
|
|
30
|
-
const MAX_OUTPUT_BYTES = DEFAULT_MAX_OUTPUT_BYTES
|
|
31
|
-
const MAX_DEPTH = DEFAULT_MAX_DEPTH
|
|
32
|
-
const MAX_NODES = DEFAULT_MAX_NODES
|
|
33
|
-
const MAX_MATCHES = DEFAULT_MAX_MATCHES
|
|
34
|
-
const MAX_OBJECT_KEYS = MAX_NODES
|
|
35
|
-
const MAX_PATTERN_COUNT = 4_096
|
|
36
|
-
const MAX_PATTERN_TEXT = 65_536
|
|
37
|
-
const MAX_REPRESENTATIONS = 16
|
|
38
|
-
const MAX_INDEX_ENTRIES = 65_536
|
|
39
|
-
const MAX_IDENTITY_ENTRIES = 50_000
|
|
40
|
-
const MAX_PATTERN_BYTES = 4 * 1024 * 1024
|
|
41
|
-
const MAX_TEMP_BYTES = 8 * 1024 * 1024
|
|
42
|
-
|
|
43
|
-
type Reservation = { release(): void }
|
|
44
|
-
let reservedPatternBytes = 0
|
|
45
|
-
let reservedIndexEntries = 0
|
|
46
|
-
let reservedIdentityEntries = 0
|
|
47
|
-
let reservedTempBytes = 0
|
|
48
|
-
|
|
49
|
-
function reserveOperation(patterns: readonly (Pattern | string)[] | undefined, inputBytes: number): Reservation | null {
|
|
50
|
-
try {
|
|
51
|
-
if (patterns !== undefined && !Array.isArray(patterns)) return null
|
|
52
|
-
const count = patterns?.length ?? 0
|
|
53
|
-
if (count > MAX_PATTERN_COUNT) return null
|
|
54
|
-
let patternBytes = 0
|
|
55
|
-
for (const item of patterns ?? []) {
|
|
56
|
-
const text = typeof item === "string" ? item : (() => {
|
|
57
|
-
if (item === null || typeof item !== "object") return null
|
|
58
|
-
const descriptor = Object.getOwnPropertyDescriptor(item, "text")
|
|
59
|
-
return descriptor && "value" in descriptor && typeof descriptor.value === "string" ? descriptor.value : null
|
|
60
|
-
})()
|
|
61
|
-
if (text === null) return null
|
|
62
|
-
patternBytes += encoder.encode(text).byteLength
|
|
63
|
-
if (patternBytes > MAX_PATTERN_BYTES) return null
|
|
64
|
-
}
|
|
65
|
-
const indexEntries = count * MAX_REPRESENTATIONS
|
|
66
|
-
const identityEntries = count
|
|
67
|
-
const tempBytes = inputBytes + patternBytes
|
|
68
|
-
if (!Number.isSafeInteger(inputBytes) || inputBytes < 0 || indexEntries > MAX_INDEX_ENTRIES || identityEntries > MAX_IDENTITY_ENTRIES || tempBytes > MAX_TEMP_BYTES) return null
|
|
69
|
-
if (reservedPatternBytes > MAX_PATTERN_BYTES - patternBytes || reservedIndexEntries > MAX_INDEX_ENTRIES - indexEntries || reservedIdentityEntries > MAX_IDENTITY_ENTRIES - identityEntries || reservedTempBytes > MAX_TEMP_BYTES - tempBytes) return null
|
|
70
|
-
reservedPatternBytes += patternBytes; reservedIndexEntries += indexEntries; reservedIdentityEntries += identityEntries; reservedTempBytes += tempBytes
|
|
71
|
-
let released = false
|
|
72
|
-
return { release: () => { if (released) return; released = true; reservedPatternBytes -= patternBytes; reservedIndexEntries -= indexEntries; reservedIdentityEntries -= identityEntries; reservedTempBytes -= tempBytes } }
|
|
73
|
-
} catch { return null }
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export const RESERVED_OUTPUT_INVENTORY = Object.freeze([
|
|
77
|
-
"true", "false", "null", "REDACTED", "SANITIZATION_UNAVAILABLE",
|
|
78
|
-
"INVALID_CREDENTIAL", "ACCESS_DENIED", "UNAVAILABLE", "error", "content", "text",
|
|
79
|
-
])
|
|
80
|
-
|
|
81
|
-
export interface Representations {
|
|
82
|
-
readonly semantic: string
|
|
83
|
-
readonly dotenv: readonly string[]
|
|
84
|
-
readonly json: string
|
|
85
|
-
readonly percent: readonly string[]
|
|
86
|
-
readonly base64: readonly string[]
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function base64(bytes: Uint8Array): string {
|
|
90
|
-
let binary = ""
|
|
91
|
-
for (const byte of bytes) binary += String.fromCharCode(byte)
|
|
92
|
-
return Buffer.from(binary, "binary").toString("base64")
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/** The finite, versioned representation inventory used by the pure MVP matcher. */
|
|
96
|
-
export function representationsFor(value: string): Representations | null {
|
|
97
|
-
const reservation = typeof value === "string" ? reserveOperation(undefined, encoder.encode(value).byteLength) : null
|
|
98
|
-
try {
|
|
99
|
-
if (typeof value !== "string" || value.length > MAX_PATTERN_TEXT) return null
|
|
100
|
-
const bytes = encoder.encode(value)
|
|
101
|
-
if (bytes.byteLength > MAX_PATTERN_TEXT * 3) return null
|
|
102
|
-
const encoded = Array.from(bytes, (byte) => {
|
|
103
|
-
const character = String.fromCharCode(byte)
|
|
104
|
-
return /[A-Za-z0-9\-_.!~*'()]/u.test(character) ? character : `%${byte.toString(16).padStart(2, "0")}`
|
|
105
|
-
}).join("")
|
|
106
|
-
const percentUpper = encoded.replace(/%[0-9a-f]{2}/g, (part) => part.toUpperCase())
|
|
107
|
-
const padded = base64(bytes)
|
|
108
|
-
const url = padded.replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, "")
|
|
109
|
-
const doubleQuoted = `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("\n", "\\n").replaceAll("\r", "\\r").replaceAll("\t", "\\t")}"`
|
|
110
|
-
const singleQuoted = `'${value}'`
|
|
111
|
-
const dotenv = [doubleQuoted, singleQuoted]
|
|
112
|
-
if (/^[^\s#'"\\\r\n]+$/u.test(value)) dotenv.push(value)
|
|
113
|
-
const result = {
|
|
114
|
-
semantic: value,
|
|
115
|
-
dotenv,
|
|
116
|
-
json: JSON.stringify(value).slice(1, -1),
|
|
117
|
-
percent: [encoded, percentUpper],
|
|
118
|
-
base64: [padded, padded.replace(/=+$/u, ""), url],
|
|
119
|
-
}
|
|
120
|
-
return result.dotenv.length + result.percent.length + result.base64.length > MAX_REPRESENTATIONS ? null : result
|
|
121
|
-
} catch {
|
|
122
|
-
return null
|
|
123
|
-
} finally { reservation?.release() }
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
export function patternRepresentations(pattern: Pattern): string[] | null {
|
|
127
|
-
const reservation = reserveOperation([pattern], 0)
|
|
128
|
-
try {
|
|
129
|
-
if (typeof pattern?.text !== "string") return null
|
|
130
|
-
const derived = representationsFor(pattern.text)
|
|
131
|
-
if (derived === null) return null
|
|
132
|
-
const result = [...new Set([pattern.text, ...derived.dotenv, derived.json, ...derived.percent, ...derived.base64])]
|
|
133
|
-
return result.length > MAX_REPRESENTATIONS ? null : result
|
|
134
|
-
} catch {
|
|
135
|
-
return null
|
|
136
|
-
} finally { reservation?.release() }
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
const encoder = new TextEncoder()
|
|
140
|
-
const REDACTION = "[REDACTED]"
|
|
141
|
-
const UUID_TEMPLATE = [
|
|
142
|
-
..."secret-",
|
|
143
|
-
...Array.from({ length: 8 }, () => "h"), "-",
|
|
144
|
-
...Array.from({ length: 4 }, () => "h"), "-", "4", ...Array.from({ length: 3 }, () => "h"), "-",
|
|
145
|
-
"v", ...Array.from({ length: 3 }, () => "h"), "-", ...Array.from({ length: 12 }, () => "h"),
|
|
146
|
-
]
|
|
147
|
-
|
|
148
|
-
function uuidLanguageContains(value: string): boolean {
|
|
149
|
-
if (value.length === 0 || value.length > UUID_TEMPLATE.length) return false
|
|
150
|
-
const hex = /^[0-9a-f]$/i
|
|
151
|
-
for (let start = 0; start <= UUID_TEMPLATE.length - value.length; start++) {
|
|
152
|
-
let fits = true
|
|
153
|
-
for (let i = 0; i < value.length; i++) {
|
|
154
|
-
const slot = UUID_TEMPLATE[start + i]
|
|
155
|
-
const character = value[i]
|
|
156
|
-
const slotMatches = slot === "h" ? hex.test(character ?? "") : slot === "v" ? /^[89ab]$/i.test(character ?? "") : slot === character
|
|
157
|
-
if (slot === undefined || character === undefined || !slotMatches) {
|
|
158
|
-
fits = false
|
|
159
|
-
break
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
if (fits) return true
|
|
163
|
-
}
|
|
164
|
-
return false
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
export interface AdmissionResult {
|
|
168
|
-
readonly ok: boolean
|
|
169
|
-
readonly code?: "SANITIZATION_UNAVAILABLE"
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/** Admission is deliberately all-or-nothing and emits only a fixed safe failure. */
|
|
173
|
-
export function admitPatterns(patterns: readonly Pattern[]): AdmissionResult {
|
|
174
|
-
const reservation = reserveOperation(patterns, 0)
|
|
175
|
-
if (reservation === null) return { ok: false, code: "SANITIZATION_UNAVAILABLE" }
|
|
176
|
-
try {
|
|
177
|
-
for (const pattern of patterns) {
|
|
178
|
-
if (pattern === null || typeof pattern !== "object" || Array.isArray(pattern)) return { ok: false, code: "SANITIZATION_UNAVAILABLE" }
|
|
179
|
-
const prototype = Object.getPrototypeOf(pattern)
|
|
180
|
-
if (prototype !== Object.prototype && prototype !== null) return { ok: false, code: "SANITIZATION_UNAVAILABLE" }
|
|
181
|
-
const names = Object.getOwnPropertyNames(pattern)
|
|
182
|
-
if (Object.getOwnPropertySymbols(pattern).length !== 0 || names.some((name) => !["text", "replacement", "entry", "representations"].includes(name))) return { ok: false, code: "SANITIZATION_UNAVAILABLE" }
|
|
183
|
-
for (const name of names) {
|
|
184
|
-
const descriptor = Object.getOwnPropertyDescriptor(pattern, name)
|
|
185
|
-
if (descriptor === undefined || !("value" in descriptor)) return { ok: false, code: "SANITIZATION_UNAVAILABLE" }
|
|
186
|
-
}
|
|
187
|
-
const representations = patternRepresentations(pattern)
|
|
188
|
-
if (representations === null) return { ok: false, code: "SANITIZATION_UNAVAILABLE" }
|
|
189
|
-
for (const representation of representations) {
|
|
190
|
-
if (representation.length === 0 || uuidLanguageContains(representation) || RESERVED_OUTPUT_INVENTORY.some((item) => item.includes(representation))) {
|
|
191
|
-
return { ok: false, code: "SANITIZATION_UNAVAILABLE" }
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
return { ok: true }
|
|
196
|
-
} catch { return { ok: false, code: "SANITIZATION_UNAVAILABLE" } } finally { reservation.release() }
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
function normalizedBudget(budget: Budget): Required<Budget> | null {
|
|
200
|
-
try {
|
|
201
|
-
if (budget === null || typeof budget !== "object" || Array.isArray(budget)) return null
|
|
202
|
-
const prototype = Object.getPrototypeOf(budget)
|
|
203
|
-
if (prototype !== Object.prototype && prototype !== null) return null
|
|
204
|
-
const names = Object.getOwnPropertyNames(budget)
|
|
205
|
-
if (Object.getOwnPropertySymbols(budget).length !== 0 || names.some((name) => !["maxOutputBytes", "maxDepth", "maxNodes", "maxMatches"].includes(name))) return null
|
|
206
|
-
const data: Record<string, unknown> = {}
|
|
207
|
-
for (const name of names) {
|
|
208
|
-
const descriptor = Object.getOwnPropertyDescriptor(budget, name)
|
|
209
|
-
if (descriptor === undefined || !("value" in descriptor)) return null
|
|
210
|
-
data[name] = descriptor.value
|
|
211
|
-
}
|
|
212
|
-
const values = {
|
|
213
|
-
maxOutputBytes: (data.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES) as number,
|
|
214
|
-
maxDepth: (data.maxDepth ?? DEFAULT_MAX_DEPTH) as number,
|
|
215
|
-
maxNodes: (data.maxNodes ?? DEFAULT_MAX_NODES) as number,
|
|
216
|
-
maxMatches: (data.maxMatches ?? DEFAULT_MAX_MATCHES) as number,
|
|
217
|
-
}
|
|
218
|
-
const valid =
|
|
219
|
-
typeof values.maxOutputBytes === "number" && Number.isSafeInteger(values.maxOutputBytes) && values.maxOutputBytes >= 0 && values.maxOutputBytes <= MAX_OUTPUT_BYTES &&
|
|
220
|
-
typeof values.maxDepth === "number" && Number.isSafeInteger(values.maxDepth) && values.maxDepth >= 0 && values.maxDepth <= MAX_DEPTH &&
|
|
221
|
-
typeof values.maxNodes === "number" && Number.isSafeInteger(values.maxNodes) && values.maxNodes >= 0 && values.maxNodes <= MAX_NODES &&
|
|
222
|
-
typeof values.maxMatches === "number" && Number.isSafeInteger(values.maxMatches) && values.maxMatches >= 0 && values.maxMatches <= MAX_MATCHES
|
|
223
|
-
return valid ? values as Required<Budget> : null
|
|
224
|
-
} catch { return null }
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
function collectIntervalsBounded(input: string, patterns: readonly (Pattern | string)[], budget: Required<Budget>): Interval[] | null {
|
|
228
|
-
if (typeof input !== "string" || !Array.isArray(patterns) || patterns.length > MAX_PATTERN_COUNT) return null
|
|
229
|
-
const found: Array<{ start: number; end: number; pattern: string }> = []
|
|
230
|
-
let matchCount = 0
|
|
231
|
-
for (const item of patterns) {
|
|
232
|
-
const texts = typeof item === "string" ? [item] : patternRepresentations(item)
|
|
233
|
-
if (texts === null) return null
|
|
234
|
-
for (const text of texts) {
|
|
235
|
-
if (text.length === 0) continue
|
|
236
|
-
let at = input.indexOf(text)
|
|
237
|
-
while (at !== -1) {
|
|
238
|
-
matchCount++
|
|
239
|
-
if (matchCount > budget.maxMatches) return null
|
|
240
|
-
found.push({ start: at, end: at + text.length, pattern: text })
|
|
241
|
-
at = input.indexOf(text, at + 1)
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
found.sort((a, b) => a.start - b.start || a.end - b.end)
|
|
246
|
-
const result: Interval[] = []
|
|
247
|
-
for (const match of found) {
|
|
248
|
-
const previous = result[result.length - 1]
|
|
249
|
-
if (previous !== undefined && match.start <= previous.end) {
|
|
250
|
-
previous.end = Math.max(previous.end, match.end)
|
|
251
|
-
if (!previous.patterns.includes(match.pattern)) previous.patterns.push(match.pattern)
|
|
252
|
-
} else {
|
|
253
|
-
result.push({ start: match.start, end: match.end, patterns: [match.pattern] })
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
return result
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
export function collectIntervals(input: string, patterns: readonly (Pattern | string)[], budget: Budget = {}): Interval[] {
|
|
260
|
-
const reservation = typeof input === "string" ? reserveOperation(patterns, encoder.encode(input).byteLength) : null
|
|
261
|
-
if (reservation === null) return []
|
|
262
|
-
try {
|
|
263
|
-
if (typeof input !== "string") return []
|
|
264
|
-
const normalized = normalizedBudget(budget)
|
|
265
|
-
return normalized === null ? [] : (collectIntervalsBounded(input, patterns, normalized) ?? [])
|
|
266
|
-
} catch { return [] } finally { reservation.release() }
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
function withinBytes(value: string, budget: Required<Budget>): boolean {
|
|
270
|
-
return encoder.encode(value).byteLength <= budget.maxOutputBytes
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
function bytes(value: string): number {
|
|
274
|
-
return encoder.encode(value).byteLength
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
/** One-pass replacement followed by a final boundary scan; null means safely withheld. */
|
|
278
|
-
export function sanitizeText(input: string, patterns: readonly Pattern[], budget: Budget = {}): string | null {
|
|
279
|
-
const reservation = typeof input === "string" ? reserveOperation(patterns, encoder.encode(input).byteLength) : null
|
|
280
|
-
if (reservation === null) return null
|
|
281
|
-
try {
|
|
282
|
-
if (typeof input !== "string") return null
|
|
283
|
-
const normalized = normalizedBudget(budget)
|
|
284
|
-
if (normalized === null || !withinBytes(input, normalized)) return null
|
|
285
|
-
const intervals = collectIntervalsBounded(input, patterns, normalized)
|
|
286
|
-
if (intervals === null) return null
|
|
287
|
-
let output = ""
|
|
288
|
-
let cursor = 0
|
|
289
|
-
for (const interval of intervals) {
|
|
290
|
-
const prefix = input.slice(cursor, interval.start)
|
|
291
|
-
const matches = patterns.filter((pattern) => {
|
|
292
|
-
const representations = patternRepresentations(pattern)
|
|
293
|
-
return representations !== null && representations.some((text) => interval.patterns.includes(text))
|
|
294
|
-
})
|
|
295
|
-
const entries = new Set(matches.map((pattern) => pattern.entry ?? pattern.text))
|
|
296
|
-
const replacement = entries.size === 1 && matches[0]?.replacement !== undefined ? matches[0].replacement : REDACTION
|
|
297
|
-
if (bytes(output) + bytes(prefix) + bytes(replacement) > normalized.maxOutputBytes) return null
|
|
298
|
-
const next = output + prefix + replacement
|
|
299
|
-
if (!withinBytes(next, normalized)) return null
|
|
300
|
-
output = next
|
|
301
|
-
cursor = interval.end
|
|
302
|
-
}
|
|
303
|
-
const suffix = input.slice(cursor)
|
|
304
|
-
if (bytes(output) + bytes(suffix) > normalized.maxOutputBytes) return null
|
|
305
|
-
const final = output + suffix
|
|
306
|
-
if (!withinBytes(final, normalized) || collectIntervalsBounded(final, patterns, normalized)?.length !== 0) return null
|
|
307
|
-
return final
|
|
308
|
-
} catch { return null } finally { reservation.release() }
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
export type SecretScan = "clean" | "match" | "unverifiable"
|
|
312
|
-
|
|
313
|
-
/**
|
|
314
|
-
* Bounded containment scan that materializes no output: `"clean"` means every
|
|
315
|
-
* representation was verified absent, `"match"` means at least one occurrence
|
|
316
|
-
* exists (including when the match budget is exhausted), and `"unverifiable"`
|
|
317
|
-
* means the scan could not run to completion (oversize input, unadmitted
|
|
318
|
-
* patterns, exhausted reservations). Fail-closed callers must treat
|
|
319
|
-
* `"unverifiable"` exactly like `"match"`.
|
|
320
|
-
*/
|
|
321
|
-
export function scanForSecrets(input: string, patterns: readonly Pattern[], budget: Budget = {}): SecretScan {
|
|
322
|
-
try {
|
|
323
|
-
// UTF-8 bytes are always >= UTF-16 units; bound the encoding work first.
|
|
324
|
-
if (typeof input !== "string" || input.length > MAX_TEMP_BYTES) return "unverifiable"
|
|
325
|
-
const reservation = reserveOperation(patterns, encoder.encode(input).byteLength)
|
|
326
|
-
if (reservation === null) return "unverifiable"
|
|
327
|
-
try {
|
|
328
|
-
const normalized = normalizedBudget(budget)
|
|
329
|
-
if (normalized === null || !Array.isArray(patterns) || patterns.length > MAX_PATTERN_COUNT) return "unverifiable"
|
|
330
|
-
for (const item of patterns) {
|
|
331
|
-
const texts = patternRepresentations(item as Pattern)
|
|
332
|
-
if (texts === null) return "unverifiable"
|
|
333
|
-
for (const text of texts) {
|
|
334
|
-
// One occurrence decides the verdict; no matches are accumulated.
|
|
335
|
-
if (text.length > 0 && input.indexOf(text) !== -1) return "match"
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
return "clean"
|
|
339
|
-
} finally {
|
|
340
|
-
reservation.release()
|
|
341
|
-
}
|
|
342
|
-
} catch {
|
|
343
|
-
return "unverifiable"
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
/**
|
|
348
|
-
* Largest input (bytes) a scan can verify for this pattern set: the operation
|
|
349
|
-
* ceiling (`MAX_TEMP_BYTES`) minus the admitted pattern bytes. Invalid pattern
|
|
350
|
-
* sets collapse to 0 so every non-empty body is withheld rather than passed.
|
|
351
|
-
*/
|
|
352
|
-
export function verifyCeilingBytes(patterns: readonly Pattern[]): number {
|
|
353
|
-
try {
|
|
354
|
-
if (!Array.isArray(patterns)) return 0
|
|
355
|
-
let patternBytes = 0
|
|
356
|
-
for (const item of patterns) {
|
|
357
|
-
if (item === null || typeof item !== "object" || typeof (item as Pattern).text !== "string") return 0
|
|
358
|
-
patternBytes += encoder.encode((item as Pattern).text).byteLength
|
|
359
|
-
if (patternBytes > MAX_PATTERN_BYTES) return 0
|
|
360
|
-
}
|
|
361
|
-
return MAX_TEMP_BYTES - patternBytes
|
|
362
|
-
} catch {
|
|
363
|
-
return 0
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
export interface TextSink { readonly content: readonly [{ readonly type: "text"; readonly text: string }] }
|
|
368
|
-
|
|
369
|
-
export function canonicalTextSink(parts: readonly string[], patterns: readonly Pattern[], budget: Budget = {}): TextSink | null {
|
|
370
|
-
let reservation: Reservation | null = null
|
|
371
|
-
try {
|
|
372
|
-
if (!Array.isArray(parts)) return null
|
|
373
|
-
let inputBytes = 0
|
|
374
|
-
for (const part of parts) {
|
|
375
|
-
if (typeof part !== "string") return null
|
|
376
|
-
inputBytes += encoder.encode(part).byteLength
|
|
377
|
-
}
|
|
378
|
-
reservation = reserveOperation(patterns, inputBytes)
|
|
379
|
-
if (reservation === null) return null
|
|
380
|
-
const normalized = normalizedBudget(budget)
|
|
381
|
-
if (normalized === null) return null
|
|
382
|
-
let joined = ""
|
|
383
|
-
for (let i = 0; i < parts.length; i++) {
|
|
384
|
-
const part = parts[i]
|
|
385
|
-
if (typeof part !== "string") return null
|
|
386
|
-
const separatorBytes = i > 0 ? bytes("\n") : 0
|
|
387
|
-
if (bytes(joined) + separatorBytes + bytes(part) > normalized.maxOutputBytes) return null
|
|
388
|
-
if (i > 0) joined += "\n"
|
|
389
|
-
joined += part
|
|
390
|
-
}
|
|
391
|
-
const text = sanitizeText(joined, patterns, budget)
|
|
392
|
-
return text === null ? null : { content: [{ type: "text", text }] }
|
|
393
|
-
} catch { return null } finally { reservation?.release() }
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
397
|
-
if (value === null || typeof value !== "object" || Array.isArray(value)) return false
|
|
398
|
-
const prototype = Object.getPrototypeOf(value)
|
|
399
|
-
return prototype === Object.prototype || prototype === null
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
/** Canonical JSON-like projection for plain data; accessors, arrays and non-finite numbers are withheld. */
|
|
403
|
-
export function projectPlain(value: unknown, patterns: readonly Pattern[], budget: Budget = {}): string | null {
|
|
404
|
-
const reservation = reserveOperation(patterns, 0)
|
|
405
|
-
if (reservation === null) return null
|
|
406
|
-
try {
|
|
407
|
-
const normalized = normalizedBudget(budget)
|
|
408
|
-
if (normalized === null) return null
|
|
409
|
-
let nodes = 0
|
|
410
|
-
const active = new Set<object>()
|
|
411
|
-
const visit = (current: unknown, depth: number): string | null => {
|
|
412
|
-
nodes++
|
|
413
|
-
if (nodes > normalized.maxNodes || depth > normalized.maxDepth) return null
|
|
414
|
-
if (current === null) return "null"
|
|
415
|
-
if (typeof current === "string") {
|
|
416
|
-
const safe = sanitizeText(current, patterns, normalized)
|
|
417
|
-
return safe === null ? null : JSON.stringify(safe)
|
|
418
|
-
}
|
|
419
|
-
if (typeof current === "boolean") return current ? "true" : "false"
|
|
420
|
-
if (typeof current === "number") return Number.isFinite(current) ? String(current) : null
|
|
421
|
-
if (!isPlainObject(current)) return null
|
|
422
|
-
if (active.has(current)) return null
|
|
423
|
-
active.add(current)
|
|
424
|
-
const keys: string[] = []
|
|
425
|
-
// Do not materialize/sort an unbounded own-key array. The extra key is a
|
|
426
|
-
// bounded rejection probe; maxNodes also bounds the useful key set.
|
|
427
|
-
for (const key in current) {
|
|
428
|
-
if (!Object.prototype.propertyIsEnumerable.call(current, key)) continue
|
|
429
|
-
keys.push(key)
|
|
430
|
-
if (keys.length > normalized.maxNodes || keys.length > MAX_OBJECT_KEYS) return null
|
|
431
|
-
}
|
|
432
|
-
keys.sort()
|
|
433
|
-
const chunks: string[] = ["{"]
|
|
434
|
-
for (let i = 0; i < keys.length; i++) {
|
|
435
|
-
const key = keys[i]
|
|
436
|
-
if (key === undefined || collectIntervalsBounded(key, patterns, normalized)?.length !== 0) return null
|
|
437
|
-
const descriptor = Object.getOwnPropertyDescriptor(current, key)
|
|
438
|
-
if (descriptor === undefined || !("value" in descriptor)) return null
|
|
439
|
-
const child = visit(descriptor.value, depth + 1)
|
|
440
|
-
if (child === null) return null
|
|
441
|
-
if (i > 0) chunks.push(",")
|
|
442
|
-
chunks.push(JSON.stringify(key), ":", child)
|
|
443
|
-
if (!withinBytes(chunks.join(""), normalized)) return null
|
|
444
|
-
}
|
|
445
|
-
chunks.push("}")
|
|
446
|
-
const result = chunks.join("")
|
|
447
|
-
active.delete(current)
|
|
448
|
-
return withinBytes(result, normalized) ? result : null
|
|
449
|
-
}
|
|
450
|
-
const result = visit(value, 0)
|
|
451
|
-
if (result === null || collectIntervalsBounded(result, patterns, normalized)?.length !== 0 || !withinBytes(result, normalized)) return null
|
|
452
|
-
return result
|
|
453
|
-
} catch { return null } finally { reservation.release() }
|
|
454
|
-
}
|
package/src/sources/README.md
DELETED
package/src/sources/dotenv.ts
DELETED
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
import * as fs from "node:fs"
|
|
2
|
-
import * as path from "node:path"
|
|
3
|
-
import * as os from "node:os"
|
|
4
|
-
|
|
5
|
-
export type DotenvEntry = Readonly<{ key: string; value: string; lexicalValue: string }>
|
|
6
|
-
export type DotenvSnapshot = Readonly<{ entries: readonly DotenvEntry[]; bytes: number }>
|
|
7
|
-
export class SourceParseError extends Error { readonly code = "SOURCE_INVALID"; constructor() { super("SOURCE_INVALID"); this.name = "SourceParseError" } }
|
|
8
|
-
|
|
9
|
-
export function parseDotenv(source: string, options: { maxBytes?: number; maxEntries?: number; maxValueBytes?: number } = {}): DotenvSnapshot {
|
|
10
|
-
try {
|
|
11
|
-
const limits = normalizeOptions(options)
|
|
12
|
-
if (typeof source !== "string") throw new SourceParseError()
|
|
13
|
-
const bytes = Buffer.byteLength(source, "utf8")
|
|
14
|
-
if (bytes > limits.maxBytes) throw new SourceParseError()
|
|
15
|
-
const entries: DotenvEntry[] = []; const names = new Set<string>(); let index = 0
|
|
16
|
-
while (index < source.length) {
|
|
17
|
-
const lineStart = index; const end = source.indexOf("\n", index); const lineEnd = end < 0 ? source.length : end; let line = source.slice(index, lineEnd)
|
|
18
|
-
if (line.endsWith("\r")) line = line.slice(0, -1); index = end < 0 ? source.length : end + 1
|
|
19
|
-
const trimmed = line.trim(); if (trimmed === "" || trimmed.startsWith("#")) continue
|
|
20
|
-
const assignment = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*/.exec(line)
|
|
21
|
-
if (!assignment) throw new SourceParseError()
|
|
22
|
-
const key = assignment[1] as string; if (names.has(key)) throw new SourceParseError(); names.add(key)
|
|
23
|
-
let rest = line.slice(assignment[0].length); let lexical = rest; let value: string
|
|
24
|
-
if (rest.startsWith("'") || rest.startsWith('"')) {
|
|
25
|
-
const quote = rest[0] as string; let content = rest.slice(1); let close = quotedClose(content, quote)
|
|
26
|
-
while (close < 0) { if (index >= source.length) throw new SourceParseError(); const nextEnd = source.indexOf("\n", index); const nextLineEnd = nextEnd < 0 ? source.length : nextEnd; let next = source.slice(index, nextLineEnd); if (next.endsWith("\r")) next = next.slice(0, -1); content += "\n" + next; index = nextEnd < 0 ? source.length : nextEnd + 1; close = quotedClose(content, quote) }
|
|
27
|
-
const body = content.slice(0, close); const after = content.slice(close + 1).trim(); if (after !== "" && !after.startsWith("#")) throw new SourceParseError()
|
|
28
|
-
value = quote === '"' ? decodeDouble(body) : body; lexical = quote + body + quote
|
|
29
|
-
} else { const comment = rest.indexOf("#"); if (comment >= 0) rest = rest.slice(0, comment); value = rest.trimEnd(); lexical = value }
|
|
30
|
-
if (Buffer.byteLength(value, "utf8") > limits.maxValueBytes || entries.length >= limits.maxEntries) throw new SourceParseError()
|
|
31
|
-
entries.push(Object.freeze({ key, value, lexicalValue: lexical }))
|
|
32
|
-
void lineStart
|
|
33
|
-
}
|
|
34
|
-
return Object.freeze({ entries: Object.freeze(entries), bytes })
|
|
35
|
-
} catch (error) { if (error instanceof SourceParseError) throw error; throw new SourceParseError() }
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function normalizeOptions(options: unknown): { maxBytes: number; maxEntries: number; maxValueBytes: number } {
|
|
39
|
-
if (options === null || typeof options !== "object" || Array.isArray(options)) throw new SourceParseError()
|
|
40
|
-
const prototype = Object.getPrototypeOf(options)
|
|
41
|
-
if (prototype !== Object.prototype && prototype !== null) throw new SourceParseError()
|
|
42
|
-
const names = Object.getOwnPropertyNames(options)
|
|
43
|
-
if (Object.getOwnPropertySymbols(options).length !== 0 || names.some(name => !["maxBytes", "maxEntries", "maxValueBytes"].includes(name))) throw new SourceParseError()
|
|
44
|
-
const values: Record<string, unknown> = {}
|
|
45
|
-
for (const name of names) {
|
|
46
|
-
const descriptor = Object.getOwnPropertyDescriptor(options, name)
|
|
47
|
-
if (!descriptor || !("value" in descriptor)) throw new SourceParseError()
|
|
48
|
-
values[name] = descriptor.value
|
|
49
|
-
}
|
|
50
|
-
const result: { maxBytes: number; maxEntries: number; maxValueBytes: number } = {
|
|
51
|
-
maxBytes: values.maxBytes === undefined ? 1_048_576 : values.maxBytes as number,
|
|
52
|
-
maxEntries: values.maxEntries === undefined ? 1_000 : values.maxEntries as number,
|
|
53
|
-
maxValueBytes: values.maxValueBytes === undefined ? 16_384 : values.maxValueBytes as number,
|
|
54
|
-
}
|
|
55
|
-
if (![result.maxBytes, result.maxEntries, result.maxValueBytes].every(value => typeof value === "number" && Number.isSafeInteger(value) && value > 0)) throw new SourceParseError()
|
|
56
|
-
return result
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function decodeDouble(value: string): string {
|
|
60
|
-
return value.replace(/\\([\\"nrt])/g, (_match, escaped: string) => ({ "\\": "\\", '"': '"', n: "\n", r: "\r", t: "\t" })[escaped] ?? escaped)
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function quotedClose(value: string, quote: string): number {
|
|
64
|
-
for (let i = 0; i < value.length; i++) {
|
|
65
|
-
if (value[i] === quote) return i
|
|
66
|
-
if (value[i] !== "\\") continue
|
|
67
|
-
const escaped = value[++i]
|
|
68
|
-
if (escaped === undefined) throw new SourceParseError()
|
|
69
|
-
const valid = quote === '"' ? '\\"nrt'.includes(escaped) : escaped === quote || escaped === "\\"
|
|
70
|
-
if (!valid) throw new SourceParseError()
|
|
71
|
-
}
|
|
72
|
-
return -1
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** Load a dotenv file from the filesystem, returning a parsed snapshot.
|
|
76
|
-
*
|
|
77
|
-
* Fail-closed: returns `SourceParseError` if the path is inaccessible, the
|
|
78
|
-
* file cannot be read, the content is not a valid dotnet snapshot, or the
|
|
79
|
-
* options violate limits. No external state or credentials are exposed in
|
|
80
|
-
* error messages.
|
|
81
|
-
*
|
|
82
|
-
* The caller is responsible for ensuring the path is authorized; this function
|
|
83
|
-
* does not perform ACL checks itself and assumes the caller has permission to
|
|
84
|
-
* read the nominated location.
|
|
85
|
-
*/
|
|
86
|
-
export function loadDotenvFile(
|
|
87
|
-
filePath: string,
|
|
88
|
-
options: { maxBytes?: number; maxEntries?: number; maxValueBytes?: number } = {}
|
|
89
|
-
): DotenvSnapshot {
|
|
90
|
-
try {
|
|
91
|
-
const resolved = path.resolve(filePath)
|
|
92
|
-
const stat = fs.lstatSync(resolved)
|
|
93
|
-
if (!stat.isFile() || stat.isSymbolicLink()) throw new SourceParseError()
|
|
94
|
-
const noFollow = (fs.constants as { O_NOFOLLOW?: number }).O_NOFOLLOW ?? 0
|
|
95
|
-
const descriptor = fs.openSync(resolved, fs.constants.O_RDONLY | noFollow)
|
|
96
|
-
try {
|
|
97
|
-
const opened = fs.fstatSync(descriptor)
|
|
98
|
-
if (!opened.isFile()) throw new SourceParseError()
|
|
99
|
-
const raw = fs.readFileSync(descriptor, "utf8")
|
|
100
|
-
return parseDotenv(raw, options)
|
|
101
|
-
} finally {
|
|
102
|
-
fs.closeSync(descriptor)
|
|
103
|
-
}
|
|
104
|
-
} catch (e) {
|
|
105
|
-
throw new SourceParseError()
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/** Load dotenv from the process's resolved configuration directory.
|
|
110
|
-
*
|
|
111
|
-
* Attempts `~/.opencode-dotenv/.env`, `~/.env`, and `./.env` in order, returning
|
|
112
|
-
* the first successfully parsed snapshot. Returns `SourceParseError` if no
|
|
113
|
-
* file is found or any file is unparseable.
|
|
114
|
-
*/
|
|
115
|
-
export function loadDefaultDotenv(
|
|
116
|
-
options: { maxBytes?: number; maxEntries?: number; maxValueBytes?: number } = {}
|
|
117
|
-
): DotenvSnapshot | SourceParseError {
|
|
118
|
-
const home = os.homedir()
|
|
119
|
-
const candidates: string[] = [
|
|
120
|
-
path.resolve(home, ".opencode-dotenv", ".env"),
|
|
121
|
-
path.resolve(home, ".env"),
|
|
122
|
-
path.resolve(process.cwd(), ".env"),
|
|
123
|
-
]
|
|
124
|
-
for (const p of candidates) {
|
|
125
|
-
try {
|
|
126
|
-
const raw = fs.readFileSync(p, "utf8")
|
|
127
|
-
return parseDotenv(raw, options)
|
|
128
|
-
} catch { /* try next candidate */ }
|
|
129
|
-
}
|
|
130
|
-
return new SourceParseError()
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
export default { parseDotenv, loadDotenvFile, loadDefaultDotenv }
|
package/src/store/index.ts
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
import { randomBytes as systemRandomBytes } from "node:crypto"
|
|
2
|
-
import { BudgetLedger, type BudgetValues } from "../budgets/index.ts"
|
|
3
|
-
|
|
4
|
-
export interface StoreOptions {
|
|
5
|
-
readonly location: { readonly directory: string }
|
|
6
|
-
readonly generation: number
|
|
7
|
-
readonly randomBytes?: () => Uint8Array
|
|
8
|
-
readonly budget?: BudgetLedger
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
type Reservation = { dispose(): void }
|
|
12
|
-
|
|
13
|
-
/** Private in-memory value → opaque-handle map. It has no authorization or I/O behavior. */
|
|
14
|
-
export class ActivationStore {
|
|
15
|
-
private readonly entries = new Map<string, Reservation>()
|
|
16
|
-
private readonly redactions = new Map<string, string>()
|
|
17
|
-
private readonly budget: BudgetLedger
|
|
18
|
-
private readonly random: () => Uint8Array
|
|
19
|
-
private active = true
|
|
20
|
-
|
|
21
|
-
constructor(options: StoreOptions) {
|
|
22
|
-
if (!options || typeof options.location?.directory !== "string" || !Number.isInteger(options.generation) || options.generation < 0) throw new Error("invalid")
|
|
23
|
-
this.budget = options.budget ?? new BudgetLedger({ activeSessions: 1, handles: 65_536, handlesPerSession: 1, secretCount: 65_536, secretBytes: 128 * 1024 * 1024, indexBytes: 128 * 1024 * 1024, workingBytes: 128 * 1024 * 1024 })
|
|
24
|
-
this.random = options.randomBytes ?? (() => new Uint8Array(systemRandomBytes(16)))
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
register(value: string): string {
|
|
28
|
-
if (!this.active || typeof value !== "string" || value.length === 0) throw new Error("denied")
|
|
29
|
-
const existing = this.redactions.get(value)
|
|
30
|
-
if (existing !== undefined) return existing
|
|
31
|
-
const reservation = this.reserve({ handles: 1, secretCount: 1, secretBytes: Buffer.byteLength(value), indexBytes: Buffer.byteLength(value), workingBytes: Buffer.byteLength(value) })
|
|
32
|
-
if (reservation === undefined) throw new Error("denied")
|
|
33
|
-
const handle = this.newHandle()
|
|
34
|
-
this.entries.set(handle, reservation)
|
|
35
|
-
this.redactions.set(value, handle)
|
|
36
|
-
return handle
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
teardown(): void {
|
|
40
|
-
this.active = false
|
|
41
|
-
for (const reservation of this.entries.values()) reservation.dispose()
|
|
42
|
-
this.entries.clear()
|
|
43
|
-
this.redactions.clear()
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
private reserve(request: BudgetValues): Reservation | undefined {
|
|
47
|
-
const result = this.budget.reserve(request)
|
|
48
|
-
return result.ok ? result.value : undefined
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
private newHandle(): string {
|
|
52
|
-
for (let attempt = 0; attempt < 32; attempt += 1) {
|
|
53
|
-
const bytes = this.random()
|
|
54
|
-
if (!(bytes instanceof Uint8Array) || bytes.length !== 16) throw new Error("denied")
|
|
55
|
-
const copy = new Uint8Array(bytes)
|
|
56
|
-
copy[6] = (copy[6]! & 0x0f) | 0x40
|
|
57
|
-
copy[8] = (copy[8]! & 0x3f) | 0x80
|
|
58
|
-
const hex = Buffer.from(copy).toString("hex")
|
|
59
|
-
const handle = `secret-${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
|
60
|
-
if (!this.entries.has(handle)) return handle
|
|
61
|
-
}
|
|
62
|
-
throw new Error("denied")
|
|
63
|
-
}
|
|
64
|
-
}
|