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)
diff --git a/src/test/kotlin/html4tree/MainTest.kt b/src/test/kotlin/html4tree/MainTest.kt
index 9f44b97..e993454 100644
--- a/src/test/kotlin/html4tree/MainTest.kt
+++ b/src/test/kotlin/html4tree/MainTest.kt
@@ -764,6 +764,77 @@ 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 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()