Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions macos/Sources/Features/Terminal/BaseTerminalController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -869,8 +869,7 @@ class BaseTerminalController: NSWindowController,
guard let window else { return }

if derivedConfig.macosTitlebarProxyIcon == .visible {
// Use the 'to' URL directly
window.representedURL = to
window.representedURL = RestorablePath.existingDirectoryURL(to)
} else {
window.representedURL = nil
}
Expand Down Expand Up @@ -1166,6 +1165,10 @@ class BaseTerminalController: NSWindowController,
fullscreenStyle?.delegate = self
}

if RestorablePath.existingDirectoryURL(window.representedURL) == nil {
window.representedURL = nil
}

// Set our update overlay state
updateOverlayIsVisible = defaultUpdateOverlayVisibility()
}
Expand Down
7 changes: 6 additions & 1 deletion macos/Sources/Features/Terminal/TerminalController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,8 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
}

func restoreWorktreeTabRootPath(_ path: String?) {
setWorktreeTabRootPath(path)
let sanitized = RestorablePath.normalizedExistingDirectoryPath(path)
setWorktreeTabRootPath(sanitized)
}

private static func existingWorktreeTabController(forWorktreePath path: String) -> TerminalController? {
Expand Down Expand Up @@ -1994,6 +1995,10 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
// Called when the window will be encoded. We handle the data encoding here in the
// window controller.
func window(_ window: NSWindow, willEncodeRestorableState state: NSCoder) {
if RestorablePath.existingDirectoryURL(window.representedURL) == nil {
window.representedURL = nil
}

let data = TerminalRestorableState(from: self)
data.encode(with: state)
}
Expand Down
2 changes: 1 addition & 1 deletion macos/Sources/Features/Terminal/TerminalRestorable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ class TerminalRestorableState: TerminalRestorable {
self.effectiveFullscreenMode = controller.fullscreenStyle?.fullscreenMode
self.tabColor = (controller.window as? TerminalWindow)?.tabColor ?? .none
self.titleOverride = controller.titleOverride
self.worktreeTabRootPath = controller.worktreeTabRootPath
self.worktreeTabRootPath = RestorablePath.normalizedExistingDirectoryPath(controller.worktreeTabRootPath)
}

required init(copy other: TerminalRestorableState) {
Expand Down
6 changes: 4 additions & 2 deletions macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1728,7 +1728,8 @@ extension Ghostty {
let container = try decoder.container(keyedBy: CodingKeys.self)
let uuid = UUID(uuidString: try container.decode(String.self, forKey: .uuid))
var config = Ghostty.SurfaceConfiguration()
config.workingDirectory = try container.decode(String?.self, forKey: .pwd)
let decodedPwd = try container.decode(String?.self, forKey: .pwd)
config.workingDirectory = RestorablePath.normalizedExistingDirectoryPath(decodedPwd)
let savedTitle = try container.decodeIfPresent(String.self, forKey: .title)
let isUserSetTitle = try container.decodeIfPresent(Bool.self, forKey: .isUserSetTitle) ?? false

Expand All @@ -1746,7 +1747,8 @@ extension Ghostty {

func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(pwd, forKey: .pwd)
let persistedPwd = RestorablePath.normalizedExistingDirectoryPath(pwd)
try container.encode(persistedPwd, forKey: .pwd)
try container.encode(id.uuidString, forKey: .uuid)
try container.encode(title, forKey: .title)
try container.encode(titleFromTerminal != nil, forKey: .isUserSetTitle)
Expand Down
27 changes: 27 additions & 0 deletions macos/Sources/Helpers/RestorablePath.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import Foundation

enum RestorablePath {
static func normalizedExistingDirectoryPath(_ path: String?) -> String? {
guard let path else { return nil }
let standardized = URL(fileURLWithPath: path).standardizedFileURL.path
var isDirectory: ObjCBool = false
guard FileManager.default.fileExists(atPath: standardized, isDirectory: &isDirectory),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate raw restore paths before canonicalizing

normalizedExistingDirectoryPath standardizes the input before checking existence, which can turn an invalid path into a valid parent (for example, "/tmp/missing/../" becomes "/tmp" and passes). Because this helper is now used for restoring and persisting pwd/worktree state, malformed or stale paths containing .. can be silently accepted as a different directory instead of being rejected, undermining the deleted-worktree guard.

Useful? React with 👍 / 👎.

isDirectory.boolValue else {
return nil
}

return standardized
}

static func existingDirectoryURL(_ url: URL?) -> URL? {
guard let url else { return nil }
let standardized = url.standardizedFileURL
var isDirectory: ObjCBool = false
guard FileManager.default.fileExists(atPath: standardized.path, isDirectory: &isDirectory),
isDirectory.boolValue else {
return nil
}

return standardized
}
}
43 changes: 43 additions & 0 deletions macos/Tests/RestorablePathTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import Foundation
import Testing
@testable import Ghostree

struct RestorablePathTests {
@Test func normalizedExistingDirectoryPathReturnsStandardizedDirectory() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
let directory = root.appendingPathComponent("worktree", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }

let input = directory.appendingPathComponent("..", isDirectory: true)
.appendingPathComponent("worktree", isDirectory: true).path
let result = RestorablePath.normalizedExistingDirectoryPath(input)

#expect(result == directory.standardizedFileURL.path)
}

@Test func normalizedExistingDirectoryPathRejectsMissingOrFilePaths() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }

let missing = root.appendingPathComponent("missing", isDirectory: true).path
#expect(RestorablePath.normalizedExistingDirectoryPath(missing) == nil)

let file = root.appendingPathComponent("file.txt")
try "x".write(to: file, atomically: true, encoding: .utf8)
#expect(RestorablePath.normalizedExistingDirectoryPath(file.path) == nil)
}

@Test func existingDirectoryURLReturnsOnlyExistingDirectoryURLs() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }

let existing = RestorablePath.existingDirectoryURL(root)
#expect(existing?.path == root.standardizedFileURL.path)

let missing = root.appendingPathComponent("gone", isDirectory: true)
#expect(RestorablePath.existingDirectoryURL(missing) == nil)
}
}
Loading