1import * as Redacted from "effect/Redacted";
2import * as Stream from "effect/Stream";
3import { BadArgument, SystemError } from "effect/PlatformError";
4import type { CommandProps } from "./Command.ts";
5
6const DEFAULT_REDACTION_MARKER = "[REDACTED]";
7
8export interface CommandRedactor {
9 readonly redact: (value: string) => string;
10 readonly stream: <E, R>(
11 stream: Stream.Stream<string, E, R>,
12 ) => Stream.Stream<string, E, R>;
13}
14
15const redactedValues = (env: CommandProps["env"]): ReadonlyArray<string> =>
16 [
17 ...new Set(
18 Object.values(env ?? {})
19 .filter((value): value is Redacted.Redacted<string> =>
20 Redacted.isRedacted(value),
21 )
22 .map(Redacted.value)
23 .filter((value) => value.length > 0),
24 ),
25 ].sort((left, right) => right.length - left.length);
26
27const redactionMarker = (secrets: ReadonlyArray<string>): string => {
28 if (!secrets.some((secret) => DEFAULT_REDACTION_MARKER.includes(secret))) {
29 return DEFAULT_REDACTION_MARKER;
30 }
31
32 // The usual marker can itself be a secret (or contain a very short secret).
33 // Pick a single private-use character absent from every secret so replacing
34 // a value can never reproduce that exact value in output.
35 for (let codePoint = 0xe000; codePoint <= 0xf8ff; codePoint++) {
36 const candidate = String.fromCodePoint(codePoint);
37 if (secrets.every((secret) => !secret.includes(candidate))) {
38 return candidate;
39 }
40 }
41
42 // An env value containing the entire private-use block is pathological, but
43 // a supplementary private-use code point still gives us a safe marker.
44 for (let codePoint = 0xf0000; codePoint <= 0xffffd; codePoint++) {
45 const candidate = String.fromCodePoint(codePoint);
46 if (secrets.every((secret) => !secret.includes(candidate))) {
47 return candidate;
48 }
49 }
50
51 // This requires a secret larger than the full private-use character space.
52 // Keeping an empty replacement is safer than reproducing any part of it.
53 return "";
54};
55
56const redact = (
57 value: string,
58 secrets: ReadonlyArray<string>,
59 marker: string,
60): string =>
61 secrets.reduce((safe, secret) => safe.split(secret).join(marker), value);
62
63/**
64 * Redact a text stream without leaking secrets split across chunk boundaries.
65 * The pending suffix is always the longest suffix that could still grow into
66 * one of the configured secrets. Complete matches are replaced before any
67 * text is emitted.
68 */
69const redactStream = <E, R>(
70 stream: Stream.Stream<string, E, R>,
71 secrets: ReadonlyArray<string>,
72 marker: string,
73): Stream.Stream<string, E, R> => {
74 if (secrets.length === 0) return stream;
75
76 return stream.pipe(
77 Stream.mapAccum(
78 () => "",
79 (pending, chunk) => {
80 let remaining = pending + chunk;
81 let output = "";
82
83 while (remaining.length > 0) {
84 let matchIndex = -1;
85 let match: string | undefined;
86 for (const secret of secrets) {
87 const index = remaining.indexOf(secret);
88 if (
89 index >= 0 &&
90 (matchIndex < 0 ||
91 index < matchIndex ||
92 (index === matchIndex && secret.length > (match?.length ?? 0)))
93 ) {
94 matchIndex = index;
95 match = secret;
96 }
97 }
98
99 if (match !== undefined) {
100 output += remaining.slice(0, matchIndex) + marker;
101 remaining = remaining.slice(matchIndex + match.length);
102 continue;
103 }
104
105 let suffixLength = 0;
106 for (const secret of secrets) {
107 const candidateLength = Math.min(
108 secret.length - 1,
109 remaining.length,
110 );
111 for (
112 let length = candidateLength;
113 length > suffixLength;
114 length--
115 ) {
116 if (secret.startsWith(remaining.slice(-length))) {
117 suffixLength = length;
118 break;
119 }
120 }
121 }
122
123 output += remaining.slice(0, remaining.length - suffixLength);
124 remaining = remaining.slice(remaining.length - suffixLength);
125 break;
126 }
127
128 return [remaining, output.length === 0 ? [] : [output]] as const;
129 },
130 {
131 onHalt: (pending) =>
132 pending.length === 0 ? [] : [redact(pending, secrets, marker)],
133 },
134 ),
135 );
136};
137
138export const makeCommandRedactor = (
139 env: CommandProps["env"],
140): CommandRedactor => {
141 const secrets = redactedValues(env);
142 const marker = redactionMarker(secrets);
143 return {
144 redact: (value) => redact(value, secrets, marker),
145 stream: (stream) => redactStream(stream, secrets, marker),
146 };
147};
148
149export const redactPlatformReason = (
150 reason: BadArgument | SystemError,
151 redactor: CommandRedactor,
152): BadArgument | SystemError => {
153 if (reason instanceof BadArgument) {
154 return new BadArgument({
155 module: redactor.redact(reason.module),
156 method: redactor.redact(reason.method),
157 description:
158 reason.description === undefined
159 ? undefined
160 : redactor.redact(reason.description),
161 });
162 }
163
164 return new SystemError({
165 _tag: reason._tag,
166 module: redactor.redact(reason.module),
167 method: redactor.redact(reason.method),
168 description:
169 reason.description === undefined
170 ? undefined
171 : redactor.redact(reason.description),
172 syscall:
173 reason.syscall === undefined
174 ? undefined
175 : redactor.redact(reason.syscall),
176 pathOrDescriptor:
177 typeof reason.pathOrDescriptor === "string"
178 ? redactor.redact(reason.pathOrDescriptor)
179 : reason.pathOrDescriptor,
180 });
181};