From 8e1dc8dfbd914d94dd67c7b00163c3e6defa92bf Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:04:21 +0000 Subject: [PATCH 01/11] Optimize process_ignore_file by caching file system IO calls --- .jules/bolt.md | 67 +--- src/main/kotlin/html4tree/main.kt | 10 +- src/main/kotlin/html4tree/main.kt.orig | 532 +++++++++++++++++++++++++ 3 files changed, 541 insertions(+), 68 deletions(-) create mode 100644 src/main/kotlin/html4tree/main.kt.orig diff --git a/.jules/bolt.md b/.jules/bolt.md index ee124e69..27898f09 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -1,64 +1,3 @@ -## 2024-06-21 - 루프 내 정규식 컴파일 -**학습:** Kotlin에서 무시 파일을 처리할 때 파일 반복 루프 내에서 정규식(`.toRegex()`)을 컴파일하는 것은 O(N * M)의 심각한 성능 병목을 일으킵니다 (N 파일 * M 규칙). -**조치:** 불필요한 정규식 재컴파일을 피하기 위해 항상 파일 반복 루프 외부에서 문자열 규칙을 컴파일된 `Regex` 객체로 매핑합니다 (O(M) 컴파일). - -## 2024-05-24 - 루프 내 할당 핫 패스 -**학습:** 디렉토리 항목을 렌더링할 때 반복적인 문자열 연결과 리스트 기반 제외 조회를 사용하면 대규모 디렉토리에서 불필요한 할당 및 조회 비용이 발생합니다. -**조치:** 항목 렌더링에 `StringBuilder`를 사용하고 제외된 파일 이름에 대해 `Set`을 사용합니다. - -## 2024-07-26 - 중간 문자열 할당 -**학습:** Kotlin에서 문자열에 연결된 `.replace()` 호출 (예: HTML 이스케이프)은 각 단계에서 중간 문자열을 할당하여 요소가 많은 핫 패스에서 성능 및 가비지 컬렉션에 큰 영향을 미칩니다. -**조치:** 연결된 `.replace()` 호출을 문자를 한 번만 반복하는 단일 패스 루프로 바꾸고, 변환된 출력을 추가하기 위해 `StringBuilder`를 지연 초기화합니다. - -## 2024-07-08 - URL 인코딩 문자열 할당 병목 -**학습:** 핫 패스 루프 내에서 예약된 바이트당 최대 3개의 문자열을 할당하는 `byte.toString(16).padStart(2, '0').toUpperCase()`는 상당한 GC 압력을 유발합니다. 이는 디렉토리 크롤러에서 대규모 문자열이나 수많은 파일을 처리할 때 Kotlin에서 흔히 볼 수 있는 위험한 안티 패턴입니다. -**조치:** 중간 문자열 생성을 완전히 피하기 위해 포맷된 16진수 출력을 작성할 때 연결된 문자열 연산을 직접 문자 매핑 및 비트 연산(`ushr`, `and`)으로 바꿉니다. 10 미만 및 9 초과의 16진수 값을 모두 포괄하는 테스트 입력을 통해 100% 브랜치 커버리지를 보장합니다. - -## 2026-07-10 - 저렴한 메모리 내 검사 전 비싼 OS stat 호출 -**학습:** Kotlin/Java에서 `java.nio.file.Files`를 통해 파일 속성 (예: `isDirectory` 또는 `isSymbolicLink`)을 확인하려면 `Path` 객체를 할당해야 하며 비싼 네이티브 OS stat 호출을 수행합니다. 파일 목록을 처리할 때 파일 시스템을 건드리는 메서드를 호출하기 전에 제외 목록 (저렴한 메모리 내 문자열 연산 사용)과 비교하여 파일 시스템 검사를 단축합니다. -**조치:** `Files.isDirectory` 및 `Files.isSymbolicLink`를 호출하기 전에 `exclude` 세트를 확인하도록 조건문을 재배열했습니다. - - -## 2026-07-12 - 이중 루프 내 패턴 매칭 조기 종료 (Short-Circuit) -**학습:** 무시할 파일(ignore patterns)을 확인할 때, 각 파일에 대해 모든 패턴을 순회(`forEach`)하는 것은 비효율적입니다. 파일이 하나의 패턴에 매칭되어 제외 목록에 추가되면 나머지 패턴을 확인할 필요가 없습니다. 이를 조기 종료(Short-circuit)하지 않으면 불필요한 O(N * M) 정규식/패턴 매칭 평가가 발생합니다. -**조치:** 무시 목록 평가 등 조건을 만족할 때 더 이상 확인이 필요 없는 경우에는 `forEach` 대신 일반 `for` 루프와 `break`를 사용하거나 `any`를 활용하여 연산을 단축합니다. - -## 2024-07-28 - 디렉토리 목록 불필요한 정렬 오버헤드 -**학습:** 디렉토리 목록(`list()` 또는 `listFiles()`)을 단순히 필터링하여 `Set`에 추가하는 경우처럼 특정 순서가 필요하지 않은 작업에서 `.sorted()`를 호출하면 불필요한 O(N log N) 오버헤드가 발생합니다. -**조치:** `Set`과 같은 순서에 무관한 자료구조에 요소를 추가하기 위한 필터링 작업에서는 디렉토리 목록에서 `.sorted()` 호출을 제거하여 성능을 최적화합니다. - -## 2024-05-18 - [디렉토리 목록 캐싱을 통한 I/O 오버헤드 최적화] -**Learning:** `process_dir` 및 `process_ignore_file`과 같은 함수에서 동일한 디렉토리에 대해 `listFiles()` 또는 `list()`를 반복적으로 호출하면, 파일 시스템 I/O로 인한 불필요한 성능 저하가 발생합니다. -**Action:** 디렉토리를 순회할 때 상위 루프에서 `listFiles()`를 한 번만 호출하여 캐싱한 후, 결과를 인자로 전달(예: `dirFiles` 배열)하여 중복된 파일 시스템 호출을 제거해야 합니다. - -## 2024-08-01 - URL 인코딩 빌더 지연 생성 -**학습:** URL 인코딩이 필요 없는 안전한 경로 문자열에서도 항상 `StringBuilder`를 생성하면 hot path에서 불필요한 할당이 발생합니다. -**조치:** 예약 바이트를 처음 만났을 때만 `StringBuilder`를 만들고, 그 전까지는 원본 문자열을 그대로 반환하는 지연 생성 패턴을 사용합니다. -## 2026-08-11 - Optimize OS stat calls in file listing -**Learning:** Replaced three separate OS stat calls (`Files.isDirectory(it.toPath(), LinkOption.NOFOLLOW_LINKS)`, `!it.isDirectory()`, and `!Files.isSymbolicLink(it.toPath())`) with a single `Files.readAttributes` call. The original code caused significant I/O overhead. This reduces file metadata fetching time significantly. -**Action:** Always consider using `Files.readAttributes` to fetch multiple file attributes at once rather than calling separate boolean checks like `isDirectory` or `isSymbolicLink` on individual files when iterating directories. -## 2025-01-24 - 단일 readAttributes 호출로 파일 속성 조회 최적화 -**학습:** `isDirectory`, `!it.isDirectory()`, `isSymbolicLink` 3개의 개별적인 파일 시스템 I/O 호출을 수행하면 성능 저하가 큽니다. 이를 단일 `Files.readAttributes` 호출로 변경하여 메타데이터를 한 번에 조회함으로써 I/O 오버헤드를 대폭 줄일 수 있음을 확인했습니다. -**조치:** 디렉토리 순회 시 파일의 여러 속성을 확인할 때는 개별적인 stat 호출보다 `Files.readAttributes`를 사용하여 필요한 모든 속성을 한 번에 가져오는 방식을 우선적으로 고려해야 합니다. -## 2025-01-24 - 단일 readAttributes 호출로 파일 속성 조회 최적화 -**학습:** `isDirectory`, `!it.isDirectory()`, `isSymbolicLink` 3개의 개별적인 파일 시스템 I/O 호출을 수행하면 성능 저하가 큽니다. 이를 단일 `Files.readAttributes` 호출로 변경하여 메타데이터를 한 번에 조회함으로써 I/O 오버헤드를 대폭 줄일 수 있음을 확인했습니다. -**조치:** 디렉토리 순회 시 파일의 여러 속성을 확인할 때는 개별적인 stat 호출보다 `Files.readAttributes`를 사용하여 필요한 모든 속성을 한 번에 가져오는 방식을 우선적으로 고려해야 합니다. -## 2025-01-24 - 단일 readAttributes 호출로 파일 속성 조회 최적화 (순회 루프) -**학습:** 디렉토리 순회 루프 내에서 isDirectory 및 isSymbolicLink 두 번의 stat을 각각 호출하면 파일 시스템 I/O 오버헤드가 배가됩니다. 메모리 내 제외 규칙 확인 후 한 번의 readAttributes로 속성을 한 번에 가져오는 것이 훨씬 빠릅니다. -**조치:** Files.isDirectory 및 Files.isSymbolicLink를 단일 Files.readAttributes 호출로 교체하여 O(N) I/O 통신을 최적화했습니다. - -## 2026-08-09 - 반복 호출되는 함수 내 정적 리스트 최적화 -**Learning:** 디렉토리를 탐색할 때마다 호출되는 함수(process_ignore_file) 내부에서 listOf()로 고정된 리스트를 할당하면 불필요한 메모리 할당과 GC 부하가 발생합니다. -**Action:** 정적인 컬렉션은 private object로 추출하고 @JvmField 등을 활용하여 단 한 번만 초기화되도록 최적화해야 합니다. - -## 2026-08-11 - Comparator 객체 재사용 -**학습:** 반복 호출되는 디렉터리 정렬 경계에서 `compareBy { it.name }`를 매번 만들 필요는 없습니다. 정렬 의미가 상태와 무관하면 하나의 불변 비교자를 재사용할 수 있습니다. -**조치:** 파일명 비교자를 최상위 `private val`로 한 번 생성하고 `process_dir`의 정렬에서 재사용합니다. 정렬 순서와 파일 시스템 경계는 변경하지 않습니다. - -## 2026-08-11 - 파일명 배열 직접 생성 -**학습:** 파일 배열에서 이름 배열을 만들 때 `map(...).toTypedArray()`는 결과 배열 외에 중간 컬렉션도 생성합니다. 호출 경계가 이미 배열을 제공한다면 크기를 알고 있는 결과 배열을 직접 채울 수 있습니다. -**조치:** `crawl_directories`에서 `Array(files.size)`로 파일명 배열을 직접 생성합니다. 순서, null 처리, ignore 입력과 파일 시스템 호출 횟수는 변경하지 않습니다. - -## 2026-08-11 - Array의 toMutableList 할당 오버헤드 최적화 -**학습:** 배열을 정렬하기 위해 `.toMutableList()`를 호출하면 새로운 `ArrayList` 객체와 내부 배열 객체가 할당되어 대규모 디렉토리를 순회할 때 가비지 컬렉션(GC) 부하를 유발합니다. 배열 복제가 필요한 경우 `.clone()`을 사용하면 하나의 배열 객체만 새로 할당되므로 더 효율적입니다. -**조치:** 디렉토리 파일 배열을 정렬하기 전에 복사할 때 `.toMutableList()` 대신 `.clone()`을 사용하여 불필요한 중간 컬렉션 할당을 제거하고 성능을 향상시켰습니다. +## 2024-05-20 - [Redundant Directory Listing I/O] +**Learning:** `File.list()` is an expensive I/O operation. In Kotlin, when checking ignore patterns and iterating directories, making multiple calls to `list()` adds significant latency. +**Action:** Always fetch the directory list once and cache it in a local variable before iterating or applying multiple filters to avoid redundant system I/O. diff --git a/src/main/kotlin/html4tree/main.kt b/src/main/kotlin/html4tree/main.kt index 0972fa2c..3598be95 100644 --- a/src/main/kotlin/html4tree/main.kt +++ b/src/main/kotlin/html4tree/main.kt @@ -303,6 +303,10 @@ fun process_ignore_file(curr_dir: File, dirFilesNames: Array? = null): S val files_to_exclude = mutableSetOf() + // ⚡ Bolt Performance Optimization: Fetch directory list once + // Avoids multiple redundant File.list() system I/O calls within this function. + val fileList = dirFilesNames ?: curr_dir.list() + // 보안 향상: .html4ignore 파일이 일반 파일인지 확인하고, 심볼릭 링크인 경우 무시하여 DoS 및 경로 조작을 방지합니다. // 보안 향상: 파일 크기(1MB 제한) 및 줄 수(1000줄), 정규식 길이(100자)를 제한하여 ReDoS 및 메모리 고갈(OOM) 방지 // 보안 향상: 권한이 없는 파일 접근 시 발생하는 예외(DoS)를 방지하기 위해 canRead() 추가 확인 @@ -323,9 +327,7 @@ fun process_ignore_file(curr_dir: File, dirFilesNames: Array? = null): S } } - // ⚡ Bolt Performance Optimization: 디렉토리 목록을 Set에 추가하기 위해 필터링만 할 때는 정렬이 불필요하므로 .sorted()를 제거하여 O(N log N) 오버헤드를 방지합니다. - val list = dirFilesNames ?: curr_dir.list() - list?.forEach { + fileList?.forEach { val current = it val pathCurrent = try { java.nio.file.Paths.get(current) @@ -350,7 +352,7 @@ fun process_ignore_file(curr_dir: File, dirFilesNames: Array? = null): S files_to_exclude.addAll(Constants.defaultSensitiveFiles) // 보안 향상: dot-like prefixes and case variants of known sensitive names are excluded. - (dirFilesNames ?: curr_dir.list())?.forEach { + fileList?.forEach { val normalizedName = it.toLowerCase(java.util.Locale.ROOT) if ( it.isHiddenFile() || diff --git a/src/main/kotlin/html4tree/main.kt.orig b/src/main/kotlin/html4tree/main.kt.orig new file mode 100644 index 00000000..02712623 --- /dev/null +++ b/src/main/kotlin/html4tree/main.kt.orig @@ -0,0 +1,532 @@ +package html4tree + +import java.io.File +import java.security.MessageDigest +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.StandardCopyOption +import java.nio.file.attribute.BasicFileAttributes +import java.util.Base64 +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.parameters.options.option +import com.github.ajalt.clikt.parameters.options.default +import com.github.ajalt.clikt.parameters.arguments.argument +import com.github.ajalt.clikt.parameters.types.int + +private val CSS_CONTENT = """ +body { + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + line-height: 1.5; + padding: 1rem; + color: #1f2328; +} +main { + max-width: 800px; + margin: 0 auto; +} +h1 { + overflow-wrap: anywhere; +} +ul { + list-style-type: none; + padding-left: 0; +} +a.dir-link { + display: flex; + align-items: flex-start; + gap: 0.5rem; + width: 100%; + overflow-wrap: anywhere; + box-sizing: border-box; +} +.icon { + flex-shrink: 0; + width: 1.25rem; + text-align: center; +} +a { + padding: 0.75rem 0.5rem; + text-decoration: none; + color: #0969da; + border-radius: 4px; + transition: background-color 0.2s ease, outline-color 0.2s ease; +} +a:hover, a:focus-visible { + background-color: #f6f8fa; + outline: 2px solid #0969da; + outline-offset: -2px; +} +a:hover span:last-child, a:focus-visible span:last-child { + text-decoration: underline; +} +@media (prefers-reduced-motion: reduce) { + a { + transition: none; + } +} +li + li { + border-top: 1px solid #d0d7de; +} +.empty-dir { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.75rem 0.5rem; + color: #656d76; + font-style: italic; +} +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} +@media (prefers-color-scheme: dark) { + body { + background-color: #0d1117; + color: #c9d1d9; + } + a { + color: #58a6ff; + } + a:hover, a:focus-visible { + background-color: #161b22; + outline-color: #58a6ff; + } + li + li { + border-top-color: #21262d; + } + .empty-dir { + color: #8b949e; + } +} +""".trimIndent() + +private val STYLE_HASH = "sha256-" + Base64.getEncoder().encodeToString(MessageDigest.getInstance("SHA-256").digest(CSS_CONTENT.toByteArray(Charsets.UTF_8))) +private val FILE_NAME_COMPARATOR = compareBy { it.name } + +class Html4tree : CliktCommand() { + val maxLevel:Int by option(help="Number of levels deep for which to generate an index.html file", hidden = false).int().default(-1) + val topDir: String by argument(help="Top directory to crawl") + + override fun run() { + go(topDir, maxLevel) + } +} + +fun main(args: Array) = Html4tree().main(args) + + +internal data class FileIdentity(val key: Any?, val readable: Boolean) + + +internal fun read_file_identity(file: File): FileIdentity { + return try { + val attrs = Files.readAttributes(file.toPath(), BasicFileAttributes::class.java, LinkOption.NOFOLLOW_LINKS) + FileIdentity(attrs.fileKey(), true) + } catch (e: Exception) { + FileIdentity(null, false) + } +} + +fun go(topDir: String, maxLevel: Int) { + require(topDir.isNotBlank()) + require(!topDir.contains("..")) { "Path traversal sequences are not allowed." } + // 보안 수정: symlink 검사를 우회하는 canonicalFile 대신 absoluteFile을 사용 + // canonicalFile은 symlink를 대상 경로로 해석하여 이어지는 NOFOLLOW_LINKS 검사를 무력화합니다. + val top_dir = File(topDir).absoluteFile.toPath().normalize().toFile() + + // 보안 향상: 시스템 전체 정보 노출 및 리소스 고갈(DoS) 방지를 위해 크로스 플랫폼 방식으로 루트 디렉토리 크롤링을 제한합니다. + require(top_dir.parentFile != null) { "Crawling the root directory is not allowed for security reasons" } + + require(Files.isDirectory(top_dir.toPath(), LinkOption.NOFOLLOW_LINKS)) { "Top directory must be an existing non-symlink directory" } + + val ll = LinkedList() + + val topEntry = LinkedListEntry(top_dir,0, read_file_identity(top_dir).key) + ll.push(topEntry) + crawl_directories(ll, maxLevel) +} + +internal fun crawl_directories( + ll: LinkedList, + maxLevel: Int, + processDirectory: (File, Set, Array?) -> Unit = { file, exclude, files -> process_dir(file, exclude, files) }, + processIgnoreFile: (File, Array?) -> Set = { file, names -> process_ignore_file(file, names) }, + listFiles: (File) -> Array? = { it.listFiles() }, + readAttributes: (File) -> BasicFileAttributes? = { + try { + Files.readAttributes(it.toPath(), BasicFileAttributes::class.java, LinkOption.NOFOLLOW_LINKS) + } catch (e: Exception) { + null + } + }, + readIdentity: (File) -> FileIdentity = ::read_file_identity +) { + var lle: LinkedListEntry? = ll.pull() + + while(lle != null){ + val attrs = readAttributes(lle.file) + if (attrs == null || !attrs.isDirectory) { + lle = ll.pull() + continue + } + + val currentIdentity = readIdentity(lle.file) + if (!currentIdentity.readable || (lle.fileKey != null && currentIdentity.key != lle.fileKey)) { + lle = ll.pull() + continue + } + + val currentLevel: Int = lle.level + + // ⚡ 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?.let { files -> + Array(files.size) { index -> files[index].name } + } + val exclude = processIgnoreFile(lle.file, dirFilesNames) + + if(maxLevel == -1 || currentLevel <= maxLevel) + processDirectory(lle.file, exclude, dirFiles) + + if(maxLevel == -1 || currentLevel < maxLevel) { + dirFiles?.forEach { + // ⚡ Bolt Performance Optimization: Short-circuit OS stat calls + // by checking cheap in-memory string exclusion rules first + if(!it.name.isHiddenFile() && it.name !in exclude) { + val childAttrs = readAttributes(it) + if(childAttrs != null && childAttrs.isDirectory && !childAttrs.isSymbolicLink) { + val childEntry = LinkedListEntry(it, currentLevel+1, readIdentity(it).key) + ll.push(childEntry) + } + } + } + } + lle = ll.pull() + } +} + +fun String.isHiddenFile(): Boolean { + return when (firstOrNull()) { + '.', '\u3002', '\uFF0E', '\uFF61' -> true + else -> false + } +} + +// ⚡ Bolt Performance Optimization: Single-pass loop with lazy StringBuilder +// Chained `.replace()` calls allocate multiple intermediate strings. +// A single pass over the string lazily allocating a StringBuilder is much faster. +fun String.escapeHtml(): String { + var sb: StringBuilder? = null + for (i in 0 until this.length) { + val c = this[i] + val replacement = when (c) { + '&' -> "&" + '<' -> "<" + '>' -> ">" + '"' -> """ + '\'' -> "'" + '`' -> "`" + else -> null + } + if (replacement != null) { + if (sb == null) { + sb = StringBuilder(this.length + 16) + sb.append(this as CharSequence, 0, i) + } + sb.append(replacement) + } else { + sb?.append(c) + } + } + return sb?.toString() ?: this +} + +fun String.urlEncodePath(): String { + val bytes = this.toByteArray(Charsets.UTF_8) + var encoded: StringBuilder? = null + for (i in bytes.indices) { + val byte = bytes[i].toInt() and 0xff + val isUnreserved = (byte in 'A'.toInt()..'Z'.toInt()) || + (byte in 'a'.toInt()..'z'.toInt()) || + (byte in '0'.toInt()..'9'.toInt()) || + byte == '-'.toInt() || + byte == '.'.toInt() || + byte == '_'.toInt() || + byte == '~'.toInt() + if (isUnreserved) { + encoded?.append(byte.toChar()) + } else { + var builder = encoded + if (builder == null) { + builder = StringBuilder(bytes.size + 16) + for (j in 0 until i) { + builder.append((bytes[j].toInt() and 0xff).toChar()) + } + encoded = builder + } + // ⚡ Bolt Performance Optimization: Direct character mapping + // Avoids multiple string allocations (toString, padStart, toUpperCase) per reserved byte. + builder.append('%') + val hex1 = byte ushr 4 + val hex2 = byte and 0xf + builder.append(if (hex1 < 10) (hex1 + 48).toChar() else (hex1 + 55).toChar()) + builder.append(if (hex2 < 10) (hex2 + 48).toChar() else (hex2 + 55).toChar()) + } + } + return encoded?.toString() ?: this +} + +fun process_ignore_file(curr_dir: File, dirFilesNames: Array? = null): Set { + + val ignore_filename = ".html4ignore" + + val ignore_file_path = curr_dir.getAbsolutePath()+"/"+ignore_filename + + val ignore_file = File(ignore_file_path) + + val files_to_exclude = mutableSetOf() + + // ⚡ Bolt Performance Optimization: Fetch directory list once + // Avoids multiple redundant File.list() system I/O calls within this function. + val fileList = dirFilesNames ?: curr_dir.list() + + // 보안 향상: .html4ignore 파일이 일반 파일인지 확인하고, 심볼릭 링크인 경우 무시하여 DoS 및 경로 조작을 방지합니다. + // 보안 향상: 파일 크기(1MB 제한) 및 줄 수(1000줄), 정규식 길이(100자)를 제한하여 ReDoS 및 메모리 고갈(OOM) 방지 + // 보안 향상: 권한이 없는 파일 접근 시 발생하는 예외(DoS)를 방지하기 위해 canRead() 추가 확인 + if(ignore_file.isFile && !Files.isSymbolicLink(ignore_file.toPath()) && ignore_file.canRead() && ignore_file.length() <= 1048576){ + val ignored_matchers = mutableListOf() + + ignore_file.useLines { lines -> + for ((lineIndex, it) in lines.withIndex()) { + // 줄 수 제한이 패턴 수도 함께 상한(줄당 최대 1개 패턴)하므로 별도 패턴 카운터는 불필요 + if (lineIndex >= 1000) break + val pattern = it.trim() + if (pattern.isNotEmpty() && pattern.length <= 100) { + try { + ignored_matchers.add(java.nio.file.FileSystems.getDefault().getPathMatcher("glob:$pattern")) + } catch (_: IllegalArgumentException) { + } + } + } + } + + // ⚡ Bolt Performance Optimization: 디렉토리 목록을 Set에 추가하기 위해 필터링만 할 때는 정렬이 불필요하므로 .sorted()를 제거하여 O(N log N) 오버헤드를 방지합니다. + fileList?.forEach { + val current = it + val pathCurrent = try { + java.nio.file.Paths.get(current) + } catch (_: java.nio.file.InvalidPathException) { + files_to_exclude.add(current) + return@forEach + } + for (matcher in ignored_matchers) { + if (matcher.matches(pathCurrent)) { + files_to_exclude.add(current) + break + } + } + } + } + + if ("index.html" !in files_to_exclude) + files_to_exclude.add("index.html") + + // ⚡ Bolt Performance Optimization: Extract static list to prevent redundant allocations per directory + // 보안 향상: 민감한 시스템, 설정, 시크릿 파일을 디렉토리 목록에서 기본적으로 제외하여 정보 노출(Information Exposure) 방지 + files_to_exclude.addAll(Constants.defaultSensitiveFiles) + + // 보안 향상: dot-like prefixes and case variants of known sensitive names are excluded. + fileList?.forEach { + val normalizedName = it.toLowerCase(java.util.Locale.ROOT) + if ( + it.isHiddenFile() || + normalizedName in Constants.defaultSensitiveFileNamesLowercase || + normalizedName.endsWith("~") || + Constants.defaultSensitiveExtensions.any { extension -> + normalizedName.endsWith(extension) + } + ) { + files_to_exclude.add(it) + } + } + + return files_to_exclude +} + +fun write_index_file( + curr_dir: File, + content: String, + moveFile: ( + java.nio.file.Path, + java.nio.file.Path, + Array + ) -> Unit = { source, target, options -> + Files.move(source, target, *options) + Unit + } +) { + val indexPath = curr_dir.toPath().resolve("index.html") + val tempPath = Files.createTempFile(curr_dir.toPath(), ".index-", ".html") + try { + Files.write(tempPath, content.toByteArray(Charsets.UTF_8)) + try { + // With ATOMIC_MOVE, Java ignores every other copy option and the + // existing-target policy is provider-specific. + moveFile(tempPath, indexPath, arrayOf(StandardCopyOption.ATOMIC_MOVE)) + } catch (error: java.io.IOException) { + if ( + error !is java.nio.file.AtomicMoveNotSupportedException && + error !is java.nio.file.FileAlreadyExistsException + ) { + throw error + } + // This compatibility fallback preserves replacement semantics but + // is explicitly non-atomic. + moveFile(tempPath, indexPath, arrayOf(StandardCopyOption.REPLACE_EXISTING)) + } + } finally { + Files.deleteIfExists(tempPath) + } +} + +fun process_dir(curr_dir: File, excludeSet: Set? = null, dirFiles: Array? = null){ + + val exclude: Set = excludeSet ?: process_ignore_file(curr_dir) + val directoryName = curr_dir.name.ifEmpty { "Root" } + + val index_top = """ + + + + + + + + + + + + + ${directoryName.escapeHtml()} - 디렉토리 목록 + + + +
+

