fix: harden config-file include scanning

- Add cycle detection via visited path set to prevent infinite recursion
  on cyclic config-file includes.
- Resolve relative include paths against the parent directory of the
  including config file.
- Strip trailing '?' from optional include paths (Ghostty convention).
- Use UUID-based path for missing file test.
- Add tests for relative includes, optional includes, and cyclic includes.
This commit is contained in:
atani 2026-03-06 23:28:59 +09:00
parent 12e91aa4fe
commit a0ba82f8be
2 changed files with 78 additions and 8 deletions

View file

@ -1109,9 +1109,10 @@ class GhosttyApp {
"~/Library/Application Support/com.mitchellh.ghostty/config.ghostty",
]
) -> Bool {
var visited = Set<String>()
for rawPath in configPaths {
let path = NSString(string: rawPath).expandingTildeInPath
if Self.configFileContainsCodepointMap(atPath: path) {
if Self.configFileContainsCodepointMap(atPath: path, visited: &visited) {
return true
}
}
@ -1119,11 +1120,21 @@ class GhosttyApp {
}
/// Scans a single config file (and any files it includes) for
/// `font-codepoint-map` entries.
private static func configFileContainsCodepointMap(atPath path: String) -> Bool {
guard let contents = try? String(contentsOfFile: path, encoding: .utf8) else {
/// `font-codepoint-map` entries. Tracks visited paths to prevent
/// infinite recursion on cyclic includes.
private static func configFileContainsCodepointMap(
atPath path: String,
visited: inout Set<String>
) -> Bool {
let resolved = (path as NSString).standardizingPath
guard !visited.contains(resolved) else { return false }
visited.insert(resolved)
guard let contents = try? String(contentsOfFile: resolved, encoding: .utf8) else {
return false
}
let parentDir = (resolved as NSString).deletingLastPathComponent
for line in contents.components(separatedBy: .newlines) {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("#") { continue }
@ -1133,11 +1144,18 @@ class GhosttyApp {
if trimmed.hasPrefix("config-file") {
let parts = trimmed.split(separator: "=", maxSplits: 1)
if parts.count == 2 {
let includePath = parts[1]
var includePath = parts[1]
.trimmingCharacters(in: .whitespaces)
.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
let resolved = NSString(string: includePath).expandingTildeInPath
if configFileContainsCodepointMap(atPath: resolved) {
// Ghostty supports optional includes with a trailing '?'
if includePath.hasSuffix("?") {
includePath = String(includePath.dropLast())
}
let expanded = NSString(string: includePath).expandingTildeInPath
let absolute = (expanded as NSString).isAbsolutePath
? expanded
: (parentDir as NSString).appendingPathComponent(expanded)
if configFileContainsCodepointMap(atPath: absolute, visited: &visited) {
return true
}
}

View file

@ -1431,8 +1431,9 @@ final class GhosttyMouseFocusTests: XCTestCase {
}
func testUserConfigContainsCJKCodepointMapReturnsFalseForMissingFiles() {
let path = NSTemporaryDirectory() + "cmux-nonexistent-\(UUID().uuidString)/config"
XCTAssertFalse(
GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: ["/nonexistent/path/config"])
GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path])
)
}
@ -1452,4 +1453,55 @@ final class GhosttyMouseFocusTests: XCTestCase {
XCTAssertTrue(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [main.path]))
}
func testUserConfigContainsCJKCodepointMapFollowsRelativeIncludes() throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-test-cjk-rel-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let included = dir.appendingPathComponent("fonts.conf")
try "font-codepoint-map = U+4E00-U+9FFF=Hiragino Sans\n"
.write(to: included, atomically: true, encoding: .utf8)
let main = dir.appendingPathComponent("config")
try "config-file = fonts.conf\n"
.write(to: main, atomically: true, encoding: .utf8)
XCTAssertTrue(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [main.path]))
}
func testUserConfigContainsCJKCodepointMapHandlesOptionalInclude() throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-test-cjk-opt-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let included = dir.appendingPathComponent("fonts.conf")
try "font-codepoint-map = U+4E00-U+9FFF=Hiragino Sans\n"
.write(to: included, atomically: true, encoding: .utf8)
let main = dir.appendingPathComponent("config")
try "config-file = \(included.path)?\n"
.write(to: main, atomically: true, encoding: .utf8)
XCTAssertTrue(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [main.path]))
}
func testUserConfigContainsCJKCodepointMapHandlesCyclicIncludes() throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-test-cjk-cycle-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let fileA = dir.appendingPathComponent("a.conf")
let fileB = dir.appendingPathComponent("b.conf")
try "config-file = \(fileB.path)\n"
.write(to: fileA, atomically: true, encoding: .utf8)
try "config-file = \(fileA.path)\n"
.write(to: fileB, atomically: true, encoding: .utf8)
// Should not hang; should return false since neither file has font-codepoint-map
XCTAssertFalse(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [fileA.path]))
}
}