Skip to content

Commit 410d0c3

Browse files
D0n9X1nclaude
andcommitted
Recheck the log directories before reusing the file handle
The reused log handle was checked only by lstat of the file path, which follows links in the parent path. A logs directory replaced by a link to the folder the open file was moved to led back to the same file, so entries kept going there; main refused that directory on every write. The writer now records the app and logs directories when it opens the file and reuses the handle only while both are still those directories: real directories, not links, and on POSIX mode 0700. Otherwise it reopens with the full checks, which refuse a linked directory and tighten a loosened mode. New tests: an entry is not appended through a logs folder replaced by a link (fails before), and on POSIX a loosened logs folder mode is restored before the next append. Internals describes the directory check and that each flushLogs call waits for its own close. Part of #141 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 7fa8a0d commit 410d0c3

4 files changed

Lines changed: 95 additions & 24 deletions

File tree

‎src/lib/log.ts‎

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,8 @@ const sameFile = (left: Stats, right: Stats): boolean =>
194194

195195
// Do not chmod through an arbitrary symlink. Open the already checked directory
196196
// without following its final component, and operate on the handle on POSIX.
197-
const ensurePrivateDirectory = async (directory: string): Promise<void> => {
197+
// Returns the checked directory's lstat, so a reused log handle can tell it was not replaced.
198+
const ensurePrivateDirectory = async (directory: string): Promise<Stats> => {
198199
await fs.mkdir(directory, { mode: 0o700 }).catch((error: unknown) => {
199200
if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
200201
throw error
@@ -208,7 +209,7 @@ const ensurePrivateDirectory = async (directory: string): Promise<void> => {
208209

209210
// Windows has no O_NOFOLLOW and no POSIX mode bits, so the lstat check above is all it gets.
210211
if (process.platform === "win32") {
211-
return
212+
return observed
212213
}
213214

214215
const handle = await fs.open(directory, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW)
@@ -222,11 +223,20 @@ const ensurePrivateDirectory = async (directory: string): Promise<void> => {
222223
} finally {
223224
await handle.close()
224225
}
226+
227+
return observed
225228
}
226229

227-
const ensureLogDirectory = async (): Promise<void> => {
228-
await ensurePrivateDirectory(paths.appDir)
229-
await ensurePrivateDirectory(paths.logsDir)
230+
// The app directory holds the logs directory, so it is checked first.
231+
const logDirectories = [paths.appDir, paths.logsDir]
232+
233+
const ensureLogDirectory = async (): Promise<Array<Stats>> => {
234+
const checked: Array<Stats> = []
235+
for (const directory of logDirectories) {
236+
checked.push(await ensurePrivateDirectory(directory))
237+
}
238+
239+
return checked
230240
}
231241

232242
interface LogEntry {
@@ -240,6 +250,8 @@ interface ActiveLog {
240250
filePath: string
241251
handle: FileHandle
242252
identity: Stats
253+
// The log directories as checked when the file was opened, in logDirectories order.
254+
directories: Array<Stats>
243255
}
244256

245257
// Entries wait here and are written in call order by one drain at a time, so a burst costs one
@@ -254,19 +266,34 @@ let activeLog: ActiveLog | undefined
254266
// entry is under 64 KiB, so an entry is never split.
255267
const maxLogWriteBytes = 256 * 1024
256268

257-
// The open handle is reused only while its path still names the same private file with one link.
258-
// A rename, deletion, replacement, second hard link or loosened mode reopens with the full checks.
269+
// Windows has no POSIX mode bits to check.
270+
const hasMode = (stats: Stats, mode: number): boolean =>
271+
process.platform === "win32" || (stats.mode & 0o777) === mode
272+
273+
const isSameDirectory = (current: Stats | undefined, checked: Stats): boolean =>
274+
current !== undefined
275+
&& current.isDirectory()
276+
&& sameFile(current, checked)
277+
&& hasMode(current, 0o700)
278+
279+
// The open handle is reused only while its path still names the same private file with one link,
280+
// inside the same private directories. lstat of the file follows links in its parent path, so the
281+
// directories are checked too. A rename, deletion, replacement, second hard link, replaced
282+
// directory or loosened mode reopens with the full checks.
259283
const isStillActive = async (active: ActiveLog): Promise<boolean> => {
260-
const current = await fs.lstat(active.filePath).catch(() => undefined)
284+
const [current, ...directories] = await Promise.all(
285+
[active.filePath, ...logDirectories].map((target) => fs.lstat(target).catch(() => undefined)),
286+
)
261287

262288
return current !== undefined
263289
&& current.isFile()
264290
&& current.nlink === 1
265291
&& sameFile(current, active.identity)
266-
&& (process.platform === "win32" || (current.mode & 0o777) === 0o600)
292+
&& hasMode(current, 0o600)
293+
&& active.directories.every((checked, index) => isSameDirectory(directories[index], checked))
267294
}
268295

269-
const openPrivateLog = async (filePath: string): Promise<ActiveLog> => {
296+
const openPrivateLog = async (filePath: string, directories: Array<Stats>): Promise<ActiveLog> => {
270297
const observed = await fs.lstat(filePath).catch((error: unknown) => {
271298
if (!isMissing(error)) {
272299
throw error
@@ -303,7 +330,7 @@ const openPrivateLog = async (filePath: string): Promise<ActiveLog> => {
303330
await handle.chmod(0o600)
304331
}
305332

306-
return { filePath, handle, identity: opened }
333+
return { filePath, handle, identity: opened, directories }
307334
} catch (error) {
308335
await handle.close().catch(() => undefined)
309336
throw error
@@ -322,8 +349,8 @@ const openActiveLog = async (filePath: string): Promise<FileHandle> => {
322349
}
323350

324351
await closeActiveLog()
325-
await ensureLogDirectory()
326-
activeLog = await openPrivateLog(filePath)
352+
const directories = await ensureLogDirectory()
353+
activeLog = await openPrivateLog(filePath, directories)
327354

328355
return activeLog.handle
329356
}

‎tests/unit/log-format.test.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,42 @@ test("a loosened active log mode is restored before the next append", {
491491
assert.match(await fs.readFile(getLogPath(), "utf8"), /before chmod\n.*after chmod\n$/)
492492
})
493493

494+
// Why: lstat of the log path follows links in its parent path. A logs folder replaced by a link to
495+
// the folder the open file was moved to leads back to the same file, so the directories are
496+
// checked before the handle is reused (#141 review).
497+
test("an entry is not appended through a logs folder replaced by a link", async () => {
498+
log.info("before move")
499+
await readActiveLog()
500+
const moved = path.join(paths.appDir, "logs-moved")
501+
await fs.mkdir(moved)
502+
await fs.rename(getLogPath(), path.join(moved, path.basename(getLogPath())))
503+
await fs.rmdir(paths.logsDir)
504+
await fs.symlink(moved, paths.logsDir, "junction")
505+
try {
506+
log.info("after move")
507+
await flushLogs()
508+
509+
assert.match(await fs.readFile(path.join(moved, path.basename(getLogPath())), "utf8"), /^\S+ info before move\n$/)
510+
} finally {
511+
await fs.unlink(paths.logsDir)
512+
await fs.rm(moved, { recursive: true, force: true })
513+
}
514+
})
515+
516+
test("a loosened logs folder mode is restored before the next append", {
517+
skip: process.platform === "win32",
518+
}, async () => {
519+
log.info("before folder chmod")
520+
await readActiveLog()
521+
await fs.chmod(paths.logsDir, 0o755)
522+
523+
log.info("after folder chmod")
524+
await flushLogs()
525+
526+
assert.equal((await fs.stat(paths.logsDir)).mode & 0o777, 0o700)
527+
assert.match(await fs.readFile(getLogPath(), "utf8"), /before folder chmod\n.*after folder chmod\n$/)
528+
})
529+
494530
test.after(async () => {
495531
await flushLogs()
496532
await fs.rm(tempHome, { force: true, recursive: true })

‎wiki/EN-Internals.md‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -847,12 +847,17 @@ that end on entry boundaries: `FileHandle.appendFile` writes a larger buffer in
847847
between two of them.
848848

849849
The handle is reused only while its path still names the same private file with
850-
one link and, on POSIX, mode 0600. A rename, deletion, replacement, second hard
851-
link or loosened mode makes the next batch reopen the path with the full checks:
852-
no symlink, one link, the same file before and after the open, then chmod 0600.
853-
Directories are checked when the file is opened and on each retention pass. A
854-
failed write never fails a request; it drops that batch and closes the handle.
855-
`flushLogs` waits for queued writes, then closes the file.
850+
one link and, on POSIX, mode 0600, and the app and logs directories are still the
851+
directories checked when it was opened: real directories, not links, with mode
852+
0700 on POSIX. lstat of the file follows links in its parent path, so without the
853+
directory check a logs directory replaced by a link to where the open file was
854+
moved would pass. A rename, deletion, replacement, second hard link, replaced
855+
directory or loosened mode makes the next batch reopen the path with the full
856+
checks: real private directories, then no symlink, one link, the same file before
857+
and after the open, and chmod 0600. A failed write never fails a request; it drops
858+
that batch and closes the handle. `flushLogs` waits for queued writes, then closes
859+
the file; each call waits for the close it queued, so overlapping calls both
860+
finish.
856861

857862
Before #141 each entry ran its own directory checks, open, stat, chmod, append and
858863
close, and a burst could reach the file out of order.

‎wiki/ZH-Internals.md‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -711,11 +711,14 @@ UTC 戳会让格林尼治以西的人在本地下午的正中间发生文件切
711711
`FileHandle.appendFile` 会把更大的缓冲区分成 512 KiB 的几段写入,另一个向同一文件追加的
712712
进程可能插在两段之间。
713713

714-
只有当路径仍指向同一个只有一个链接的私有文件,且在 POSIX 上权限仍为 0600 时,句柄才会
715-
被复用。重命名、删除、替换、第二个硬链接或放宽的权限,都会让下一批重新打开该路径并执行
716-
完整检查:不是符号链接、只有一个链接、打开前后是同一个文件,然后 chmod 0600。目录在打开
717-
文件时以及每次保留清理时检查。写入失败永远不会让请求失败;它丢弃这一批并关闭句柄。
718-
`flushLogs` 等待队列中的写入完成,再关闭文件。
714+
只有当路径仍指向同一个只有一个链接的私有文件、在 POSIX 上权限仍为 0600,且应用目录和
715+
日志目录仍是打开时检查过的目录(真实目录而不是链接,在 POSIX 上权限为 0700)时,句柄
716+
才会被复用。对文件的 lstat 会跟随父路径中的链接,所以没有目录检查时,被替换成指向已打开
717+
文件新位置的链接的日志目录也能通过检查。重命名、删除、替换、第二个硬链接、被替换的目录或
718+
放宽的权限,都会让下一批重新打开该路径并执行完整检查:先确认目录是真实的私有目录,再确认
719+
不是符号链接、只有一个链接、打开前后是同一个文件,然后 chmod 0600。写入失败永远不会让请求
720+
失败;它丢弃这一批并关闭句柄。`flushLogs` 等待队列中的写入完成,再关闭文件;每次调用只
721+
等待自己排入的关闭,所以重叠的调用都会结束。
719722

720723
#141 之前,每条日志各自执行目录检查、open、stat、chmod、追加和 close,一批突发日志可能
721724
乱序写入文件。`tests/unit/log-format.test.ts` 覆盖调用顺序、调用时的时间戳和复用检查。

0 commit comments

Comments
 (0)