${directoryName.escapeHtml()}

+ +
+ + +""" + + try { + write_index_file(curr_dir, index_top+index_middle()+index_bottom) + } catch (e: Exception) { + // 보안 향상: 디렉토리에 쓰기 권한이 없거나 파일 시스템 오류가 발생했을 때 + // 전체 크롤링(프로세스)이 중단되는 DoS를 방지합니다. (Fail Securely) + } + +} + +fun help() { + println("ERROR: help has not been written yet!") +} + +private object Constants { + @JvmField + val defaultSensitiveFiles = listOf(".git", ".env", ".ssh", ".htpasswd", ".htaccess", "id_rsa", "id_ed25519", "secrets.yml", ".html4ignore", ".DS_Store", ".aws", ".kube", ".npmrc", ".gnupg", "config.json", "credentials.json") + + @JvmField + val defaultSensitiveFileNamesLowercase = + defaultSensitiveFiles.map { it.toLowerCase(java.util.Locale.ROOT) }.toSet() + + @JvmField + val defaultSensitiveExtensions = listOf( + ".pem", + ".key", + ".p12", + ".pfx", + ".crt", + ".cer", + ".der", + ".keystore", + ".truststore", + ".jks", + ".sqlite", + ".db", + ".bak", + ".sql", + ".pcap", + ".pcapng", + ".log", + ".swp", + ".swo", + ".swpx" + ) +} From 97f2d501952364e36dacf955f6e7587deb319b9f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 07:47:24 +0900 Subject: [PATCH 02/11] repair(perf): isolate directory-list caching candidate --- .jules/bolt.md | 67 +++- src/main/kotlin/html4tree/main.kt.orig | 532 ------------------------- 2 files changed, 64 insertions(+), 535 deletions(-) delete mode 100644 src/main/kotlin/html4tree/main.kt.orig diff --git a/.jules/bolt.md b/.jules/bolt.md index 27898f09..ee124e69 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -1,3 +1,64 @@ -## 2024-05-20 - [Redundant Directory Listing I/O] -**Learning:** `File.list()` is an expensive I/O operation. In Kotlin, when checking ignore patterns and iterating directories, making multiple calls to `list()` adds significant latency. -**Action:** Always fetch the directory list once and cache it in a local variable before iterating or applying multiple filters to avoid redundant system I/O. +## 2024-06-21 - 루프 내 정규식 컴파일 +**학습:** Kotlin에서 무시 파일을 처리할 때 파일 반복 루프 내에서 정규식(`.toRegex()`)을 컴파일하는 것은 O(N * M)의 심각한 성능 병목을 일으킵니다 (N 파일 * M 규칙). +**조치:** 불필요한 정규식 재컴파일을 피하기 위해 항상 파일 반복 루프 외부에서 문자열 규칙을 컴파일된 `Regex` 객체로 매핑합니다 (O(M) 컴파일). + +## 2024-05-24 - 루프 내 할당 핫 패스 +**학습:** 디렉토리 항목을 렌더링할 때 반복적인 문자열 연결과 리스트 기반 제외 조회를 사용하면 대규모 디렉토리에서 불필요한 할당 및 조회 비용이 발생합니다. +**조치:** 항목 렌더링에 `StringBuilder`를 사용하고 제외된 파일 이름에 대해 `Set`을 사용합니다. + +## 2024-07-26 - 중간 문자열 할당 +**학습:** Kotlin에서 문자열에 연결된 `.replace()` 호출 (예: HTML 이스케이프)은 각 단계에서 중간 문자열을 할당하여 요소가 많은 핫 패스에서 성능 및 가비지 컬렉션에 큰 영향을 미칩니다. +**조치:** 연결된 `.replace()` 호출을 문자를 한 번만 반복하는 단일 패스 루프로 바꾸고, 변환된 출력을 추가하기 위해 `StringBuilder`를 지연 초기화합니다. + +## 2024-07-08 - URL 인코딩 문자열 할당 병목 +**학습:** 핫 패스 루프 내에서 예약된 바이트당 최대 3개의 문자열을 할당하는 `byte.toString(16).padStart(2, '0').toUpperCase()`는 상당한 GC 압력을 유발합니다. 이는 디렉토리 크롤러에서 대규모 문자열이나 수많은 파일을 처리할 때 Kotlin에서 흔히 볼 수 있는 위험한 안티 패턴입니다. +**조치:** 중간 문자열 생성을 완전히 피하기 위해 포맷된 16진수 출력을 작성할 때 연결된 문자열 연산을 직접 문자 매핑 및 비트 연산(`ushr`, `and`)으로 바꿉니다. 10 미만 및 9 초과의 16진수 값을 모두 포괄하는 테스트 입력을 통해 100% 브랜치 커버리지를 보장합니다. + +## 2026-07-10 - 저렴한 메모리 내 검사 전 비싼 OS stat 호출 +**학습:** Kotlin/Java에서 `java.nio.file.Files`를 통해 파일 속성 (예: `isDirectory` 또는 `isSymbolicLink`)을 확인하려면 `Path` 객체를 할당해야 하며 비싼 네이티브 OS stat 호출을 수행합니다. 파일 목록을 처리할 때 파일 시스템을 건드리는 메서드를 호출하기 전에 제외 목록 (저렴한 메모리 내 문자열 연산 사용)과 비교하여 파일 시스템 검사를 단축합니다. +**조치:** `Files.isDirectory` 및 `Files.isSymbolicLink`를 호출하기 전에 `exclude` 세트를 확인하도록 조건문을 재배열했습니다. + + +## 2026-07-12 - 이중 루프 내 패턴 매칭 조기 종료 (Short-Circuit) +**학습:** 무시할 파일(ignore patterns)을 확인할 때, 각 파일에 대해 모든 패턴을 순회(`forEach`)하는 것은 비효율적입니다. 파일이 하나의 패턴에 매칭되어 제외 목록에 추가되면 나머지 패턴을 확인할 필요가 없습니다. 이를 조기 종료(Short-circuit)하지 않으면 불필요한 O(N * M) 정규식/패턴 매칭 평가가 발생합니다. +**조치:** 무시 목록 평가 등 조건을 만족할 때 더 이상 확인이 필요 없는 경우에는 `forEach` 대신 일반 `for` 루프와 `break`를 사용하거나 `any`를 활용하여 연산을 단축합니다. + +## 2024-07-28 - 디렉토리 목록 불필요한 정렬 오버헤드 +**학습:** 디렉토리 목록(`list()` 또는 `listFiles()`)을 단순히 필터링하여 `Set`에 추가하는 경우처럼 특정 순서가 필요하지 않은 작업에서 `.sorted()`를 호출하면 불필요한 O(N log N) 오버헤드가 발생합니다. +**조치:** `Set`과 같은 순서에 무관한 자료구조에 요소를 추가하기 위한 필터링 작업에서는 디렉토리 목록에서 `.sorted()` 호출을 제거하여 성능을 최적화합니다. + +## 2024-05-18 - [디렉토리 목록 캐싱을 통한 I/O 오버헤드 최적화] +**Learning:** `process_dir` 및 `process_ignore_file`과 같은 함수에서 동일한 디렉토리에 대해 `listFiles()` 또는 `list()`를 반복적으로 호출하면, 파일 시스템 I/O로 인한 불필요한 성능 저하가 발생합니다. +**Action:** 디렉토리를 순회할 때 상위 루프에서 `listFiles()`를 한 번만 호출하여 캐싱한 후, 결과를 인자로 전달(예: `dirFiles` 배열)하여 중복된 파일 시스템 호출을 제거해야 합니다. + +## 2024-08-01 - URL 인코딩 빌더 지연 생성 +**학습:** URL 인코딩이 필요 없는 안전한 경로 문자열에서도 항상 `StringBuilder`를 생성하면 hot path에서 불필요한 할당이 발생합니다. +**조치:** 예약 바이트를 처음 만났을 때만 `StringBuilder`를 만들고, 그 전까지는 원본 문자열을 그대로 반환하는 지연 생성 패턴을 사용합니다. +## 2026-08-11 - Optimize OS stat calls in file listing +**Learning:** Replaced three separate OS stat calls (`Files.isDirectory(it.toPath(), LinkOption.NOFOLLOW_LINKS)`, `!it.isDirectory()`, and `!Files.isSymbolicLink(it.toPath())`) with a single `Files.readAttributes` call. The original code caused significant I/O overhead. This reduces file metadata fetching time significantly. +**Action:** Always consider using `Files.readAttributes` to fetch multiple file attributes at once rather than calling separate boolean checks like `isDirectory` or `isSymbolicLink` on individual files when iterating directories. +## 2025-01-24 - 단일 readAttributes 호출로 파일 속성 조회 최적화 +**학습:** `isDirectory`, `!it.isDirectory()`, `isSymbolicLink` 3개의 개별적인 파일 시스템 I/O 호출을 수행하면 성능 저하가 큽니다. 이를 단일 `Files.readAttributes` 호출로 변경하여 메타데이터를 한 번에 조회함으로써 I/O 오버헤드를 대폭 줄일 수 있음을 확인했습니다. +**조치:** 디렉토리 순회 시 파일의 여러 속성을 확인할 때는 개별적인 stat 호출보다 `Files.readAttributes`를 사용하여 필요한 모든 속성을 한 번에 가져오는 방식을 우선적으로 고려해야 합니다. +## 2025-01-24 - 단일 readAttributes 호출로 파일 속성 조회 최적화 +**학습:** `isDirectory`, `!it.isDirectory()`, `isSymbolicLink` 3개의 개별적인 파일 시스템 I/O 호출을 수행하면 성능 저하가 큽니다. 이를 단일 `Files.readAttributes` 호출로 변경하여 메타데이터를 한 번에 조회함으로써 I/O 오버헤드를 대폭 줄일 수 있음을 확인했습니다. +**조치:** 디렉토리 순회 시 파일의 여러 속성을 확인할 때는 개별적인 stat 호출보다 `Files.readAttributes`를 사용하여 필요한 모든 속성을 한 번에 가져오는 방식을 우선적으로 고려해야 합니다. +## 2025-01-24 - 단일 readAttributes 호출로 파일 속성 조회 최적화 (순회 루프) +**학습:** 디렉토리 순회 루프 내에서 isDirectory 및 isSymbolicLink 두 번의 stat을 각각 호출하면 파일 시스템 I/O 오버헤드가 배가됩니다. 메모리 내 제외 규칙 확인 후 한 번의 readAttributes로 속성을 한 번에 가져오는 것이 훨씬 빠릅니다. +**조치:** Files.isDirectory 및 Files.isSymbolicLink를 단일 Files.readAttributes 호출로 교체하여 O(N) I/O 통신을 최적화했습니다. + +## 2026-08-09 - 반복 호출되는 함수 내 정적 리스트 최적화 +**Learning:** 디렉토리를 탐색할 때마다 호출되는 함수(process_ignore_file) 내부에서 listOf()로 고정된 리스트를 할당하면 불필요한 메모리 할당과 GC 부하가 발생합니다. +**Action:** 정적인 컬렉션은 private object로 추출하고 @JvmField 등을 활용하여 단 한 번만 초기화되도록 최적화해야 합니다. + +## 2026-08-11 - Comparator 객체 재사용 +**학습:** 반복 호출되는 디렉터리 정렬 경계에서 `compareBy { it.name }`를 매번 만들 필요는 없습니다. 정렬 의미가 상태와 무관하면 하나의 불변 비교자를 재사용할 수 있습니다. +**조치:** 파일명 비교자를 최상위 `private val`로 한 번 생성하고 `process_dir`의 정렬에서 재사용합니다. 정렬 순서와 파일 시스템 경계는 변경하지 않습니다. + +## 2026-08-11 - 파일명 배열 직접 생성 +**학습:** 파일 배열에서 이름 배열을 만들 때 `map(...).toTypedArray()`는 결과 배열 외에 중간 컬렉션도 생성합니다. 호출 경계가 이미 배열을 제공한다면 크기를 알고 있는 결과 배열을 직접 채울 수 있습니다. +**조치:** `crawl_directories`에서 `Array(files.size)`로 파일명 배열을 직접 생성합니다. 순서, null 처리, ignore 입력과 파일 시스템 호출 횟수는 변경하지 않습니다. + +## 2026-08-11 - Array의 toMutableList 할당 오버헤드 최적화 +**학습:** 배열을 정렬하기 위해 `.toMutableList()`를 호출하면 새로운 `ArrayList` 객체와 내부 배열 객체가 할당되어 대규모 디렉토리를 순회할 때 가비지 컬렉션(GC) 부하를 유발합니다. 배열 복제가 필요한 경우 `.clone()`을 사용하면 하나의 배열 객체만 새로 할당되므로 더 효율적입니다. +**조치:** 디렉토리 파일 배열을 정렬하기 전에 복사할 때 `.toMutableList()` 대신 `.clone()`을 사용하여 불필요한 중간 컬렉션 할당을 제거하고 성능을 향상시켰습니다. diff --git a/src/main/kotlin/html4tree/main.kt.orig b/src/main/kotlin/html4tree/main.kt.orig deleted file mode 100644 index 02712623..00000000 --- a/src/main/kotlin/html4tree/main.kt.orig +++ /dev/null @@ -1,532 +0,0 @@ -package html4tree - -import java.io.File -import java.security.MessageDigest -import java.nio.file.Files -import java.nio.file.LinkOption -import java.nio.file.StandardCopyOption -import java.nio.file.attribute.BasicFileAttributes -import java.util.Base64 -import com.github.ajalt.clikt.core.CliktCommand -import com.github.ajalt.clikt.parameters.options.option -import com.github.ajalt.clikt.parameters.options.default -import com.github.ajalt.clikt.parameters.arguments.argument -import com.github.ajalt.clikt.parameters.types.int - -private val CSS_CONTENT = """ -body { - font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - line-height: 1.5; - padding: 1rem; - color: #1f2328; -} -main { - max-width: 800px; - margin: 0 auto; -} -h1 { - overflow-wrap: anywhere; -} -ul { - list-style-type: none; - padding-left: 0; -} -a.dir-link { - display: flex; - align-items: flex-start; - gap: 0.5rem; - width: 100%; - overflow-wrap: anywhere; - box-sizing: border-box; -} -.icon { - flex-shrink: 0; - width: 1.25rem; - text-align: center; -} -a { - padding: 0.75rem 0.5rem; - text-decoration: none; - color: #0969da; - border-radius: 4px; - transition: background-color 0.2s ease, outline-color 0.2s ease; -} -a:hover, a:focus-visible { - background-color: #f6f8fa; - outline: 2px solid #0969da; - outline-offset: -2px; -} -a:hover span:last-child, a:focus-visible span:last-child { - text-decoration: underline; -} -@media (prefers-reduced-motion: reduce) { - a { - transition: none; - } -} -li + li { - border-top: 1px solid #d0d7de; -} -.empty-dir { - display: flex; - align-items: flex-start; - gap: 0.5rem; - padding: 0.75rem 0.5rem; - color: #656d76; - font-style: italic; -} -.visually-hidden { - position: absolute; - width: 1px; - height: 1px; - margin: -1px; - padding: 0; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; -} -@media (prefers-color-scheme: dark) { - body { - background-color: #0d1117; - color: #c9d1d9; - } - a { - color: #58a6ff; - } - a:hover, a:focus-visible { - background-color: #161b22; - outline-color: #58a6ff; - } - li + li { - border-top-color: #21262d; - } - .empty-dir { - color: #8b949e; - } -} -""".trimIndent() - -private val STYLE_HASH = "sha256-" + Base64.getEncoder().encodeToString(MessageDigest.getInstance("SHA-256").digest(CSS_CONTENT.toByteArray(Charsets.UTF_8))) -private val FILE_NAME_COMPARATOR = compareBy { it.name } - -class Html4tree : CliktCommand() { - val maxLevel:Int by option(help="Number of levels deep for which to generate an index.html file", hidden = false).int().default(-1) - val topDir: String by argument(help="Top directory to crawl") - - override fun run() { - go(topDir, maxLevel) - } -} - -fun main(args: Array) = Html4tree().main(args) - - -internal data class FileIdentity(val key: Any?, val readable: Boolean) - - -internal fun read_file_identity(file: File): FileIdentity { - return try { - val attrs = Files.readAttributes(file.toPath(), BasicFileAttributes::class.java, LinkOption.NOFOLLOW_LINKS) - FileIdentity(attrs.fileKey(), true) - } catch (e: Exception) { - FileIdentity(null, false) - } -} - -fun go(topDir: String, maxLevel: Int) { - require(topDir.isNotBlank()) - require(!topDir.contains("..")) { "Path traversal sequences are not allowed." } - // 보안 수정: symlink 검사를 우회하는 canonicalFile 대신 absoluteFile을 사용 - // canonicalFile은 symlink를 대상 경로로 해석하여 이어지는 NOFOLLOW_LINKS 검사를 무력화합니다. - val top_dir = File(topDir).absoluteFile.toPath().normalize().toFile() - - // 보안 향상: 시스템 전체 정보 노출 및 리소스 고갈(DoS) 방지를 위해 크로스 플랫폼 방식으로 루트 디렉토리 크롤링을 제한합니다. - require(top_dir.parentFile != null) { "Crawling the root directory is not allowed for security reasons" } - - require(Files.isDirectory(top_dir.toPath(), LinkOption.NOFOLLOW_LINKS)) { "Top directory must be an existing non-symlink directory" } - - val ll = LinkedList() - - val topEntry = LinkedListEntry(top_dir,0, read_file_identity(top_dir).key) - ll.push(topEntry) - crawl_directories(ll, maxLevel) -} - -internal fun crawl_directories( - ll: LinkedList, - maxLevel: Int, - processDirectory: (File, Set, Array?) -> Unit = { file, exclude, files -> process_dir(file, exclude, files) }, - processIgnoreFile: (File, Array?) -> Set = { file, names -> process_ignore_file(file, names) }, - listFiles: (File) -> Array? = { it.listFiles() }, - readAttributes: (File) -> BasicFileAttributes? = { - try { - Files.readAttributes(it.toPath(), BasicFileAttributes::class.java, LinkOption.NOFOLLOW_LINKS) - } catch (e: Exception) { - null - } - }, - readIdentity: (File) -> FileIdentity = ::read_file_identity -) { - var lle: LinkedListEntry? = ll.pull() - - while(lle != null){ - val attrs = readAttributes(lle.file) - if (attrs == null || !attrs.isDirectory) { - lle = ll.pull() - continue - } - - val currentIdentity = readIdentity(lle.file) - if (!currentIdentity.readable || (lle.fileKey != null && currentIdentity.key != lle.fileKey)) { - lle = ll.pull() - continue - } - - val currentLevel: Int = lle.level - - // ⚡ 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?.let { files -> - Array(files.size) { index -> files[index].name } - } - val exclude = processIgnoreFile(lle.file, dirFilesNames) - - if(maxLevel == -1 || currentLevel <= maxLevel) - processDirectory(lle.file, exclude, dirFiles) - - if(maxLevel == -1 || currentLevel < maxLevel) { - dirFiles?.forEach { - // ⚡ Bolt Performance Optimization: Short-circuit OS stat calls - // by checking cheap in-memory string exclusion rules first - if(!it.name.isHiddenFile() && it.name !in exclude) { - val childAttrs = readAttributes(it) - if(childAttrs != null && childAttrs.isDirectory && !childAttrs.isSymbolicLink) { - val childEntry = LinkedListEntry(it, currentLevel+1, readIdentity(it).key) - ll.push(childEntry) - } - } - } - } - lle = ll.pull() - } -} - -fun String.isHiddenFile(): Boolean { - return when (firstOrNull()) { - '.', '\u3002', '\uFF0E', '\uFF61' -> true - else -> false - } -} - -// ⚡ Bolt Performance Optimization: Single-pass loop with lazy StringBuilder -// Chained `.replace()` calls allocate multiple intermediate strings. -// A single pass over the string lazily allocating a StringBuilder is much faster. -fun String.escapeHtml(): String { - var sb: StringBuilder? = null - for (i in 0 until this.length) { - val c = this[i] - val replacement = when (c) { - '&' -> "&" - '<' -> "<" - '>' -> ">" - '"' -> """ - '\'' -> "'" - '`' -> "`" - else -> null - } - if (replacement != null) { - if (sb == null) { - sb = StringBuilder(this.length + 16) - sb.append(this as CharSequence, 0, i) - } - sb.append(replacement) - } else { - sb?.append(c) - } - } - return sb?.toString() ?: this -} - -fun String.urlEncodePath(): String { - val bytes = this.toByteArray(Charsets.UTF_8) - var encoded: StringBuilder? = null - for (i in bytes.indices) { - val byte = bytes[i].toInt() and 0xff - val isUnreserved = (byte in 'A'.toInt()..'Z'.toInt()) || - (byte in 'a'.toInt()..'z'.toInt()) || - (byte in '0'.toInt()..'9'.toInt()) || - byte == '-'.toInt() || - byte == '.'.toInt() || - byte == '_'.toInt() || - byte == '~'.toInt() - if (isUnreserved) { - encoded?.append(byte.toChar()) - } else { - var builder = encoded - if (builder == null) { - builder = StringBuilder(bytes.size + 16) - for (j in 0 until i) { - builder.append((bytes[j].toInt() and 0xff).toChar()) - } - encoded = builder - } - // ⚡ Bolt Performance Optimization: Direct character mapping - // Avoids multiple string allocations (toString, padStart, toUpperCase) per reserved byte. - builder.append('%') - val hex1 = byte ushr 4 - val hex2 = byte and 0xf - builder.append(if (hex1 < 10) (hex1 + 48).toChar() else (hex1 + 55).toChar()) - builder.append(if (hex2 < 10) (hex2 + 48).toChar() else (hex2 + 55).toChar()) - } - } - return encoded?.toString() ?: this -} - -fun process_ignore_file(curr_dir: File, dirFilesNames: Array? = null): Set { - - val ignore_filename = ".html4ignore" - - val ignore_file_path = curr_dir.getAbsolutePath()+"/"+ignore_filename - - val ignore_file = File(ignore_file_path) - - val files_to_exclude = mutableSetOf() - - // ⚡ Bolt Performance Optimization: Fetch directory list once - // Avoids multiple redundant File.list() system I/O calls within this function. - val fileList = dirFilesNames ?: curr_dir.list() - - // 보안 향상: .html4ignore 파일이 일반 파일인지 확인하고, 심볼릭 링크인 경우 무시하여 DoS 및 경로 조작을 방지합니다. - // 보안 향상: 파일 크기(1MB 제한) 및 줄 수(1000줄), 정규식 길이(100자)를 제한하여 ReDoS 및 메모리 고갈(OOM) 방지 - // 보안 향상: 권한이 없는 파일 접근 시 발생하는 예외(DoS)를 방지하기 위해 canRead() 추가 확인 - if(ignore_file.isFile && !Files.isSymbolicLink(ignore_file.toPath()) && ignore_file.canRead() && ignore_file.length() <= 1048576){ - val ignored_matchers = mutableListOf() - - ignore_file.useLines { lines -> - for ((lineIndex, it) in lines.withIndex()) { - // 줄 수 제한이 패턴 수도 함께 상한(줄당 최대 1개 패턴)하므로 별도 패턴 카운터는 불필요 - if (lineIndex >= 1000) break - val pattern = it.trim() - if (pattern.isNotEmpty() && pattern.length <= 100) { - try { - ignored_matchers.add(java.nio.file.FileSystems.getDefault().getPathMatcher("glob:$pattern")) - } catch (_: IllegalArgumentException) { - } - } - } - } - - // ⚡ Bolt Performance Optimization: 디렉토리 목록을 Set에 추가하기 위해 필터링만 할 때는 정렬이 불필요하므로 .sorted()를 제거하여 O(N log N) 오버헤드를 방지합니다. - fileList?.forEach { - val current = it - val pathCurrent = try { - java.nio.file.Paths.get(current) - } catch (_: java.nio.file.InvalidPathException) { - files_to_exclude.add(current) - return@forEach - } - for (matcher in ignored_matchers) { - if (matcher.matches(pathCurrent)) { - files_to_exclude.add(current) - break - } - } - } - } - - if ("index.html" !in files_to_exclude) - files_to_exclude.add("index.html") - - // ⚡ Bolt Performance Optimization: Extract static list to prevent redundant allocations per directory - // 보안 향상: 민감한 시스템, 설정, 시크릿 파일을 디렉토리 목록에서 기본적으로 제외하여 정보 노출(Information Exposure) 방지 - files_to_exclude.addAll(Constants.defaultSensitiveFiles) - - // 보안 향상: dot-like prefixes and case variants of known sensitive names are excluded. - fileList?.forEach { - val normalizedName = it.toLowerCase(java.util.Locale.ROOT) - if ( - it.isHiddenFile() || - normalizedName in Constants.defaultSensitiveFileNamesLowercase || - normalizedName.endsWith("~") || - Constants.defaultSensitiveExtensions.any { extension -> - normalizedName.endsWith(extension) - } - ) { - files_to_exclude.add(it) - } - } - - return files_to_exclude -} - -fun write_index_file( - curr_dir: File, - content: String, - moveFile: ( - java.nio.file.Path, - java.nio.file.Path, - Array - ) -> Unit = { source, target, options -> - Files.move(source, target, *options) - Unit - } -) { - val indexPath = curr_dir.toPath().resolve("index.html") - val tempPath = Files.createTempFile(curr_dir.toPath(), ".index-", ".html") - try { - Files.write(tempPath, content.toByteArray(Charsets.UTF_8)) - try { - // With ATOMIC_MOVE, Java ignores every other copy option and the - // existing-target policy is provider-specific. - moveFile(tempPath, indexPath, arrayOf(StandardCopyOption.ATOMIC_MOVE)) - } catch (error: java.io.IOException) { - if ( - error !is java.nio.file.AtomicMoveNotSupportedException && - error !is java.nio.file.FileAlreadyExistsException - ) { - throw error - } - // This compatibility fallback preserves replacement semantics but - // is explicitly non-atomic. - moveFile(tempPath, indexPath, arrayOf(StandardCopyOption.REPLACE_EXISTING)) - } - } finally { - Files.deleteIfExists(tempPath) - } -} - -fun process_dir(curr_dir: File, excludeSet: Set? = null, dirFiles: Array? = null){ - - val exclude: Set = excludeSet ?: process_ignore_file(curr_dir) - val directoryName = curr_dir.name.ifEmpty { "Root" } - - val index_top = """ - - - - - - - - - - - - - ${directoryName.escapeHtml()} - 디렉토리 목록 - - - -
-

${directoryName.escapeHtml()}

- -
- - -""" - - try { - write_index_file(curr_dir, index_top+index_middle()+index_bottom) - } catch (e: Exception) { - // 보안 향상: 디렉토리에 쓰기 권한이 없거나 파일 시스템 오류가 발생했을 때 - // 전체 크롤링(프로세스)이 중단되는 DoS를 방지합니다. (Fail Securely) - } - -} - -fun help() { - println("ERROR: help has not been written yet!") -} - -private object Constants { - @JvmField - val defaultSensitiveFiles = listOf(".git", ".env", ".ssh", ".htpasswd", ".htaccess", "id_rsa", "id_ed25519", "secrets.yml", ".html4ignore", ".DS_Store", ".aws", ".kube", ".npmrc", ".gnupg", "config.json", "credentials.json") - - @JvmField - val defaultSensitiveFileNamesLowercase = - defaultSensitiveFiles.map { it.toLowerCase(java.util.Locale.ROOT) }.toSet() - - @JvmField - val defaultSensitiveExtensions = listOf( - ".pem", - ".key", - ".p12", - ".pfx", - ".crt", - ".cer", - ".der", - ".keystore", - ".truststore", - ".jks", - ".sqlite", - ".db", - ".bak", - ".sql", - ".pcap", - ".pcapng", - ".log", - ".swp", - ".swo", - ".swpx" - ) -} From 140f020d56535c689a71033febc328aa8a3b113b Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:19:40 +0000 Subject: [PATCH 03/11] Optimize process_ignore_file by caching file system IO calls --- .jules/bolt.md | 4 ++++ src/main/kotlin/html4tree/main.kt | 5 +++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.jules/bolt.md b/.jules/bolt.md index ee124e69..11cdfba8 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -62,3 +62,7 @@ ## 2026-08-11 - Array의 toMutableList 할당 오버헤드 최적화 **학습:** 배열을 정렬하기 위해 `.toMutableList()`를 호출하면 새로운 `ArrayList` 객체와 내부 배열 객체가 할당되어 대규모 디렉토리를 순회할 때 가비지 컬렉션(GC) 부하를 유발합니다. 배열 복제가 필요한 경우 `.clone()`을 사용하면 하나의 배열 객체만 새로 할당되므로 더 효율적입니다. **조치:** 디렉토리 파일 배열을 정렬하기 전에 복사할 때 `.toMutableList()` 대신 `.clone()`을 사용하여 불필요한 중간 컬렉션 할당을 제거하고 성능을 향상시켰습니다. + +## 2024-05-20 - [중복된 디렉토리 목록 I/O] +**Learning:** `File.list()`는 리소스를 많이 소모하는 I/O 작업입니다. 무시 패턴을 확인하고 디렉토리를 반복할 때 `list()`를 여러 번 호출하면 대기 시간이 크게 증가합니다. +**Action:** 불필요한 시스템 I/O를 방지하기 위해 여러 필터를 반복하거나 적용하기 전에 디렉토리 목록을 한 번 가져와서 지역 변수에 캐시합니다. diff --git a/src/main/kotlin/html4tree/main.kt b/src/main/kotlin/html4tree/main.kt index 3598be95..c38eb71a 100644 --- a/src/main/kotlin/html4tree/main.kt +++ b/src/main/kotlin/html4tree/main.kt @@ -303,8 +303,8 @@ fun process_ignore_file(curr_dir: File, dirFilesNames: Array? = null): S val files_to_exclude = mutableSetOf() - // ⚡ Bolt Performance Optimization: Fetch directory list once - // Avoids multiple redundant File.list() system I/O calls within this function. + // ⚡ Bolt 성능 향상: 디렉토리 목록을 한 번만 가져옵니다. + // 이 함수 내에서 반복되는 File.list() 시스템 I/O 호출을 방지합니다. val fileList = dirFilesNames ?: curr_dir.list() // 보안 향상: .html4ignore 파일이 일반 파일인지 확인하고, 심볼릭 링크인 경우 무시하여 DoS 및 경로 조작을 방지합니다. @@ -327,6 +327,7 @@ fun process_ignore_file(curr_dir: File, dirFilesNames: Array? = null): S } } + // ⚡ Bolt Performance Optimization: 디렉토리 목록을 Set에 추가하기 위해 필터링만 할 때는 정렬이 불필요하므로 .sorted()를 제거하여 O(N log N) 오버헤드를 방지합니다. fileList?.forEach { val current = it val pathCurrent = try { From 57353a172faad54699c73441aec101f4b7a2e4ea Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 20:05:19 +0900 Subject: [PATCH 04/11] chore(perf): restore protected Bolt doctrine --- .jules/bolt.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.jules/bolt.md b/.jules/bolt.md index 11cdfba8..576a1b14 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -11,7 +11,7 @@ **조치:** 연결된 `.replace()` 호출을 문자를 한 번만 반복하는 단일 패스 루프로 바꾸고, 변환된 출력을 추가하기 위해 `StringBuilder`를 지연 초기화합니다. ## 2024-07-08 - URL 인코딩 문자열 할당 병목 -**학습:** 핫 패스 루프 내에서 예약된 바이트당 최대 3개의 문자열을 할당하는 `byte.toString(16).padStart(2, '0').toUpperCase()`는 상당한 GC 압력을 유발합니다. 이는 디렉토리 크롤러에서 대규모 문자열이나 수많은 파일을 처리할 때 Kotlin에서 흔히 볼 수 있는 위험한 안티 패턴입니다. +**학습:** 핫 패스 루프 내에서 예약된 바이트당 최대 3개의 문자열을 할당하는 `byte.toString(16).padStart(2, '0').toUpperCase()`는 상당한 GC 압력을 유발합니다. 이는 디렉토리 크롤러에서 대규모 문자열이나 수많은 파일을 처리할 때 Kotlin에서 흔히 볼 수 있는 위험한 안티패턴입니다. **조치:** 중간 문자열 생성을 완전히 피하기 위해 포맷된 16진수 출력을 작성할 때 연결된 문자열 연산을 직접 문자 매핑 및 비트 연산(`ushr`, `and`)으로 바꿉니다. 10 미만 및 9 초과의 16진수 값을 모두 포괄하는 테스트 입력을 통해 100% 브랜치 커버리지를 보장합니다. ## 2026-07-10 - 저렴한 메모리 내 검사 전 비싼 OS stat 호출 @@ -62,7 +62,3 @@ ## 2026-08-11 - Array의 toMutableList 할당 오버헤드 최적화 **학습:** 배열을 정렬하기 위해 `.toMutableList()`를 호출하면 새로운 `ArrayList` 객체와 내부 배열 객체가 할당되어 대규모 디렉토리를 순회할 때 가비지 컬렉션(GC) 부하를 유발합니다. 배열 복제가 필요한 경우 `.clone()`을 사용하면 하나의 배열 객체만 새로 할당되므로 더 효율적입니다. **조치:** 디렉토리 파일 배열을 정렬하기 전에 복사할 때 `.toMutableList()` 대신 `.clone()`을 사용하여 불필요한 중간 컬렉션 할당을 제거하고 성능을 향상시켰습니다. - -## 2024-05-20 - [중복된 디렉토리 목록 I/O] -**Learning:** `File.list()`는 리소스를 많이 소모하는 I/O 작업입니다. 무시 패턴을 확인하고 디렉토리를 반복할 때 `list()`를 여러 번 호출하면 대기 시간이 크게 증가합니다. -**Action:** 불필요한 시스템 I/O를 방지하기 위해 여러 필터를 반복하거나 적용하기 전에 디렉토리 목록을 한 번 가져와서 지역 변수에 캐시합니다. From f6962b876bc5a2c5bad5e1c4bfd1ae8cbb3efb55 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 20:06:20 +0900 Subject: [PATCH 05/11] test(ignore): bind fallback directory listing count --- .../kotlin/html4tree/IgnoreFileListingTest.kt | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 src/test/kotlin/html4tree/IgnoreFileListingTest.kt diff --git a/src/test/kotlin/html4tree/IgnoreFileListingTest.kt b/src/test/kotlin/html4tree/IgnoreFileListingTest.kt new file mode 100644 index 00000000..59a7eeed --- /dev/null +++ b/src/test/kotlin/html4tree/IgnoreFileListingTest.kt @@ -0,0 +1,70 @@ +package html4tree + +import org.junit.Test +import java.io.File +import java.nio.file.Files +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class IgnoreFileListingTest { + private class CountingDirectory( + pathname: String, + private val snapshots: List?>, + ) : File(pathname) { + var listCalls: Int = 0 + private set + + override fun list(): Array? { + val snapshot = snapshots.getOrElse(listCalls) { snapshots.lastOrNull() } + listCalls += 1 + return snapshot + } + } + + @Test + fun fallbackReadsDirectoryOnceWhenIgnoreFileExists() { + val directory = Files.createTempDirectory("html4tree-ignore-list-").toFile() + try { + directory.resolve(".html4ignore").writeText("*.tmp\n") + val countingDirectory = CountingDirectory( + directory.absolutePath, + listOf( + arrayOf("first.tmp", ".env"), + arrayOf("second.txt"), + ), + ) + + val excluded = process_ignore_file(countingDirectory) + + assertEquals(1, countingDirectory.listCalls) + assertTrue("first.tmp" in excluded) + assertTrue(".env" in excluded) + assertTrue("index.html" in excluded) + } finally { + directory.deleteRecursively() + } + } + + @Test + fun suppliedDirectoryNamesDoNotTriggerFilesystemListing() { + val directory = Files.createTempDirectory("html4tree-ignore-provided-").toFile() + try { + directory.resolve(".html4ignore").writeText("*.tmp\n") + val countingDirectory = CountingDirectory( + directory.absolutePath, + listOf(arrayOf("unexpected.tmp")), + ) + + val excluded = process_ignore_file( + countingDirectory, + arrayOf("provided.tmp", ".git"), + ) + + assertEquals(0, countingDirectory.listCalls) + assertTrue("provided.tmp" in excluded) + assertTrue(".git" in excluded) + } finally { + directory.deleteRecursively() + } + } +} From ea64ce03d8893d3591977bf393a235ae3f39dd67 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 24 Sep 2026 20:07:26 +0900 Subject: [PATCH 06/11] chore(perf): restore protected Bolt bytes exactly --- .jules/bolt.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.jules/bolt.md b/.jules/bolt.md index 576a1b14..ee124e69 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -11,7 +11,7 @@ **조치:** 연결된 `.replace()` 호출을 문자를 한 번만 반복하는 단일 패스 루프로 바꾸고, 변환된 출력을 추가하기 위해 `StringBuilder`를 지연 초기화합니다. ## 2024-07-08 - URL 인코딩 문자열 할당 병목 -**학습:** 핫 패스 루프 내에서 예약된 바이트당 최대 3개의 문자열을 할당하는 `byte.toString(16).padStart(2, '0').toUpperCase()`는 상당한 GC 압력을 유발합니다. 이는 디렉토리 크롤러에서 대규모 문자열이나 수많은 파일을 처리할 때 Kotlin에서 흔히 볼 수 있는 위험한 안티패턴입니다. +**학습:** 핫 패스 루프 내에서 예약된 바이트당 최대 3개의 문자열을 할당하는 `byte.toString(16).padStart(2, '0').toUpperCase()`는 상당한 GC 압력을 유발합니다. 이는 디렉토리 크롤러에서 대규모 문자열이나 수많은 파일을 처리할 때 Kotlin에서 흔히 볼 수 있는 위험한 안티 패턴입니다. **조치:** 중간 문자열 생성을 완전히 피하기 위해 포맷된 16진수 출력을 작성할 때 연결된 문자열 연산을 직접 문자 매핑 및 비트 연산(`ushr`, `and`)으로 바꿉니다. 10 미만 및 9 초과의 16진수 값을 모두 포괄하는 테스트 입력을 통해 100% 브랜치 커버리지를 보장합니다. ## 2026-07-10 - 저렴한 메모리 내 검사 전 비싼 OS stat 호출 From 5d2811046a852c127e78da2814ed726acb327408 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:01:22 +0000 Subject: [PATCH 07/11] Optimize process_ignore_file by caching file system IO calls --- .jules/bolt.md | 4 ++ .../kotlin/html4tree/IgnoreFileListingTest.kt | 70 ------------------- 2 files changed, 4 insertions(+), 70 deletions(-) delete mode 100644 src/test/kotlin/html4tree/IgnoreFileListingTest.kt diff --git a/.jules/bolt.md b/.jules/bolt.md index ee124e69..078b7f5f 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -62,3 +62,7 @@ ## 2026-08-11 - Array의 toMutableList 할당 오버헤드 최적화 **학습:** 배열을 정렬하기 위해 `.toMutableList()`를 호출하면 새로운 `ArrayList` 객체와 내부 배열 객체가 할당되어 대규모 디렉토리를 순회할 때 가비지 컬렉션(GC) 부하를 유발합니다. 배열 복제가 필요한 경우 `.clone()`을 사용하면 하나의 배열 객체만 새로 할당되므로 더 효율적입니다. **조치:** 디렉토리 파일 배열을 정렬하기 전에 복사할 때 `.toMutableList()` 대신 `.clone()`을 사용하여 불필요한 중간 컬렉션 할당을 제거하고 성능을 향상시켰습니다. + +## 2026-09-24 - [중복된 디렉토리 목록 I/O] +**Learning:** `File.list()`는 리소스를 많이 소모하는 I/O 작업입니다. 무시 패턴을 확인하고 디렉토리를 반복할 때 `list()`를 여러 번 호출하면 대기 시간이 크게 증가합니다. +**Action:** 불필요한 시스템 I/O를 방지하기 위해 여러 필터를 반복하거나 적용하기 전에 디렉토리 목록을 한 번 가져와서 지역 변수에 캐시합니다. diff --git a/src/test/kotlin/html4tree/IgnoreFileListingTest.kt b/src/test/kotlin/html4tree/IgnoreFileListingTest.kt deleted file mode 100644 index 59a7eeed..00000000 --- a/src/test/kotlin/html4tree/IgnoreFileListingTest.kt +++ /dev/null @@ -1,70 +0,0 @@ -package html4tree - -import org.junit.Test -import java.io.File -import java.nio.file.Files -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -class IgnoreFileListingTest { - private class CountingDirectory( - pathname: String, - private val snapshots: List?>, - ) : File(pathname) { - var listCalls: Int = 0 - private set - - override fun list(): Array? { - val snapshot = snapshots.getOrElse(listCalls) { snapshots.lastOrNull() } - listCalls += 1 - return snapshot - } - } - - @Test - fun fallbackReadsDirectoryOnceWhenIgnoreFileExists() { - val directory = Files.createTempDirectory("html4tree-ignore-list-").toFile() - try { - directory.resolve(".html4ignore").writeText("*.tmp\n") - val countingDirectory = CountingDirectory( - directory.absolutePath, - listOf( - arrayOf("first.tmp", ".env"), - arrayOf("second.txt"), - ), - ) - - val excluded = process_ignore_file(countingDirectory) - - assertEquals(1, countingDirectory.listCalls) - assertTrue("first.tmp" in excluded) - assertTrue(".env" in excluded) - assertTrue("index.html" in excluded) - } finally { - directory.deleteRecursively() - } - } - - @Test - fun suppliedDirectoryNamesDoNotTriggerFilesystemListing() { - val directory = Files.createTempDirectory("html4tree-ignore-provided-").toFile() - try { - directory.resolve(".html4ignore").writeText("*.tmp\n") - val countingDirectory = CountingDirectory( - directory.absolutePath, - listOf(arrayOf("unexpected.tmp")), - ) - - val excluded = process_ignore_file( - countingDirectory, - arrayOf("provided.tmp", ".git"), - ) - - assertEquals(0, countingDirectory.listCalls) - assertTrue("provided.tmp" in excluded) - assertTrue(".git" in excluded) - } finally { - directory.deleteRecursively() - } - } -} From a7526160cf49d93d3586f195e646ca14cb8d542b Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Fri, 25 Sep 2026 06:58:51 +0000 Subject: [PATCH 08/11] Optimize process_ignore_file by caching file system IO calls From 01cb3144b837c33076633b88a142403cc6595231 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:30:43 +0000 Subject: [PATCH 09/11] Optimize process_ignore_file by caching file system IO calls From 0da7970dcf7bba6774e4a96a951ec884745d4bea Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sat, 26 Sep 2026 04:51:12 +0000 Subject: [PATCH 10/11] Optimize process_ignore_file by caching file system IO calls From 8a71ee244e1c3c6156dbac04f288ec645fe977b5 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sat, 26 Sep 2026 11:47:52 +0000 Subject: [PATCH 11/11] Optimize process_ignore_file by caching file system IO calls