diff --git a/.jules/bolt.md b/.jules/bolt.md
index 3b1d19e..5cf5ec7 100644
--- a/.jules/bolt.md
+++ b/.jules/bolt.md
@@ -1,28 +1,18 @@
-## 2024-06-21 - 루프 내 정규식 컴파일
-**학습:** Kotlin에서 무시 파일을 처리할 때 파일 반복 루프 내에서 정규식(`.toRegex()`)을 컴파일하는 것은 O(N * M)의 심각한 성능 병목을 일으킵니다 (N 파일 * M 규칙).
-**조치:** 불필요한 정규식 재컴파일을 피하기 위해 항상 파일 반복 루프 외부에서 문자열 규칙을 컴파일된 `Regex` 객체로 매핑합니다 (O(M) 컴파일).
+## 2024-06-21 - Regex Compilation in Loops
+**Learning:** In Kotlin, compiling regular expressions (`.toRegex()`) inside a loop over files is a significant O(N * M) performance bottleneck when processing ignore files (N files * M rules).
+**Action:** Always map string rules to compiled `Regex` objects outside of the file iteration loop (O(M) compilation) to avoid unnecessary regex re-compilations.
-## 2024-05-24 - 루프 내 할당 핫 패스
-**학습:** 디렉토리 항목을 렌더링할 때 반복적인 문자열 연결과 리스트 기반 제외 조회를 사용하면 대규모 디렉토리에서 불필요한 할당 및 조회 비용이 발생합니다.
-**조치:** 항목 렌더링에 `StringBuilder`를 사용하고 제외된 파일 이름에 대해 `Set`을 사용합니다.
+## 2024-05-24 - Loop Allocation Hot Paths
+**Learning:** Rendering directory entries with repeated string concatenation and list-based exclusion lookups creates avoidable allocation and lookup cost in large directories.
+**Action:** Use `StringBuilder` for entry rendering and a `Set` for excluded file names.
-## 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-07-26 - Intermediate String Allocations
+**Learning:** Chained `.replace()` calls on strings in Kotlin (e.g. for HTML escaping) allocate an intermediate String at each step, significantly impacting performance and garbage collection on hot paths with many elements.
+**Action:** Replace chained `.replace()` calls with a single-pass loop that iterates over characters once, lazily initializing a `StringBuilder` to append the transformed output.
+## 2024-07-08 - URL Encoding String Allocation Bottleneck
+**Learning:** `byte.toString(16).padStart(2, '0').toUpperCase()` inside a loop allocating up to 3 strings per reserved byte in a hot path causes significant GC pressure. This is a common but dangerous anti-pattern in Kotlin when processing large strings or numerous files in directory crawlers.
+**Action:** Replace chained string operations with direct character mapping and bitwise operations (`ushr`, `and`) when building formatted hex output, which avoids intermediate string creation entirely. Ensure 100% branch coverage with test inputs spanning both < 10 and > 9 hex values.
+## 2026-07-10 - Expensive OS stat calls before cheap in-memory checks\n**Learning:** In Kotlin/Java, checking file properties (like `isDirectory` or `isSymbolicLink`) via `java.nio.file.Files` requires allocating `Path` objects and performs expensive native OS stat calls. When processing file listings, always short-circuit filesystem checks by testing against exclusion lists (using cheap in-memory string operations) before calling methods that touch the filesystem.\n**Action:** Re-ordered conditionals to check `exclude` sets before invoking `Files.isDirectory` and `Files.isSymbolicLink`.
+## 2024-07-11 - [Eliminating Redundant Directory Listings]
+**Learning:** In nested file processing functions like `process_dir` and `process_ignore_file`, executing `curr_dir.listFiles()` repeatedly for the same directory is highly inefficient and creates expensive duplicate OS syscalls.
+**Action:** Always hoist I/O intensive listing operations like `listFiles()` to the highest appropriate scope and pass the result as a parameter to child functions rather than re-evaluating it.
diff --git a/.jules/palette.md b/.jules/palette.md
index 0db5d64..4ee7e42 100644
--- a/.jules/palette.md
+++ b/.jules/palette.md
@@ -17,10 +17,3 @@
## 2026-07-10 - ARIA Label Language Matching
**Learning:** Screen readers rely on the document's language attribute (`lang="ko"`) to select the appropriate pronunciation engine. If semantic descriptors like `aria-label` are provided in a language different from the document language (e.g., English text in a Korean document), they may be mispronounced or skipped entirely, breaking accessibility.
**Action:** Always ensure that `aria-label`, `alt` text, and other hidden semantic text strings match the language specified in the document's `` tag for proper localization and screen reader compatibility.
-## 2026-07-11 - Safari VoiceOver List Semantics Fix
-**Learning:** Using `list-style: none` or `list-style-type: none` in CSS causes Safari (VoiceOver) to remove list semantics entirely, which harms accessibility for screen reader users trying to navigate directory listings.
-**Action:** Always explicitly add `role="list"` to `
` or `` elements when removing their default list styling to preserve accessibility semantics across all browsers.
-
-## 2026-07-12 - Inline Colors vs Dark Mode
-**Learning:** Hardcoded inline colors (like `color: #666`) fail to adapt in dark mode because they bypass CSS media queries and cannot be easily overridden without `!important`, causing text legibility issues on dark backgrounds.
-**Action:** Use `opacity: 0.7` instead of hardcoded hex values for muted text to dynamically inherit theme colors and ensure legibility across all color schemes.
diff --git a/.jules/sentinel.md b/.jules/sentinel.md
index aba452c..a21dad7 100644
--- a/.jules/sentinel.md
+++ b/.jules/sentinel.md
@@ -18,10 +18,10 @@
**Learning:** `File.listFiles()` returns null (not empty) on unreadable directories. Directory crawlers must reject symlink roots, skip symlink children, and avoid enqueueing paths deeper than the configured traversal limit.
**Prevention:** Use `java.nio.file.Files.isSymbolicLink(file.toPath())` for root and child directory checks, gracefully handle `null` arrays from `listFiles()` and `list()`, and only enqueue child directories when the current level is still below `maxLevel`.
-## 2024-06-28 - [html4tree] 정적 HTML 생성 보안
-**Vulnerability:** 심층 방어 누락 (CSP 누락)
-**Learning:** 입력값이 적절히 이스케이프 되더라도, 파일/디렉토리 구조를 표시하는 정적 생성 HTML은 잠재적인 XSS 우회 공격에 대비하여 추가적인 방어 계층을 제공하는 콘텐츠 보안 정책(CSP)을 반드시 구현해야 합니다.
-**Prevention:** 외부 스크립트나 리소스가 필요하지 않은 경우 자동 생성되는 HTML 헤더에 엄격한 CSP 메타 태그(예: `default-src 'none'; style-src 'nonce-...'; base-uri 'none'; form-action 'none';`)를 포함하고, 스타일 블록에는 일회성 nonce를 부여하십시오.
+## 2024-06-28 - [html4tree] Static HTML Generation Security
+**Vulnerability:** Defense in Depth (CSP Missing)
+**Learning:** Even when inputs are properly escaped, statically generated HTML that displays file/directory structures should implement a Content Security Policy (CSP) to provide an extra layer of defense against potential XSS bypasses.
+**Prevention:** Include a strict CSP meta tag (e.g., `default-src 'none'; style-src 'unsafe-inline';`) in auto-generated HTML headers when external scripts or resources are not required.
## 2024-05-31 - [CRITICAL] 심볼릭 링크 검사 우회 취약점 (canonicalFile)
**Vulnerability:** `File(path).canonicalFile`를 사용하여 심볼릭 링크 여부를 검사하면, `canonicalFile` 함수 내부에서 심볼릭 링크를 이미 대상(target) 파일의 실제 경로로 해석(resolve)해 버리기 때문에, 이후에 진행되는 `Files.isDirectory(..., LinkOption.NOFOLLOW_LINKS)` 등의 심볼릭 링크 제한 검사가 완전히 무력화되는 취약점이 발견되었습니다.
@@ -45,11 +45,3 @@
**Vulnerability:** The `.html4ignore` parser still allowed excessively large files and root-directory crawls could generate unbounded filesystem output.
**Learning:** Local CLI configuration inputs and traversal roots need explicit resource ceilings, not only syntactic validation.
**Prevention:** Limit `.html4ignore` file size, parsed line count, compiled pattern count, and regex length; reject filesystem root traversal using `File.parentFile != null`.
-## 2024-07-11 - [.html4ignore 파일의 ReDoS 취약점 수정 (Glob 패턴 적용)]
-**Vulnerability:** [사용자가 제공한 `.html4ignore` 패턴을 직접 정규표현식으로 컴파일하여 발생하는 ReDoS(정규표현식 서비스 거부) 취약점 발견.]
-**Learning:** [필터링 패턴으로 정규표현식을 직접 노출하면 악의적으로 조작된 긴 문자열이나 복잡한 패턴을 통해 애플리케이션의 리소스를 고갈시킬 수 있음.]
-**Prevention:** [사용자 입력 패턴은 정규표현식으로 변환하기 전 `java.nio.file.FileSystems.getDefault().getPathMatcher("glob:$pattern")`와 같은 안전한 Glob 매칭 방식을 사용해야 함.]
-## 2024-07-12 - [html4tree] Unhandled File/Directory Permissions causing DoS
-**Vulnerability:** When encountering an unreadable `.html4ignore` file or a directory without write permissions, an unhandled `java.io.FileNotFoundException` or `java.nio.file.AccessDeniedException` was thrown, respectively. This causes the entire crawler to crash (DoS) when encountering files/directories with restricted permissions.
-**Learning:** Application processes that recursively scan filesystem directories must gracefully handle permission denied exceptions to ensure that one inaccessible node does not halt the entire scanning/processing operation.
-**Prevention:** Check `.canRead()` before attempting to parse configuration/ignore files, and wrap file writing operations in `try-catch` blocks to securely handle `AccessDeniedException` or other `IOException`s, failing gracefully (Fail Securely) rather than crashing the application.
diff --git a/src/main/kotlin/html4tree/main.kt b/src/main/kotlin/html4tree/main.kt
index c14010f..3141b09 100644
--- a/src/main/kotlin/html4tree/main.kt
+++ b/src/main/kotlin/html4tree/main.kt
@@ -1,11 +1,9 @@
package html4tree
import java.io.File
-import java.security.SecureRandom
import java.nio.file.Files
import java.nio.file.LinkOption
import java.nio.file.StandardCopyOption
-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
@@ -23,17 +21,8 @@ class Html4tree : CliktCommand() {
fun main(args: Array) = Html4tree().main(args)
-private val nonceRandom = SecureRandom()
-
-fun generate_csp_nonce(): String {
- val nonceBytes = ByteArray(16)
- nonceRandom.nextBytes(nonceBytes)
- return Base64.getEncoder().encodeToString(nonceBytes)
-}
-
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()
@@ -51,13 +40,17 @@ fun go(topDir: String, maxLevel: Int) {
while(lle != null && Files.isDirectory(lle.file.toPath(), LinkOption.NOFOLLOW_LINKS)){
val currentLevel: Int = lle.level
+ // ⚡ Bolt Performance Optimization: Hoist listFiles() out of nested functions
+ // By fetching directory contents once, we eliminate redundant OS syscalls
+ // in process_dir and process_ignore_file (saves 2x I/O calls per directory).
+ val dir_files = lle.file.listFiles()?.toList() ?: emptyList()
+
if(maxLevel == -1 || currentLevel <= maxLevel)
- process_dir(lle.file)
+ process_dir(lle.file, dir_files)
if(maxLevel == -1 || currentLevel < maxLevel) {
- val exclude = process_ignore_file(lle.file)
- lle.file.listFiles()?.forEach {
- if(Files.isDirectory(it.toPath(), LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(it.toPath()) && it.name !in exclude) {
+ dir_files.forEach {
+ if(Files.isDirectory(it.toPath(), LinkOption.NOFOLLOW_LINKS)){
ll.push( LinkedListEntry(it, currentLevel+1))
}
}
@@ -121,7 +114,7 @@ fun String.urlEncodePath(): String {
return encoded.toString()
}
-fun process_ignore_file(curr_dir: File): Set {
+fun process_ignore_file(curr_dir: File, dir_files: List): Set {
val ignore_filename = ".html4ignore"
@@ -133,9 +126,8 @@ fun process_ignore_file(curr_dir: File): Set {
// 보안 향상: .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()
+ if(ignore_file.isFile && !Files.isSymbolicLink(ignore_file.toPath()) && ignore_file.length() <= 1048576){
+ val ignored_regexes = mutableListOf()
ignore_file.useLines { lines ->
for ((lineIndex, it) in lines.withIndex()) {
@@ -144,23 +136,20 @@ fun process_ignore_file(curr_dir: File): Set {
val pattern = it.trim()
if (pattern.isNotEmpty() && pattern.length <= 100) {
try {
- ignored_matchers.add(java.nio.file.FileSystems.getDefault().getPathMatcher("glob:$pattern"))
- } catch (_: java.util.regex.PatternSyntaxException) {
+ ignored_regexes.add(("^"+pattern+"$").toRegex())
+ } catch (_: IllegalArgumentException) {
}
}
}
}
- // ⚡ Bolt Performance Optimization: 디렉토리 목록을 Set에 추가하기 위해 필터링만 할 때는 정렬이 불필요하므로 .sorted()를 제거하여 O(N log N) 오버헤드를 방지합니다.
- curr_dir.list()?.forEach {
+ dir_files.map { it.name }.sorted().forEach {
val current = it
- val pathCurrent = java.nio.file.Paths.get(current)
- for (matcher in ignored_matchers) {
- if (matcher.matches(pathCurrent)) {
+ ignored_regexes.forEach { regex ->
+ if(regex.matches(current)){
files_to_exclude.add(current)
- break
}
- }
+ }
}
}
@@ -185,37 +174,27 @@ fun write_index_file(curr_dir: File, content: String) {
}
}
-fun process_dir(curr_dir: File){
+fun process_dir(curr_dir: File, dir_files: List){
- val exclude: Set = process_ignore_file(curr_dir)
- val styleNonce = generate_csp_nonce()
+ val exclude: Set = process_ignore_file(curr_dir, dir_files)
val css = """
-
"""
@@ -250,7 +224,7 @@ fun process_dir(curr_dir: File){
-
+
${curr_dir.getName().escapeHtml()}
${css}
@@ -258,16 +232,16 @@ fun process_dir(curr_dir: File){