diff --git a/.jules/bolt.md b/.jules/bolt.md index ee124e69..aaa82bd5 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-26 - 조건 분기 전 불필요한 문자열 할당 지연 (Lazy Allocation) +**Learning:** `process_ignore_file` 내에서 디렉토리 목록을 순회할 때, 파일이 숨김 파일이거나 틸드(`~`)로 끝나는지 검사하는 저렴한 조건보다 앞서 무조건 `it.toLowerCase()`를 호출하여 새로운 문자열 객체를 할당하는 오버헤드가 있었습니다. +**Action:** 논리합(`||`) 연산자의 단락 평가(short-circuit) 특성을 활용하여, 할당 비용이 없는 단순 문자열/문자 검사를 먼저 수행하도록 순서를 변경했습니다. 이를 통해 해당 조건을 만족하는 파일에 대해서는 `toLowerCase()` 할당을 지연(건너뛰기)하여 GC 부하를 줄일 수 있습니다. diff --git a/src/main/kotlin/html4tree/main.kt b/src/main/kotlin/html4tree/main.kt index 0972fa2c..c3f80fa1 100644 --- a/src/main/kotlin/html4tree/main.kt +++ b/src/main/kotlin/html4tree/main.kt @@ -351,13 +351,14 @@ fun process_ignore_file(curr_dir: File, dirFilesNames: Array? = null): S // 보안 향상: dot-like prefixes and case variants of known sensitive names are excluded. (dirFilesNames ?: curr_dir.list())?.forEach { - val normalizedName = it.toLowerCase(java.util.Locale.ROOT) + // ⚡ Bolt Performance Optimization: Delay expensive string allocation (toLowerCase) + // Check cheap conditions first before allocating a new lowercase string for every file if ( it.isHiddenFile() || - normalizedName in Constants.defaultSensitiveFileNamesLowercase || - normalizedName.endsWith("~") || - Constants.defaultSensitiveExtensions.any { extension -> - normalizedName.endsWith(extension) + it.endsWith("~") || + it.toLowerCase(java.util.Locale.ROOT).let { normalizedName -> + normalizedName in Constants.defaultSensitiveFileNamesLowercase || + Constants.defaultSensitiveExtensions.any { extension -> normalizedName.endsWith(extension) } } ) { files_to_exclude.add(it) diff --git a/src/main/kotlin/html4tree/main.kt.orig b/src/main/kotlin/html4tree/main.kt.orig new file mode 100644 index 00000000..3749ba42 --- /dev/null +++ b/src/main/kotlin/html4tree/main.kt.orig @@ -0,0 +1,529 @@ +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() + + // 보안 향상: .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) 오버헤드를 방지합니다. + val list = dirFilesNames ?: curr_dir.list() + list?.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. + (dirFilesNames ?: curr_dir.list())?.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" + ) +}