Skip to content

Commit 0a9411c

Browse files
authored
fix(auth): prevent deadlock and race condition in credential storage (#37) (#38)
- Read file directly in set()/remove() instead of calling all() - Eliminates nested lock acquisition deadlock - Maintains file locking for cross-call synchronization - Validates data with Info.safeParse() on read
1 parent 20d11b6 commit 0a9411c

1 file changed

Lines changed: 65 additions & 20 deletions

File tree

‎packages/opencode/src/auth/index.ts‎

Lines changed: 65 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import path from "path"
22
import { Global } from "../global"
33
import fs from "fs/promises"
44
import z from "zod"
5+
import { Lock } from "../util/lock"
56

67
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
78

@@ -43,31 +44,75 @@ export namespace Auth {
4344
}
4445

4546
export async function all(): Promise<Record<string, Info>> {
46-
const file = Bun.file(filepath)
47-
const data = await file.json().catch(() => ({}) as Record<string, unknown>)
48-
return Object.entries(data).reduce(
49-
(acc, [key, value]) => {
50-
const parsed = Info.safeParse(value)
51-
if (!parsed.success) return acc
52-
acc[key] = parsed.data
53-
return acc
54-
},
55-
{} as Record<string, Info>,
56-
)
47+
const release = await Lock.read("auth")
48+
try {
49+
const file = Bun.file(filepath)
50+
51+
if (!(await file.exists())) return {}
52+
53+
const data = await file.json()
54+
55+
if (typeof data !== "object" || data === null) {
56+
throw new Error("auth.json contains invalid data")
57+
}
58+
59+
return Object.entries(data).reduce(
60+
(acc, [key, value]) => {
61+
const parsed = Info.safeParse(value)
62+
if (!parsed.success) return acc
63+
acc[key] = parsed.data
64+
return acc
65+
},
66+
{} as Record<string, Info>,
67+
)
68+
} finally {
69+
release[Symbol.dispose]()
70+
}
5771
}
5872

5973
export async function set(key: string, info: Info) {
60-
const file = Bun.file(filepath)
61-
const data = await all()
62-
await Bun.write(file, JSON.stringify({ ...data, [key]: info }, null, 2))
63-
await fs.chmod(file.name!, 0o600)
74+
const release = await Lock.write("auth")
75+
try {
76+
const file = Bun.file(filepath)
77+
const exists = await file.exists()
78+
const rawData = exists ? await file.json() : null
79+
const data: Record<string, Info> = {}
80+
81+
if (typeof rawData === "object" && rawData !== null) {
82+
Object.entries(rawData).forEach(([k, v]) => {
83+
const parsed = Info.safeParse(v)
84+
if (parsed.success) data[k] = parsed.data
85+
})
86+
}
87+
88+
data[key] = info
89+
await Bun.write(file, JSON.stringify(data, null, 2))
90+
await fs.chmod(filepath, 0o600)
91+
} finally {
92+
release[Symbol.dispose]()
93+
}
6494
}
6595

6696
export async function remove(key: string) {
67-
const file = Bun.file(filepath)
68-
const data = await all()
69-
delete data[key]
70-
await Bun.write(file, JSON.stringify(data, null, 2))
71-
await fs.chmod(file.name!, 0o600)
97+
const release = await Lock.write("auth")
98+
try {
99+
const file = Bun.file(filepath)
100+
const exists = await file.exists()
101+
const rawData = exists ? await file.json() : null
102+
const data: Record<string, Info> = {}
103+
104+
if (typeof rawData === "object" && rawData !== null) {
105+
Object.entries(rawData).forEach(([k, v]) => {
106+
const parsed = Info.safeParse(v)
107+
if (parsed.success) data[k] = parsed.data
108+
})
109+
}
110+
111+
delete data[key]
112+
await Bun.write(file, JSON.stringify(data, null, 2))
113+
await fs.chmod(filepath, 0o600)
114+
} finally {
115+
release[Symbol.dispose]()
116+
}
72117
}
73118
}

0 commit comments

Comments
 (0)