diff --git a/.jules/bolt.md b/.jules/bolt.md index 19b4c613..448462c6 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -43,3 +43,6 @@ ## 2025-01-24 - 단일 readAttributes 호출로 파일 속성 조회 최적화 **학습:** `isDirectory`, `!it.isDirectory()`, `isSymbolicLink` 3개의 개별적인 파일 시스템 I/O 호출을 수행하면 성능 저하가 큽니다. 이를 단일 `Files.readAttributes` 호출로 변경하여 메타데이터를 한 번에 조회함으로써 I/O 오버헤드를 대폭 줄일 수 있음을 확인했습니다. **조치:** 디렉토리 순회 시 파일의 여러 속성을 확인할 때는 개별적인 stat 호출보다 `Files.readAttributes`를 사용하여 필요한 모든 속성을 한 번에 가져오는 방식을 우선적으로 고려해야 합니다. +## 2026-08-08 - 반복적인 리스트 할당 제거를 통한 최적화 +**Learning:** Kotlin에서 무시 파일을 처리할 때(process_ignore_file) 파일 반복 루프 내에서 `listOf(...)`를 지속적으로 할당하면 불필요한 메모리 할당 및 가비지 컬렉션(GC) 오버헤드가 발생합니다. +**Action:** 빈번하게 호출되는 루프 내부의 정적 컬렉션 또는 값 할당은 `private object` 내부로 추출하여 단 한 번만 인스턴스화되도록 하고, `@JvmField`를 활용해 접근 오버헤드를 최소화합니다. diff --git a/patch.diff b/patch.diff new file mode 100644 index 00000000..1faebfdd --- /dev/null +++ b/patch.diff @@ -0,0 +1,26 @@ +--- src/main/kotlin/html4tree/main.kt ++++ src/main/kotlin/html4tree/main.kt +@@ -10,6 +10,13 @@ + import com.github.ajalt.clikt.parameters.arguments.argument + import com.github.ajalt.clikt.parameters.types.int + ++private object IgnoreRules { ++ // ⚡ Bolt Performance Optimization: Extract frequently allocated list to a static property ++ // Avoids redundant list allocations for every directory processed during crawling. ++ @JvmField ++ val DEFAULT_SENSITIVE_FILES = listOf(".git", ".env", ".ssh", ".htpasswd", ".htaccess", "id_rsa", "id_ed25519", "secrets.yml", ".html4ignore", ".DS_Store", ".aws", ".kube", ".npmrc", ".gnupg", "config.json", "credentials.json") ++} ++ + private val CSS_CONTENT = """ + body { + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +@@ -219,8 +226,7 @@ + files_to_exclude.add("index.html") + + // 보안 향상: 민감한 시스템, 설정, 시크릿 파일을 디렉토리 목록에서 기본적으로 제외하여 정보 노출(Information Exposure) 방지 +- 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") +- files_to_exclude.addAll(defaultSensitiveFiles) ++ files_to_exclude.addAll(IgnoreRules.DEFAULT_SENSITIVE_FILES) + + // 보안 향상: .env, .git 등 민감한 정보가 포함될 수 있는 숨김 파일(.으로 시작하는 모든 항목)을 기본적으로 노출하지 않도록 제외 (정보 노출 방지) + (dirFilesNames ?: curr_dir.list())?.forEach { diff --git a/src/main/kotlin/html4tree/main.kt b/src/main/kotlin/html4tree/main.kt index f52a1468..d0a33015 100644 --- a/src/main/kotlin/html4tree/main.kt +++ b/src/main/kotlin/html4tree/main.kt @@ -13,6 +13,13 @@ 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 object IgnoreRules { + // ⚡ Bolt Performance Optimization: Extract frequently allocated list to a static property + // Avoids redundant list allocations for every directory processed during crawling. + @JvmField + val DEFAULT_SENSITIVE_FILES = listOf(".git", ".env", ".ssh", ".htpasswd", ".htaccess", "id_rsa", "id_ed25519", "secrets.yml", ".html4ignore", ".DS_Store", ".aws", ".kube", ".npmrc", ".gnupg", "config.json", "credentials.json") +} + private val CSS_CONTENT = """ body { font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; @@ -298,8 +305,7 @@ fun process_ignore_file(curr_dir: File, dirFilesNames: Array? = null): S files_to_exclude.add("index.html") // 보안 향상: 민감한 시스템, 설정, 시크릿 파일을 디렉토리 목록에서 기본적으로 제외하여 정보 노출(Information Exposure) 방지 - 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") - files_to_exclude.addAll(defaultSensitiveFiles) + files_to_exclude.addAll(IgnoreRules.DEFAULT_SENSITIVE_FILES) // 보안 향상: .env, .git 등 민감한 정보가 포함될 수 있는 숨김 파일(.으로 시작하는 모든 항목)을 기본적으로 노출하지 않도록 제외 (정보 노출 방지) (dirFilesNames ?: curr_dir.list())?.forEach { diff --git a/src/main/kotlin/html4tree/main.kt.orig b/src/main/kotlin/html4tree/main.kt.orig new file mode 100644 index 00000000..434abc5d --- /dev/null +++ b/src/main/kotlin/html4tree/main.kt.orig @@ -0,0 +1,408 @@ +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; +} +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.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.5rem; + color: #656d76; + font-style: italic; +} +@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))) + +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() }, + isDirectory: (File) -> Boolean = { Files.isDirectory(it.toPath(), LinkOption.NOFOLLOW_LINKS) }, + isSymbolicLink: (File) -> Boolean = { Files.isSymbolicLink(it.toPath()) }, + readIdentity: (File) -> FileIdentity = ::read_file_identity +) { + var lle: LinkedListEntry? = ll.pull() + + while(lle != null){ + if (!isDirectory(lle.file)) { + 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) + val dirFilesNames = dirFiles?.map { it.name }?.toTypedArray() + 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 (isDirectory/isSymbolicLink) + // by checking cheap in-memory string exclusion rules first + if(!it.name.startsWith(".") && it.name !in exclude && isDirectory(it) && !isSymbolicLink(it)) { + val childEntry = LinkedListEntry(it, currentLevel+1, readIdentity(it).key) + ll.push(childEntry) + } + } + } + lle = ll.pull() + } +} + +// ⚡ 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() + + // 보안 향상: .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 (_: java.util.regex.PatternSyntaxException) { + } + } + } + } + + // ⚡ Bolt Performance Optimization: 디렉토리 목록을 Set에 추가하기 위해 필터링만 할 때는 정렬이 불필요하므로 .sorted()를 제거하여 O(N log N) 오버헤드를 방지합니다. + val list = dirFilesNames ?: curr_dir.list() + list?.forEach { + val current = it + val pathCurrent = java.nio.file.Paths.get(current) + 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") + + // 보안 향상: 민감한 시스템, 설정, 시크릿 파일을 디렉토리 목록에서 기본적으로 제외하여 정보 노출(Information Exposure) 방지 + 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") + files_to_exclude.addAll(defaultSensitiveFiles) + + // 보안 향상: .env, .git 등 민감한 정보가 포함될 수 있는 숨김 파일(.으로 시작하는 모든 항목)을 기본적으로 노출하지 않도록 제외 (정보 노출 방지) + (dirFilesNames ?: curr_dir.list())?.forEach { + if (it.startsWith(".")) { + files_to_exclude.add(it) + } + } + + return files_to_exclude +} + +fun write_index_file(curr_dir: File, content: String) { + 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)) + Files.move(tempPath, indexPath, 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 index_top = """ + + + + + + + + + + ${curr_dir.getName().escapeHtml()} + + + +
+

${curr_dir.getName().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!") +}