Skip to content

Commit 4df6039

Browse files
committed
fix(secrets): don't double-consume keychain CLI streams (Buffer.concat crash)
macos/linux/windows runners attached .setEncoding('utf8') + .on('data') to streams the lib spawn was concurrently buffering as Buffers, so its Buffer.concat-on-close threw 'TypeError: list[0] must be a Buffer' when a chunk arrived as a string (the security/secret-tool/PowerShell error output triggered it on macos). Await the lib spawn's result instead (stdioString → string stdout/stderr; reject on non-zero carries {code,stdout,stderr}); write secrets to stdin via the returned process. Fixes the macos secrets/keychain suite (was 8/20) + the latent linux + windows variants.
1 parent d9f2720 commit 4df6039

3 files changed

Lines changed: 110 additions & 104 deletions

File tree

src/secrets/linux.ts

Lines changed: 43 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -59,27 +59,22 @@ export async function readLinux(
5959
service: string,
6060
account: string,
6161
): Promise<string | undefined> {
62-
return new PromiseCtor(resolve => {
63-
const { process: cp } = spawn(
62+
// Let the lib spawn own output buffering (stdioString → string stdout). Don't
63+
// attach `.setEncoding('utf8')` + `.on('data')` — that flips the stream to
64+
// string chunks while the lib buffers it as Buffers + does Buffer.concat on
65+
// close, throwing `TypeError: list[0] must be a Buffer`. The lib rejects on a
66+
// non-zero exit (a missing entry), which is just "undefined" here.
67+
try {
68+
const r = await spawn(
6469
SECRET_TOOL_BIN,
6570
['lookup', 'service', service, 'user', account],
66-
{ stdio: ['ignore', 'pipe', 'pipe'] },
71+
{ stdio: ['ignore', 'pipe', 'pipe'], stdioString: true },
6772
)
68-
let stdout = ''
69-
cp.stdout!.setEncoding('utf8')
70-
cp.stdout!.on('data', chunk => {
71-
stdout += chunk
72-
})
73-
cp.on('error', () => resolve(undefined))
74-
cp.on('close', status => {
75-
if (status !== 0) {
76-
resolve(undefined)
77-
return
78-
}
79-
const out = stdout.trim()
80-
resolve(out || undefined)
81-
})
82-
})
73+
const out = String(r.stdout ?? '').trim()
74+
return out || undefined
75+
} catch {
76+
return undefined
77+
}
8378
}
8479

8580
export function readLinuxSync(
@@ -104,43 +99,37 @@ export async function writeLinux(
10499
value: string,
105100
label: string,
106101
): Promise<void> {
107-
return new PromiseCtor((resolve, reject) => {
108-
const { process: cp } = spawn(
109-
SECRET_TOOL_BIN,
110-
['store', `--label=${label}`, 'service', service, 'user', account],
111-
{ stdio: ['pipe', 'pipe', 'pipe'] },
112-
)
113-
let stderr = ''
114-
cp.stderr!.setEncoding('utf8')
115-
cp.stderr!.on('data', chunk => {
116-
stderr += chunk
117-
})
118-
cp.on('error', err =>
119-
reject(
120-
new Error(
121-
`secret-tool store failed: ${err.message}. ` +
122-
'Install libsecret-tools (apt install libsecret-tools / dnf install libsecret) ' +
123-
'or ensure a Secret Service provider (gnome-keyring, kwallet) is running.',
124-
),
125-
),
102+
const hint =
103+
'Install libsecret-tools (apt install libsecret-tools / dnf install libsecret) ' +
104+
'or ensure a Secret Service provider (gnome-keyring, kwallet) is running.'
105+
// Let the lib spawn own output buffering (stdioString); write the value to
106+
// stdin via the returned `process` so it stays out of the process listing
107+
// (`ps`) and the kernel argv view. Don't attach `.setEncoding`/`.on('data')`
108+
// — that races the lib's Buffer.concat-on-close (see readLinux). The lib
109+
// rejects on a non-zero exit with `{ code, stderr }`; re-throw it as the
110+
// helpful error.
111+
const child = spawn(
112+
SECRET_TOOL_BIN,
113+
['store', `--label=${label}`, 'service', service, 'user', account],
114+
{ stdio: ['pipe', 'pipe', 'pipe'], stdioString: true },
115+
)
116+
child.process.stdin!.end(value)
117+
try {
118+
await child
119+
} catch (e) {
120+
const err = e as
121+
| {
122+
code?: number | undefined
123+
stderr?: unknown | undefined
124+
message?: string | undefined
125+
}
126+
| undefined
127+
const status = typeof err?.code === 'number' ? err.code : -1
128+
const stderr = String(err?.stderr ?? err?.message ?? '').trim()
129+
throw new ErrorCtor(
130+
`secret-tool store failed (status=${status}, user=${account}): ${stderr}. ${hint}`,
126131
)
127-
cp.on('close', status => {
128-
if (status === 0) {
129-
resolve()
130-
return
131-
}
132-
reject(
133-
new Error(
134-
`secret-tool store failed (status=${status}, user=${account}): ${stderr.trim()}. ` +
135-
'Install libsecret-tools (apt install libsecret-tools / dnf install libsecret) ' +
136-
'or ensure a Secret Service provider (gnome-keyring, kwallet) is running.',
137-
),
138-
)
139-
})
140-
// `secret-tool store` reads the password from stdin so the value
141-
// never appears in `ps(1)` / `/proc/<pid>/cmdline`.
142-
cp.stdin!.end(value)
143-
})
132+
}
144133
}
145134

146135
export function writeLinuxSync(

src/secrets/macos.ts

Lines changed: 35 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,6 @@ import {
2626

2727
import { ErrorCtor } from '../primordials/error'
2828

29-
import { PromiseCtor } from '../primordials/promise'
30-
3129
const SECURITY_BIN = 'security'
3230

3331
export async function deleteMacOS(
@@ -102,35 +100,50 @@ export interface SpawnOpts {
102100
stdio?: 'ignore' | 'pipe' | ['ignore', 'pipe', 'pipe'] | undefined
103101
}
104102

105-
export function runAsync(
103+
export async function runAsync(
106104
args: readonly string[],
107105
opts: SpawnOpts = {},
108106
): Promise<{
109107
status: number | null
110108
stdout: string
111109
stderr: string
112110
}> {
113-
return new PromiseCtor(resolve => {
114-
const { process: cp } = spawn(SECURITY_BIN, args as string[], {
115-
stdio: opts.stdio ?? ['ignore', 'pipe', 'pipe'],
116-
})
117-
let stdout = ''
118-
let stderr = ''
119-
if (cp.stdout) {
120-
cp.stdout.setEncoding('utf8')
121-
cp.stdout.on('data', chunk => {
122-
stdout += chunk
123-
})
111+
// Let the lib's spawn own output buffering. It collects stdout/stderr as
112+
// Buffer chunks and returns them as strings (stdioString) on the awaited
113+
// result. Do NOT also attach `.setEncoding('utf8')` + `.on('data')` here:
114+
// setEncoding flips the stream to emit STRING chunks, but the lib is
115+
// concurrently buffering the same stream as Buffers and does
116+
// `Buffer.concat(chunks)` on close — a string in that array throws
117+
// `TypeError: list[0] argument must be an instance of Buffer` (the
118+
// `security` CLI's "security: SecKeychainSearch…" stderr was the trigger).
119+
// The lib REJECTS on a non-zero exit, so a failed `security` call (e.g. a
120+
// missing entry) arrives in the catch with its `{ code, stdout, stderr }`.
121+
const child = spawn(SECURITY_BIN, args as string[], {
122+
stdio: opts.stdio ?? ['ignore', 'pipe', 'pipe'],
123+
stdioString: true,
124+
})
125+
try {
126+
const r = await child
127+
return {
128+
// oxlint-disable-next-line socket/prefer-undefined-over-null -- the return contract is `status: number | null` (matches Node's ChildProcess exit-code shape); callers branch on `=== 0`.
129+
status: typeof r.code === 'number' ? r.code : null,
130+
stderr: String(r.stderr ?? ''),
131+
stdout: String(r.stdout ?? ''),
124132
}
125-
if (cp.stderr) {
126-
cp.stderr.setEncoding('utf8')
127-
cp.stderr.on('data', chunk => {
128-
stderr += chunk
129-
})
133+
} catch (e) {
134+
const err = e as
135+
| {
136+
code?: number | undefined
137+
stdout?: unknown | undefined
138+
stderr?: unknown | undefined
139+
}
140+
| undefined
141+
return {
142+
status: typeof err?.code === 'number' ? err.code : -1,
143+
stderr: String(err?.stderr ?? ''),
144+
stdout: String(err?.stdout ?? ''),
130145
}
131-
cp.on('error', () => resolve({ status: -1, stdout, stderr }))
132-
cp.on('close', status => resolve({ status, stdout, stderr }))
133-
})
146+
}
134147
}
135148

136149
export async function writeMacOS(

src/secrets/windows.ts

Lines changed: 32 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,6 @@ import { ErrorCtor } from '../primordials/error'
3030

3131
import { JSONStringify } from '../primordials/json'
3232

33-
import { PromiseCtor } from '../primordials/promise'
34-
3533
const POWERSHELL_BIN = 'powershell'
3634

3735
export function buildTarget(service: string, account: string): string {
@@ -195,40 +193,46 @@ export function readWindowsSync(
195193
return readDpapiSync(getDpapiFilePath(service, account))
196194
}
197195

198-
export function runPsAsync(
196+
export async function runPsAsync(
199197
script: string,
200198
input?: string,
201199
): Promise<{
202200
status: number | null
203201
stdout: string
204202
stderr: string
205203
}> {
206-
return new PromiseCtor(resolve => {
207-
const { process: cp } = spawn(
208-
POWERSHELL_BIN,
209-
['-NoProfile', '-Command', script],
210-
{
211-
stdio: ['pipe', 'pipe', 'pipe'],
212-
},
213-
)
214-
let stdout = ''
215-
let stderr = ''
216-
cp.stdout!.setEncoding('utf8')
217-
cp.stdout!.on('data', chunk => {
218-
stdout += chunk
219-
})
220-
cp.stderr!.setEncoding('utf8')
221-
cp.stderr!.on('data', chunk => {
222-
stderr += chunk
223-
})
224-
cp.on('error', () => resolve({ status: -1, stdout, stderr }))
225-
cp.on('close', status => resolve({ status, stdout, stderr }))
226-
if (input !== undefined) {
227-
cp.stdin!.end(input)
228-
} else {
229-
cp.stdin!.end()
230-
}
204+
// Let the lib spawn own output buffering (stdioString → string stdout/
205+
// stderr). Don't attach `.setEncoding('utf8')` + `.on('data')`: that flips
206+
// the streams to string chunks while the lib buffers them as Buffers + does
207+
// Buffer.concat on close, throwing `TypeError: list[0] must be a Buffer`.
208+
// The lib rejects on a non-zero exit; capture its `{ code, stdout, stderr }`.
209+
const child = spawn(POWERSHELL_BIN, ['-NoProfile', '-Command', script], {
210+
stdio: ['pipe', 'pipe', 'pipe'],
211+
stdioString: true,
231212
})
213+
child.process.stdin!.end(input ?? '')
214+
try {
215+
const r = await child
216+
return {
217+
// oxlint-disable-next-line socket/prefer-undefined-over-null -- the return contract is `status: number | null` (matches Node's ChildProcess exit-code shape); callers branch on `=== 0`.
218+
status: typeof r.code === 'number' ? r.code : null,
219+
stderr: String(r.stderr ?? ''),
220+
stdout: String(r.stdout ?? ''),
221+
}
222+
} catch (e) {
223+
const err = e as
224+
| {
225+
code?: number | undefined
226+
stdout?: unknown | undefined
227+
stderr?: unknown | undefined
228+
}
229+
| undefined
230+
return {
231+
status: typeof err?.code === 'number' ? err.code : -1,
232+
stderr: String(err?.stderr ?? ''),
233+
stdout: String(err?.stdout ?? ''),
234+
}
235+
}
232236
}
233237

234238
export function runPsSync(

0 commit comments

Comments
 (0)