From 293be2db36a5426dda80569d16c303b741909a2b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 15:21:38 +0900 Subject: [PATCH 1/3] test(security): reject directory replacement after listing --- src/test/kotlin/html4tree/MainTest.kt | 38 +++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/test/kotlin/html4tree/MainTest.kt b/src/test/kotlin/html4tree/MainTest.kt index 9f44b97..1628bf4 100644 --- a/src/test/kotlin/html4tree/MainTest.kt +++ b/src/test/kotlin/html4tree/MainTest.kt @@ -764,6 +764,44 @@ class MainTest { assertFalse(listed, "fileKey mismatch should skip child listing") } + @Test + fun testDirectoryReplacementAfterListingIsRejected() { + val subdir = File(tempDir, "post_listing_swap") + subdir.mkdir() + val ll = LinkedList() + val entry = LinkedListEntry(subdir, 0) + entry.fileKey = "stable-key" + ll.push(entry) + + var processed = false + var listed = false + var identityCalls = 0 + + crawl_directories( + ll, + -1, + processDirectory = { _, _, _ -> processed = true }, + processIgnoreFile = { _, _ -> emptySet() }, + listFiles = { + listed = true + emptyArray() + }, + readAttributes = { _ -> createMockAttributes(isDir = true, isSymlink = false) }, + readIdentity = { + identityCalls++ + if (identityCalls == 1) { + FileIdentity("stable-key", true) + } else { + FileIdentity("replacement-key", true) + } + } + ) + + assertTrue(listed, "the initial identity should allow child listing") + assertEquals(2, identityCalls, "identity must be re-read after child listing") + assertFalse(processed, "a replaced directory must not be processed") + } + @Test fun testProcessIgnoreFileWithIllegalArgumentPattern() { val tempDir = java.nio.file.Files.createTempDirectory("test").toFile() From 441998da34c8cb8763e566c65bbdb9c571be3764 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 15:25:28 +0900 Subject: [PATCH 2/3] test(security): reject unreadable post-listing identity --- src/test/kotlin/html4tree/MainTest.kt | 33 +++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/test/kotlin/html4tree/MainTest.kt b/src/test/kotlin/html4tree/MainTest.kt index 1628bf4..e993454 100644 --- a/src/test/kotlin/html4tree/MainTest.kt +++ b/src/test/kotlin/html4tree/MainTest.kt @@ -802,6 +802,39 @@ class MainTest { assertFalse(processed, "a replaced directory must not be processed") } + @Test + fun testDirectoryBecomingUnreadableAfterListingIsRejected() { + val subdir = File(tempDir, "post_listing_unreadable") + subdir.mkdir() + val ll = LinkedList() + val entry = LinkedListEntry(subdir, 0) + entry.fileKey = "stable-key" + ll.push(entry) + + var processed = false + var identityCalls = 0 + + crawl_directories( + ll, + -1, + processDirectory = { _, _, _ -> processed = true }, + processIgnoreFile = { _, _ -> emptySet() }, + listFiles = { emptyArray() }, + readAttributes = { _ -> createMockAttributes(isDir = true, isSymlink = false) }, + readIdentity = { + identityCalls++ + if (identityCalls == 1) { + FileIdentity("stable-key", true) + } else { + FileIdentity(null, false) + } + } + ) + + assertEquals(2, identityCalls, "identity must be re-read after child listing") + assertFalse(processed, "a directory that becomes unreadable must not be processed") + } + @Test fun testProcessIgnoreFileWithIllegalArgumentPattern() { val tempDir = java.nio.file.Files.createTempDirectory("test").toFile() From 331197bc72a8aae4a4534a47d2f187e677a2585a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 15:26:56 +0900 Subject: [PATCH 3/3] fix(security): revalidate directory after listing --- src/main/kotlin/html4tree/main.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/main/kotlin/html4tree/main.kt b/src/main/kotlin/html4tree/main.kt index b823123..e8acd01 100644 --- a/src/main/kotlin/html4tree/main.kt +++ b/src/main/kotlin/html4tree/main.kt @@ -172,6 +172,16 @@ internal fun crawl_directories( // ⚡ Bolt Performance Optimization: 디렉토리 목록을 캐싱하여 중복된 I/O 시스템 호출을 줄임 val dirFiles = listFiles(lle.file) + + // The path can be replaced between the initial identity check and + // directory enumeration. Do not process or enqueue children from a + // snapshot whose post-listing identity is unreadable or different. + val postListingIdentity = readIdentity(lle.file) + if (!postListingIdentity.readable || currentIdentity.key != postListingIdentity.key) { + lle = ll.pull() + continue + } + val dirFilesNames = dirFiles?.map { it.name }?.toTypedArray() val exclude = processIgnoreFile(lle.file, dirFilesNames)