diff --git a/README.md b/README.md index 095302a..b3dbb81 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ Requires **macOS 14+** and **Xcode Command Line Tools** (`xcode-select --install ### Workflow - **Tabs and multi-window** — open many files in one window (`⌘O` adds a tab) or pop a new window with `⌘N`. Each tab carries its own scroll, theme, font scale, and collaborator registry. `⌘W` closes the active tab when more than one is open, otherwise the window. +- **SSH profiles** — the network toolbar button opens the favorite root from `~/Library/Application Support/Mindle/ssh-profiles.yaml`. The generated template is disabled until edited, remote listings fail if their configured root is missing, and Markdown plus allowlisted referenced images are cached in a host-keyed local mirror. - **Open remote URLs** — `⌘⇧L` opens any `http(s)` URL pointing at raw Markdown (a GitHub raw link, a hosted doc) as a tab. Annotations on URL documents persist locally keyed by URL hash, so re-opening the same URL brings the annotations back. - **Open from Clipboard** — `⌘⇧V` opens the pasteboard contents as a Markdown tab. Use this when the source is behind a login your browser already handles (internal GitLab raw, Confluence, anything auth-walled): copy the raw Markdown there, paste it here. Tabs are content-addressed, so re-pasting identical text re-opens the same tab with its prior annotations. - **File browser** — scoped sidebar tree of every `.md` and `.txt` in the current folder (`⌘⇧F`). Never escapes upward. diff --git a/Resources/web/reader.js b/Resources/web/reader.js index 4aa75da..59d53e0 100644 --- a/Resources/web/reader.js +++ b/Resources/web/reader.js @@ -1219,47 +1219,59 @@ function rewriteImages() { const imgs = doc.querySelectorAll("img"); imgs.forEach(img => { - const src = img.getAttribute("src") || ""; - const res = resolveImageSrc(src); - if (res.blocked) { - const ph = document.createElement("span"); - ph.className = "mindle-img-blocked"; - ph.textContent = "[remote image hidden — " + (img.alt || src) + "]"; - img.replaceWith(ph); - } else if (res.url !== null && res.url !== src) { - img.setAttribute("src", res.url); - img.addEventListener("error", () => { - const ph = document.createElement("span"); - ph.className = "mindle-img-missing"; - ph.textContent = "[image not found — " + (img.alt || src) + "]"; - img.replaceWith(ph); - }); - } else if (res.url !== null) { - // Left as-is (data: URL etc.) — still add broken-image handler. - img.addEventListener("error", () => { - const ph = document.createElement("span"); - ph.className = "mindle-img-missing"; - ph.textContent = "[image not found — " + (img.alt || src) + "]"; - img.replaceWith(ph); - }); + let src = ""; + try { + src = img.getAttribute("src") || ""; + const res = resolveImageSrc(src); + if (res.blocked) { + replaceImageWithPlaceholder(img, "mindle-img-blocked", "remote image hidden", src); + } else if (res.url !== null) { + if (res.url !== src) img.setAttribute("src", res.url); + img.addEventListener("error", () => { + replaceImageWithPlaceholder(img, "mindle-img-missing", "image not found", src); + }); + } + } catch (error) { + console.error("Mindle couldn't rewrite an image source.", { src, error }); + replaceImageWithPlaceholder(img, "mindle-img-missing", "invalid image path", src); } }); } + function replaceImageWithPlaceholder(img, className, message, src) { + const ph = document.createElement("span"); + ph.className = className; + ph.textContent = "[" + message + " — " + (img.alt || src || "image") + "]"; + img.replaceWith(ph); + } + function resolveImageSrc(src) { if (!src) return { url: null }; if (src.startsWith("data:")) return { url: src }; if (/^https?:/i.test(src)) return { blocked: true }; + const pathSrc = decodeImagePath(src); if (/^file:\/\//i.test(src)) { - const path = src.replace(/^file:\/\//i, ""); - return { url: "mindle-file://" + path }; + const path = pathSrc.replace(/^file:\/\//i, ""); + return { url: "mindle-file://" + encodeImagePath(path) }; + } + if (pathSrc.startsWith("/")) { + return { url: "mindle-file://" + encodeImagePath(pathSrc) }; } - if (src.startsWith("/")) { - return { url: "mindle-file://" + encodeURI(src) }; + if (!baseDir) return { url: pathSrc }; + const resolved = resolveRelativePath(baseDir, pathSrc); + return { url: "mindle-file://" + encodeImagePath(resolved) }; + } + + function decodeImagePath(src) { + try { + return decodeURIComponent(src); + } catch (_) { + return src; } - if (!baseDir) return { url: src }; - const resolved = resolveRelativePath(baseDir, src); - return { url: "mindle-file://" + encodeURI(resolved) }; + } + + function encodeImagePath(path) { + return path.split("/").map(encodeURIComponent).join("/"); } function resolveRelativePath(base, rel) { diff --git a/Sources/mindle/ContentView.swift b/Sources/mindle/ContentView.swift index f9cad66..5bc2723 100644 --- a/Sources/mindle/ContentView.swift +++ b/Sources/mindle/ContentView.swift @@ -13,7 +13,7 @@ struct ContentView: View { // the material bleeds to the window's system-gray backing. c.background.ignoresSafeArea() - if store.fileURL == nil { + if store.fileURL == nil && !store.fileBrowserHasRoot { EmptyStateView() } else { VStack(spacing: 0) { @@ -25,8 +25,13 @@ struct ContentView: View { FileBrowserSidebar() .frame(minWidth: 200, idealWidth: 260, maxWidth: 400) } - ReaderPane() - .frame(minWidth: 480) + if store.fileURL == nil { + RemoteBrowserPlaceholder() + .frame(minWidth: 480) + } else { + ReaderPane() + .frame(minWidth: 480) + } if store.showAnnotations { AnnotationsSidebar() .frame(minWidth: 280, idealWidth: 340, maxWidth: 460) @@ -36,6 +41,16 @@ struct ContentView: View { } } .toolbar { + ToolbarItem(placement: .navigation) { + Button { + Task { await store.openFavoriteSSHProfile() } + } label: { + Image(systemName: "network") + .foregroundStyle(c.text) + } + .help("Open favorite SSH profile") + } + ToolbarItem(placement: .navigation) { Button { store.openWithPanel() } label: { Image(systemName: "doc.text") @@ -57,7 +72,7 @@ struct ContentView: View { .foregroundStyle(store.showFileBrowser ? c.accent : c.muted) } .help("Toggle files (⌘⇧F)") - .disabled(store.fileURL == nil) + .disabled(store.fileURL == nil && !store.fileBrowserHasRoot) } ToolbarItem(placement: .principal) { @@ -157,6 +172,27 @@ struct ContentView: View { } } +private struct RemoteBrowserPlaceholder: View { + @EnvironmentObject var store: DocumentStore + + var body: some View { + let c = store.theme.colors + VStack(spacing: 12) { + Image(systemName: "network") + .font(.system(size: 42, weight: .ultraLight)) + .foregroundStyle(c.muted) + Text("Choose a remote document") + .font(.system(size: 18, design: .serif)) + .foregroundStyle(c.text) + Text("Files stay confined to the configured SSH profile root.") + .font(.system(size: 12, design: .serif)) + .foregroundStyle(c.muted) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(c.background) + } +} + // MARK: - Button styles struct ToolChipStyle: ButtonStyle { @@ -1293,9 +1329,9 @@ struct FileBrowserSidebar: View { let c = store.theme.colors VStack(alignment: .leading, spacing: 0) { HStack(spacing: 8) { - Image(systemName: "folder") + Image(systemName: store.fileTree?.url.isMindleSSH == true ? "network" : "folder") .foregroundStyle(c.accent) - Text("Files") + Text(store.fileBrowserTitle) .font(.system(size: 13, weight: .semibold, design: .serif)) .foregroundStyle(c.text) Spacer() @@ -1314,7 +1350,28 @@ struct FileBrowserSidebar: View { Rectangle().fill(c.rule.opacity(0.4)).frame(height: 0.5) - if let tree = store.fileTree, let children = tree.children, !children.isEmpty { + if store.fileBrowserIsLoading { + VStack(spacing: 10) { + ProgressView() + .controlSize(.small) + Text("Loading remote documents…") + .font(.system(size: 12, design: .serif).italic()) + .foregroundStyle(c.muted) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error = store.fileBrowserError { + VStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 24, weight: .ultraLight)) + .foregroundStyle(c.muted) + Text(error) + .multilineTextAlignment(.center) + .font(.system(size: 12, design: .serif)) + .foregroundStyle(c.muted) + .padding(.horizontal, 20) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let tree = store.fileTree, let children = tree.children, !children.isEmpty { ScrollView { // Non-lazy VStack so the tree's content size stays constant // when other window state changes (e.g. fileURL flipping @@ -1336,7 +1393,7 @@ struct FileBrowserSidebar: View { Image(systemName: "tray") .font(.system(size: 28, weight: .ultraLight)) .foregroundStyle(c.muted.opacity(0.7)) - Text("No markdown files\nin this directory.") + Text("No supported documents\nin this directory.") .multilineTextAlignment(.center) .font(.system(size: 12, design: .serif).italic()) .foregroundStyle(c.muted) @@ -1390,9 +1447,14 @@ struct FileTreeRow: View { } } } else { - let isCurrent = store.fileURL?.standardizedFileURL == node.url.standardizedFileURL + let activeSourceURL = store.activeTabID.flatMap { activeID in + store.tabs.first(where: { $0.id == activeID })?.sourceURL + } + let isCurrent = node.url.isMindleSSH + ? activeSourceURL == node.url + : store.fileURL?.standardizedFileURL == node.url.standardizedFileURL Button { - store.open(url: node.url) + store.openBrowserItem(node.url) } label: { HStack(spacing: 6) { Spacer().frame(width: 10) diff --git a/Sources/mindle/DocumentStore.swift b/Sources/mindle/DocumentStore.swift index e74209a..eef3dc5 100644 --- a/Sources/mindle/DocumentStore.swift +++ b/Sources/mindle/DocumentStore.swift @@ -301,6 +301,13 @@ final class DocumentStore: ObservableObject { @Published var showAnnotations: Bool = false @Published var showFileBrowser: Bool = false @Published var fileTree: FileNode? = nil + @Published private(set) var fileBrowserTitle: String = "Files" + @Published private(set) var fileBrowserError: String? + @Published private(set) var fileBrowserIsLoading = false + @Published private(set) var fileBrowserHasRoot = false + @Published private(set) var remoteAssetRevision = 0 + private var activeSSHProfile: SSHProfile? + private var sshProfileLoadGeneration = 0 // Tabs (per-window). Empty when no document is open; otherwise the active // tab's state mirrors `fileURL` / `rawText` / `annotations` above. @@ -456,9 +463,8 @@ final class DocumentStore: ObservableObject { return dir } - /// ~/Library/Application Support/Mindle/ssh-cache/. Holds per-target - /// proxy copies of remote files (`/`) plus their local - /// sidecars. Created on first access. + /// ~/Library/Application Support/Mindle/ssh-cache/. Holds host-keyed + /// mirrors of remote paths plus adjacent local sidecars. static func sshCacheDir() -> URL? { guard let support = FileManager.default.urls( for: .applicationSupportDirectory, in: .userDomainMask).first else { return nil } @@ -537,7 +543,9 @@ final class DocumentStore: ObservableObject { // Re-root the file tree only when the new file is outside the current scope. // Clicking a file inside a subfolder of the current root must preserve rooting. let shouldRebuildTree: Bool - if let root = fileTree?.url { + if remoteTarget != nil { + shouldRebuildTree = false + } else if let root = fileTree?.url { shouldRebuildTree = !Self.isDescendant(url: url, of: root) } else { shouldRebuildTree = true @@ -571,7 +579,13 @@ final class DocumentStore: ObservableObject { // Capture the sidecar-loaded annotations into the tab snapshot. snapshotActiveTab() - if shouldRebuildTree { refreshFileTree() } + if shouldRebuildTree { + activeSSHProfile = nil + fileBrowserTitle = "Files" + fileBrowserError = nil + fileBrowserHasRoot = true + refreshFileTree() + } if url.isFileURL && remoteTarget == nil { NSDocumentController.shared.noteNewRecentDocumentURL(url) } @@ -585,7 +599,7 @@ final class DocumentStore: ObservableObject { /// normal pipeline on that proxy. Dedups on the canonical target across /// already-open tabs. Surfaces failures via an alert; creates no tab on /// failure. - func openRemote(_ target: SSHTarget) async { + func openRemote(_ target: SSHTarget, profile: SSHProfile? = nil) async { guard let cacheDir = Self.sshCacheDir(), let source = target.sourceURL else { NSSound.beep(); return } @@ -594,6 +608,7 @@ final class DocumentStore: ObservableObject { } let proxy = target.proxyURL(cacheDir: cacheDir) do { + try target.migrateLegacyCacheIfNeeded(cacheDir: cacheDir) try await SSHTransport.fetch(target, to: proxy) // A concurrent openRemote for the same target may have created // the tab while our fetch was in flight (the pre-await dedup @@ -603,7 +618,20 @@ final class DocumentStore: ObservableObject { } let kind = DocumentKind.kind(for: proxy) let text: String = (kind == .pdf) ? "" : try String(contentsOf: proxy, encoding: .utf8) + let assetProfile = profile ?? matchingSSHProfile(for: target) + let assetReport = kind == .markdown && assetProfile != nil + ? await SSHTransport.fetchReferencedImages( + in: text, + for: target, + profileRoot: assetProfile!.rootTarget, + cacheDir: cacheDir + ) + : RemoteAssetFetchReport(failures: [], fetchedCount: 0, skippedForLimit: 0) + if kind == .markdown { + remoteAssetRevision &+= 1 + } finishOpen(url: proxy, text: text, kind: kind, sourceURL: source, remoteTarget: target) + presentRemoteAssetReport(assetReport, target: target) } catch { presentRemoteError(title: "Couldn’t open \(target.canonical)", error: error) } @@ -618,6 +646,33 @@ final class DocumentStore: ObservableObject { alert.runModal() } + private func presentRemoteAssetReport( + _ report: RemoteAssetFetchReport, + target: SSHTarget + ) { + guard !report.failures.isEmpty || report.skippedForLimit > 0 else { return } + var lines = report.failures.prefix(3).map { "• \($0.path): \($0.message)" } + if report.failures.count > 3 { + lines.append("…and \(report.failures.count - 3) more image errors.") + } + if report.skippedForLimit > 0 { + lines.append( + "\(report.skippedForLimit) image references were skipped after the " + + "\(RemoteMarkdownAssets.maxAssetsPerDocument)-asset limit." + ) + } + let alert = NSAlert() + alert.messageText = "Opened \(target.canonical), but some images weren’t fetched" + alert.informativeText = lines.joined(separator: "\n") + alert.alertStyle = .warning + alert.runModal() + } + + private func matchingSSHProfile(for target: SSHTarget) -> SSHProfile? { + guard let profiles = try? SSHProfileConfiguration.load() else { return nil } + return SSHProfileConfiguration.profile(containing: target, in: profiles) + } + // MARK: - Live reload /// Re-reads the active file from disk in response to a watcher event. @@ -646,10 +701,29 @@ final class DocumentStore: ObservableObject { /// reload path as a local watcher event so diff-on-reload kicks in when /// the remote file changed underneath us. func reloadRemote() async { - guard let target = activeRemoteTarget, let url = fileURL else { return } + guard let target = activeRemoteTarget, + let url = fileURL, + let cacheDir = Self.sshCacheDir() else { return } do { try await SSHTransport.fetch(target, to: url) - reloadFromDisk() + let kind = DocumentKind.kind(for: url) + let text = kind == .markdown + ? try String(contentsOf: url, encoding: .utf8) + : "" + let profile = matchingSSHProfile(for: target) + let assetReport = kind == .markdown && profile != nil + ? await SSHTransport.fetchReferencedImages( + in: text, + for: target, + profileRoot: profile!.rootTarget, + cacheDir: cacheDir + ) + : RemoteAssetFetchReport(failures: [], fetchedCount: 0, skippedForLimit: 0) + if kind == .markdown { + remoteAssetRevision &+= 1 + reloadFromDisk() + } + presentRemoteAssetReport(assetReport, target: target) } catch { presentRemoteError(title: "Couldn’t refresh \(target.canonical)", error: error) } @@ -1106,6 +1180,10 @@ final class DocumentStore: ObservableObject { focusedAnnotation = nil editingAnnotationID = nil updateSelection(text: "", prefix: "", suffix: "") + if activeSSHProfile == nil { + fileTree = nil + fileBrowserHasRoot = false + } updateWatcher() syncInactiveWatchers() } @@ -1175,11 +1253,71 @@ final class DocumentStore: ObservableObject { static let browsableExtensions: Set = ["md", "markdown", "mdown", "mkd", "txt", "pdf"] func refreshFileTree() { - guard let url = fileURL else { fileTree = nil; return } + if let profile = activeSSHProfile { + Task { await openSSHProfile(profile) } + return + } + guard let url = fileURL else { + fileTree = nil + fileBrowserHasRoot = false + return + } + fileBrowserIsLoading = false + fileBrowserError = nil + fileBrowserTitle = "Files" + fileBrowserHasRoot = true fileTree = Self.buildTree(at: url.deletingLastPathComponent()) } + func openBrowserItem(_ url: URL) { + if let target = SSHTarget(sourceURL: url) { + Task { await openRemote(target, profile: activeSSHProfile) } + } else { + open(url: url) + } + } + + func openFavoriteSSHProfile() async { + do { + let profiles = try SSHProfileConfiguration.load() + guard let profile = SSHProfileConfiguration.favoriteProfile(in: profiles) else { + throw SSHProfileConfigurationError.noProfiles + } + await openSSHProfile(profile) + } catch { + presentRemoteError(title: "Couldn’t open favorite SSH profile", error: error) + } + } + + func openSSHProfile(_ profile: SSHProfile) async { + sshProfileLoadGeneration &+= 1 + let generation = sshProfileLoadGeneration + activeSSHProfile = profile + fileBrowserTitle = profile.name + fileBrowserError = nil + fileBrowserIsLoading = true + fileBrowserHasRoot = true + fileTree = nil + showFileBrowser = true + + do { + let listing = try await SSHTransport.listDocuments(in: profile) + guard activeSSHProfile == profile, + generation == sshProfileLoadGeneration else { return } + fileTree = Self.buildRemoteTree(root: listing.root, files: listing.files) + fileBrowserIsLoading = false + } catch { + guard activeSSHProfile == profile, + generation == sshProfileLoadGeneration else { return } + fileTree = nil + fileBrowserIsLoading = false + fileBrowserError = error.localizedDescription + presentRemoteError(title: "Couldn’t open SSH profile “\(profile.name)”", error: error) + } + } + private static func isDescendant(url: URL, of ancestor: URL) -> Bool { + guard url.isFileURL, ancestor.isFileURL else { return false } let aPath = ancestor.standardizedFileURL.path let uPath = url.standardizedFileURL.path let prefix = aPath.hasSuffix("/") ? aPath : aPath + "/" @@ -1216,6 +1354,65 @@ final class DocumentStore: ObservableObject { return FileNode(url: dir, name: dir.lastPathComponent, isDirectory: true, children: children) } + private static func buildRemoteTree(root: SSHTarget, files: [SSHTarget]) -> FileNode { + final class MutableNode { + let name: String + let target: SSHTarget + let isDirectory: Bool + var children: [String: MutableNode] = [:] + + init(name: String, target: SSHTarget, isDirectory: Bool) { + self.name = name + self.target = target + self.isDirectory = isDirectory + } + } + + let rootNode = MutableNode( + name: (root.remotePath as NSString).lastPathComponent, + target: root, + isDirectory: true + ) + let rootPrefix = root.remotePath.hasSuffix("/") ? root.remotePath : root.remotePath + "/" + + for file in files where file.userHost == root.userHost && file.remotePath.hasPrefix(rootPrefix) { + let relative = String(file.remotePath.dropFirst(rootPrefix.count)) + let components = relative.split(separator: "/", omittingEmptySubsequences: true).map(String.init) + guard !components.isEmpty else { continue } + + var parent = rootNode + var currentPath = root.remotePath + for (index, component) in components.enumerated() { + currentPath = (currentPath as NSString).appendingPathComponent(component) + let isDirectory = index < components.count - 1 + if let existing = parent.children[component] { + parent = existing + } else if let target = SSHTarget(userHost: root.userHost, remotePath: currentPath) { + let node = MutableNode(name: component, target: target, isDirectory: isDirectory) + parent.children[component] = node + parent = node + } + } + } + + func freeze(_ node: MutableNode) -> FileNode { + let children = node.children.values + .sorted { + if $0.isDirectory != $1.isDirectory { return $0.isDirectory } + return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending + } + .map(freeze) + return FileNode( + url: node.target.sourceURL!, + name: node.name, + isDirectory: node.isDirectory, + children: node.isDirectory ? children : nil + ) + } + + return freeze(rootNode) + } + func toggleTheme() { switch theme { case .light: theme = .sepia diff --git a/Sources/mindle/ImageSchemeHandler.swift b/Sources/mindle/ImageSchemeHandler.swift index 8cb4222..486c617 100644 --- a/Sources/mindle/ImageSchemeHandler.swift +++ b/Sources/mindle/ImageSchemeHandler.swift @@ -31,7 +31,8 @@ final class ImageSchemeHandler: NSObject, WKURLSchemeHandler { httpVersion: "HTTP/1.1", headerFields: [ "Content-Type": mime, - "Content-Length": "\(data.count)" + "Content-Length": "\(data.count)", + "Cache-Control": "no-store" ] )! urlSchemeTask.didReceive(resp) diff --git a/Sources/mindle/RemoteMarkdownAssets.swift b/Sources/mindle/RemoteMarkdownAssets.swift new file mode 100644 index 0000000..c60916e --- /dev/null +++ b/Sources/mindle/RemoteMarkdownAssets.swift @@ -0,0 +1,140 @@ +import Foundation + +enum RemoteAssetPathError: Error, Equatable, LocalizedError { + case invalidPath(String) + case outsideProfileRoot(String) + case unsupportedExtension(String) + + var errorDescription: String? { + switch self { + case .invalidPath(let path): + return "Rejected invalid remote image path '\(path)'." + case .outsideProfileRoot(let path): + return "Rejected remote image outside the configured SSH root: '\(path)'." + case .unsupportedExtension(let path): + return "Rejected remote image with an unsupported extension: '\(path)'." + } + } +} + +enum RemoteMarkdownAssets { + static let allowedExtensions: Set = [ + "avif", "gif", "jpeg", "jpg", "png", "svg", "webp" + ] + static let maxAssetsPerDocument = 32 + + static func relativePaths(in markdown: String) -> [String] { + var paths: [String] = [] + var seen: Set = [] + + func appendMatches(pattern: String, allowedLabels: Set? = nil) { + guard let regex = try? NSRegularExpression(pattern: pattern) else { return } + let range = NSRange(markdown.startIndex..., in: markdown) + for match in regex.matches(in: markdown, range: range) { + if let allowedLabels, + let labelRange = Range(match.range(at: 1), in: markdown) { + let label = String(markdown[labelRange]).lowercased() + guard allowedLabels.contains(label) else { continue } + } + let firstPathCapture = allowedLabels == nil ? 1 : 2 + let candidate = [firstPathCapture, firstPathCapture + 1].compactMap { capture in + Range(match.range(at: capture), in: markdown).map { String(markdown[$0]) } + }.first + guard let path = normalizedCandidate(candidate), + seen.insert(path).inserted else { + continue + } + paths.append(path) + } + } + + appendMatches(pattern: #"!\[[^\]]*\]\(\s*(?:<([^>]+)>|([^\s\)]+))"#) + + let referenceRegex = try? NSRegularExpression(pattern: #"!\[([^\]]*)\]\[([^\]]*)\]"#) + let fullRange = NSRange(markdown.startIndex..., in: markdown) + let labels: [String] = referenceRegex?.matches(in: markdown, range: fullRange).compactMap { + let explicit = Range($0.range(at: 2), in: markdown).map { String(markdown[$0]) } ?? "" + let fallback = Range($0.range(at: 1), in: markdown).map { String(markdown[$0]) } ?? "" + let label = explicit.isEmpty ? fallback : explicit + return label.isEmpty ? nil : label.lowercased() + } ?? [] + let referencedLabels = Set(labels) + if !referencedLabels.isEmpty { + appendMatches( + pattern: #"(?m)^\s*\[([^\]]+)\]:\s*(?:<([^>]+)>|(\S+))"#, + allowedLabels: referencedLabels + ) + } + return paths + } + + static func target( + for relativePath: String, + from document: SSHTarget, + confinedTo profileRoot: SSHTarget + ) throws -> SSHTarget { + guard document.userHost == profileRoot.userHost, + contains(document.remotePath, in: profileRoot.remotePath) else { + throw RemoteAssetPathError.outsideProfileRoot(document.remotePath) + } + guard let relative = normalizedCandidate(relativePath), + !relative.hasPrefix("/"), + !relative.hasPrefix("~"), + !relative.contains("\\"), + relative.rangeOfCharacter(from: .controlCharacters) == nil, + relative.range( + of: #"^[A-Za-z][A-Za-z0-9+.-]*:"#, + options: .regularExpression + ) == nil else { + throw RemoteAssetPathError.invalidPath(relativePath) + } + + let parent = (document.remotePath as NSString).deletingLastPathComponent + let normalized = ((parent as NSString).appendingPathComponent(relative) as NSString) + .standardizingPath + guard contains(normalized, in: profileRoot.remotePath) else { + throw RemoteAssetPathError.outsideProfileRoot(relativePath) + } + guard let target = SSHTarget(userHost: document.userHost, remotePath: normalized) else { + throw RemoteAssetPathError.invalidPath(relativePath) + } + try validate(target, confinedTo: profileRoot, originalPath: relativePath) + return target + } + + static func validate( + _ target: SSHTarget, + confinedTo profileRoot: SSHTarget, + originalPath: String + ) throws { + guard target.userHost == profileRoot.userHost, + contains(target.remotePath, in: profileRoot.remotePath) else { + throw RemoteAssetPathError.outsideProfileRoot(originalPath) + } + let ext = (target.remotePath as NSString).pathExtension.lowercased() + guard allowedExtensions.contains(ext) else { + throw RemoteAssetPathError.unsupportedExtension(originalPath) + } + } + + private static func normalizedCandidate(_ candidate: String?) -> String? { + guard var path = candidate?.trimmingCharacters(in: .whitespacesAndNewlines), + !path.isEmpty else { + return nil + } + for _ in 0..<4 { + guard let decoded = path.removingPercentEncoding else { break } + if decoded == path { break } + path = decoded + } + return path.isEmpty ? nil : path + } + + private static func contains(_ path: String, in root: String) -> Bool { + let normalizedPath = (path as NSString).standardizingPath + let normalizedRoot = (root as NSString).standardizingPath + if normalizedPath == normalizedRoot { return true } + let prefix = normalizedRoot.hasSuffix("/") ? normalizedRoot : normalizedRoot + "/" + return normalizedPath.hasPrefix(prefix) + } +} diff --git a/Sources/mindle/SSHProfile.swift b/Sources/mindle/SSHProfile.swift new file mode 100644 index 0000000..f678b5c --- /dev/null +++ b/Sources/mindle/SSHProfile.swift @@ -0,0 +1,261 @@ +import Foundation + +struct SSHProfile: Identifiable, Equatable, Sendable { + let name: String + let rootTarget: SSHTarget + let favorite: Bool + + var id: String { name } + var hostname: String { rootTarget.userHost } + var rootPath: String { rootTarget.remotePath } + + init?(name: String, hostname: String, rootPath: String, favorite: Bool) { + guard let rootTarget = SSHTarget(userHost: hostname, remotePath: rootPath) else { + return nil + } + self.name = name + self.rootTarget = rootTarget + self.favorite = favorite + } + + func contains(_ target: SSHTarget) -> Bool { + guard target.userHost == hostname else { return false } + if target.remotePath == rootPath { return true } + let prefix = rootPath.hasSuffix("/") ? rootPath : rootPath + "/" + return target.remotePath.hasPrefix(prefix) + } +} + +enum SSHProfileConfigurationError: Error, LocalizedError { + case unavailable + case invalidLine(Int, String) + case missingField(Int, String) + case invalidPath(Int, String) + case invalidHostname(Int, String) + case invalidFavorite(Int, String) + case duplicateFavorite + case noProfiles + + var errorDescription: String? { + switch self { + case .unavailable: + return "Mindle couldn't locate its Application Support directory." + case .invalidLine(let line, let text): + return "SSH profiles YAML line \(line) isn't valid: \(text)" + case .missingField(let line, let field): + return "SSH profile near line \(line) is missing '\(field)'." + case .invalidPath(let line, let path): + return "SSH profile near line \(line) needs an absolute path, not '\(path)'." + case .invalidHostname(let line, let hostname): + return "SSH profile near line \(line) has invalid hostname '\(hostname)'." + case .invalidFavorite(let line, let value): + return "SSH profile near line \(line) has invalid favorite value '\(value)'. Use true or false." + case .duplicateFavorite: + return "Only one SSH profile can be marked favorite." + case .noProfiles: + return "No SSH profiles are configured. Edit ssh-profiles.yaml to enable one." + } + } +} + +enum SSHProfileConfiguration { + static let defaultYAML = """ + # Configure one or more SSH roots, then remove the leading "# " markers. + # profiles: + # - name: docs + # hostname: docs.example + # path: /srv/docs + # favorite: true + """ + + static func configURL(fileManager: FileManager = .default) throws -> URL { + guard let support = fileManager.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first else { + throw SSHProfileConfigurationError.unavailable + } + return support + .appendingPathComponent("Mindle", isDirectory: true) + .appendingPathComponent("ssh-profiles.yaml") + } + + @discardableResult + static func ensureConfigExists(fileManager: FileManager = .default) throws -> URL { + let url = try configURL(fileManager: fileManager) + guard !fileManager.fileExists(atPath: url.path) else { return url } + try fileManager.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try defaultYAML.write(to: url, atomically: true, encoding: .utf8) + return url + } + + static func load(fileManager: FileManager = .default) throws -> [SSHProfile] { + let url = try ensureConfigExists(fileManager: fileManager) + return try parse(String(contentsOf: url, encoding: .utf8)) + } + + static func favoriteProfile(in profiles: [SSHProfile]) -> SSHProfile? { + profiles.first(where: \.favorite) ?? profiles.first + } + + static func profile(containing target: SSHTarget, in profiles: [SSHProfile]) -> SSHProfile? { + profiles + .filter { $0.contains(target) } + .max { $0.rootPath.count < $1.rootPath.count } + } + + static func parse(_ yaml: String) throws -> [SSHProfile] { + struct Draft { + let line: Int + var fields: [String: String] + } + + var drafts: [Draft] = [] + var current: Draft? + var sawProfiles = false + + func finishCurrent() { + if let current { + drafts.append(current) + } + current = nil + } + + for (index, rawLine) in yaml.components(separatedBy: .newlines).enumerated() { + let lineNumber = index + 1 + let line = stripComment(rawLine).trimmingCharacters(in: .whitespaces) + if line.isEmpty { continue } + if line == "profiles:" { + finishCurrent() + sawProfiles = true + continue + } + guard sawProfiles else { + throw SSHProfileConfigurationError.invalidLine(lineNumber, line) + } + + if line.hasPrefix("-") { + finishCurrent() + current = Draft(line: lineNumber, fields: [:]) + let remainder = line.dropFirst().trimmingCharacters(in: .whitespaces) + if !remainder.isEmpty { + let (key, value) = try field(in: String(remainder), line: lineNumber) + current!.fields[key] = value + } + } else { + guard current != nil else { + throw SSHProfileConfigurationError.invalidLine(lineNumber, line) + } + let (key, value) = try field(in: line, line: lineNumber) + current!.fields[key] = value + } + } + finishCurrent() + + guard !drafts.isEmpty else { + throw SSHProfileConfigurationError.noProfiles + } + + var favoriteCount = 0 + let profiles = try drafts.map { draft -> SSHProfile in + guard let name = nonEmpty(draft.fields["name"]) else { + throw SSHProfileConfigurationError.missingField(draft.line, "name") + } + guard let hostname = nonEmpty(draft.fields["hostname"]) else { + throw SSHProfileConfigurationError.missingField(draft.line, "hostname") + } + guard let rootPath = nonEmpty(draft.fields["path"]) else { + throw SSHProfileConfigurationError.missingField(draft.line, "path") + } + guard rootPath.hasPrefix("/") else { + throw SSHProfileConfigurationError.invalidPath(draft.line, rootPath) + } + + let favorite: Bool + switch draft.fields["favorite"]?.lowercased() { + case nil, "false": + favorite = false + case "true": + favorite = true + favoriteCount += 1 + case let value?: + throw SSHProfileConfigurationError.invalidFavorite(draft.line, value) + } + + guard let profile = SSHProfile( + name: name, + hostname: hostname, + rootPath: rootPath, + favorite: favorite + ) else { + throw SSHProfileConfigurationError.invalidHostname(draft.line, hostname) + } + return profile + } + + guard favoriteCount <= 1 else { + throw SSHProfileConfigurationError.duplicateFavorite + } + return profiles + } + + private static func field(in text: String, line: Int) throws -> (String, String) { + guard let colon = text.firstIndex(of: ":") else { + throw SSHProfileConfigurationError.invalidLine(line, text) + } + let key = text[.. String { + var quote: Character? + var escaped = false + for index in line.indices { + let character = line[index] + if escaped { + escaped = false + continue + } + if character == "\\", quote == "\"" { + escaped = true + continue + } + if character == "\"" || character == "'" { + quote = quote == character ? nil : (quote ?? character) + continue + } + if character == "#", quote == nil { + return String(line[.. String { + guard value.count >= 2, + let first = value.first, + let last = value.last, + (first == "\"" && last == "\"") || (first == "'" && last == "'") else { + return value + } + let inner = String(value.dropFirst().dropLast()) + if first == "\"" { + return inner + .replacingOccurrences(of: "\\\"", with: "\"") + .replacingOccurrences(of: "\\\\", with: "\\") + } + return inner.replacingOccurrences(of: "''", with: "'") + } + + private static func nonEmpty(_ value: String?) -> String? { + guard let value, !value.isEmpty else { return nil } + return value + } +} diff --git a/Sources/mindle/SSHTarget.swift b/Sources/mindle/SSHTarget.swift index 24f1319..9a9a958 100644 --- a/Sources/mindle/SSHTarget.swift +++ b/Sources/mindle/SSHTarget.swift @@ -27,10 +27,48 @@ struct SSHTarget: Equatable { return comps.url } - /// Deterministic local proxy: `//`. + /// Deterministic local proxy that mirrors the remote directory structure. + /// Keeping siblings adjacent lets the reader resolve fetched image paths + /// through the same local base directory as ordinary local documents. func proxyURL(cacheDir: URL) -> URL { - cacheDir.appendingPathComponent(Self.fnv1a(canonical), isDirectory: true) - .appendingPathComponent(basename) + remotePath + .split(separator: "/", omittingEmptySubsequences: true) + .reduce( + cacheDir.appendingPathComponent(Self.fnv1a(userHost), isDirectory: true) + ) { partial, component in + partial.appendingPathComponent(String(component)) + } + } + + func migrateLegacyCacheIfNeeded( + cacheDir: URL, + fileManager: FileManager = .default + ) throws { + let legacyProxy = cacheDir + .appendingPathComponent(Self.fnv1a(canonical), isDirectory: true) + .appendingPathComponent(basename) + let proxy = proxyURL(cacheDir: cacheDir) + let legacySidecar = legacyProxy.deletingLastPathComponent() + .appendingPathComponent(".\(legacyProxy.lastPathComponent).mindle.json") + let sidecar = proxy.deletingLastPathComponent() + .appendingPathComponent(".\(proxy.lastPathComponent).mindle.json") + + guard fileManager.fileExists(atPath: legacyProxy.path) + || fileManager.fileExists(atPath: legacySidecar.path) else { + return + } + try fileManager.createDirectory( + at: proxy.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + if fileManager.fileExists(atPath: legacyProxy.path), + !fileManager.fileExists(atPath: proxy.path) { + try fileManager.moveItem(at: legacyProxy, to: proxy) + } + if fileManager.fileExists(atPath: legacySidecar.path), + !fileManager.fileExists(atPath: sidecar.path) { + try fileManager.moveItem(at: legacySidecar, to: sidecar) + } } // MARK: Parsing @@ -43,9 +81,7 @@ struct SSHTarget: Equatable { guard let colon = s.firstIndex(of: ":") else { return nil } let uh = String(s[../` URL. `url.path` is already @@ -57,14 +93,24 @@ struct SSHTarget: Equatable { guard let slash = trimmed.firstIndex(of: "/") else { return nil } let uh = String(trimmed[.. 1 else { return nil } - self.userHost = uh - self.remotePath = rp + guard rp.count > 1 else { return nil } + self.init(userHost: uh, remotePath: rp) } - private init(userHost: String, remotePath: String) { - self.userHost = userHost - self.remotePath = remotePath + init?(userHost: String, remotePath: String) { + let normalizedHost = userHost.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedPath = (remotePath as NSString).standardizingPath + guard !normalizedHost.isEmpty, + !normalizedHost.hasPrefix("-"), + normalizedHost.rangeOfCharacter(from: .whitespacesAndNewlines) == nil, + normalizedHost.rangeOfCharacter(from: .controlCharacters) == nil, + !normalizedHost.contains("/"), + normalizedPath.hasPrefix("/"), + normalizedPath.rangeOfCharacter(from: .controlCharacters) == nil else { + return nil + } + self.userHost = normalizedHost + self.remotePath = normalizedPath } /// FNV-1a 64-bit hash, hex — same family as DocumentStore's content/url diff --git a/Sources/mindle/SSHTransport.swift b/Sources/mindle/SSHTransport.swift index ffcf8e7..8fa12b0 100644 --- a/Sources/mindle/SSHTransport.swift +++ b/Sources/mindle/SSHTransport.swift @@ -2,6 +2,22 @@ import Foundation struct ProcessResult { let status: Int32; let stdout: Data; let stderr: Data } +struct RemoteDocumentListing: Equatable { + let root: SSHTarget + let files: [SSHTarget] +} + +struct RemoteAssetFetchFailure: Equatable { + let path: String + let message: String +} + +struct RemoteAssetFetchReport: Equatable { + let failures: [RemoteAssetFetchFailure] + let fetchedCount: Int + let skippedForLimit: Int +} + /// Runs an external process. Behind a protocol so tests inject a fake — /// real ssh/scp I/O is not unit-testable. protocol ProcessRunner { @@ -16,13 +32,43 @@ struct SystemProcessRunner: ProcessRunner { proc.arguments = arguments let out = Pipe(); let err = Pipe() proc.standardOutput = out; proc.standardError = err - proc.terminationHandler = { p in - let o = out.fileHandleForReading.readDataToEndOfFile() - let e = err.fileHandleForReading.readDataToEndOfFile() - cont.resume(returning: ProcessResult(status: p.terminationStatus, stdout: o, stderr: e)) - } - do { try proc.run() } catch { + do { + try proc.run() + } catch { cont.resume(throwing: SSHTransportError.launchFailed(error.localizedDescription)) + return + } + + DispatchQueue.global(qos: .userInitiated).async { + let reads = DispatchGroup() + let lock = NSLock() + var stdout = Data() + var stderr = Data() + + reads.enter() + DispatchQueue.global(qos: .userInitiated).async { + let data = out.fileHandleForReading.readDataToEndOfFile() + lock.lock() + stdout = data + lock.unlock() + reads.leave() + } + reads.enter() + DispatchQueue.global(qos: .userInitiated).async { + let data = err.fileHandleForReading.readDataToEndOfFile() + lock.lock() + stderr = data + lock.unlock() + reads.leave() + } + + proc.waitUntilExit() + reads.wait() + cont.resume(returning: ProcessResult( + status: proc.terminationStatus, + stdout: stdout, + stderr: stderr + )) } } } @@ -31,6 +77,10 @@ struct SystemProcessRunner: ProcessRunner { enum SSHTransportError: Error, LocalizedError { case nonZeroExit(status: Int32, stderr: String) case launchFailed(String) + case invalidListing + case missingRemoteRoot(String) + case remoteAssetOutsideRoot(String) + case remoteAssetResolutionFailed(String) var errorDescription: String? { switch self { @@ -39,6 +89,14 @@ enum SSHTransportError: Error, LocalizedError { return trimmed.isEmpty ? "SSH command failed." : trimmed case .launchFailed(let m): return "Couldn't launch ssh/scp: \(m)" + case .invalidListing: + return "SSH returned an invalid file listing." + case .missingRemoteRoot(let path): + return "The configured SSH profile root doesn't exist: \(path)" + case .remoteAssetOutsideRoot(let path): + return "Remote image resolves outside the configured SSH root: \(path)" + case .remoteAssetResolutionFailed(let path): + return "Couldn't resolve remote image path: \(path)" } } } @@ -50,6 +108,15 @@ enum SSHTransport { static let scpPath = "/usr/bin/scp" static let sshPath = "/usr/bin/ssh" static let remoteTmpSuffix = ".mindle-tmp" + static let listingRootMarker = "\u{1e}MINDLE_ROOT\u{1e}" + static let resolvedAssetMarker = "\u{1e}MINDLE_ASSET\u{1e}" + static let missingRootExitStatus: Int32 = 72 + static let assetOutsideRootExitStatus: Int32 = 73 + static let assetResolutionExitStatus: Int32 = 74 + static let browsableDocumentExtensions: Set = [ + "md", "markdown", "mdown", "mkd", "txt", "pdf" + ] + private static let cacheWriteLock = NSLock() /// POSIX single-quote: wrap in '…', escaping embedded ' as '\''. static func shellSingleQuote(_ s: String) -> String { @@ -89,6 +156,49 @@ enum SSHTransport { return sshFlags + [target.userHost, cmd] } + static func listDocumentsArgs(_ profile: SSHProfile) -> [String] { + let extensions = browsableDocumentExtensions.sorted().map { + "-iname \(shellSingleQuote("*.\($0)"))" + }.joined(separator: " -o ") + let configuredRoot = shellSingleQuote(profile.rootPath) + let command = """ + root=\(configuredRoot); \ + if [ ! -d "$root" ]; then \ + printf 'Mindle SSH profile root does not exist: %s\\n' "$root" >&2; \ + exit \(missingRootExitStatus); \ + fi; \ + printf '\\036MINDLE_ROOT\\036%s\\0' "$root"; \ + find "$root" -path '*/.*' -prune -o -type f \\( \(extensions) \\) -print0 + """ + return sshFlags + [profile.hostname, command] + } + + static func resolveAssetArgs(_ target: SSHTarget, profileRoot: SSHTarget) -> [String] { + let configuredRoot = shellSingleQuote(profileRoot.remotePath) + let configuredAsset = shellSingleQuote(target.remotePath) + let command = """ + root=\(configuredRoot); asset=\(configuredAsset); \ + if [ ! -d "$root" ]; then \ + printf 'Mindle SSH profile root does not exist: %s\\n' "$root" >&2; \ + exit \(missingRootExitStatus); \ + fi; \ + root_real=$(cd "$root" && pwd -P) || exit \(assetResolutionExitStatus); \ + asset_real=$(realpath "$asset" 2>/dev/null) || { \ + printf 'Mindle remote image path could not be resolved: %s\\n' "$asset" >&2; \ + exit \(assetResolutionExitStatus); \ + }; \ + inside=0; \ + if [ "$root_real" = "/" ]; then inside=1; \ + else case "$asset_real" in "$root_real"/*) inside=1 ;; esac; fi; \ + if [ "$inside" -ne 1 ]; then \ + printf 'Mindle remote image resolves outside profile root: %s\\n' "$asset" >&2; \ + exit \(assetOutsideRootExitStatus); \ + fi; \ + printf '\\036MINDLE_ROOT\\036%s\\0\\036MINDLE_ASSET\\036%s\\0' "$root_real" "$asset_real" + """ + return sshFlags + [target.userHost, command] + } + // MARK: Operations /// Fetch the remote file to `proxyURL` atomically: scp to a sibling @@ -97,16 +207,185 @@ enum SSHTransport { static func fetch(_ target: SSHTarget, to proxyURL: URL, runner: ProcessRunner = SystemProcessRunner()) async throws { let fm = FileManager.default try fm.createDirectory(at: proxyURL.deletingLastPathComponent(), withIntermediateDirectories: true) - let tmp = proxyURL.appendingPathExtension("fetch") - try? fm.removeItem(at: tmp) + let tmp = proxyURL.appendingPathExtension("fetch-\(UUID().uuidString)") let res = try await runner.run(launchPath: scpPath, arguments: fetchArgs(target, tmp: tmp)) guard res.status == 0 else { try? fm.removeItem(at: tmp) throw SSHTransportError.nonZeroExit(status: res.status, stderr: String(data: res.stderr, encoding: .utf8) ?? "") } - if fm.fileExists(atPath: proxyURL.path) { try fm.removeItem(at: proxyURL) } - try fm.moveItem(at: tmp, to: proxyURL) + try cacheWriteLock.withLock { + if fm.fileExists(atPath: proxyURL.path) { + try fm.removeItem(at: proxyURL) + } + try fm.moveItem(at: tmp, to: proxyURL) + } + } + + static func listDocuments( + in profile: SSHProfile, + runner: ProcessRunner = SystemProcessRunner() + ) async throws -> RemoteDocumentListing { + let result = try await runner.run( + launchPath: sshPath, + arguments: listDocumentsArgs(profile) + ) + if result.status == missingRootExitStatus { + throw SSHTransportError.missingRemoteRoot(profile.rootPath) + } + guard result.status == 0 else { + throw SSHTransportError.nonZeroExit( + status: result.status, + stderr: String(data: result.stderr, encoding: .utf8) ?? "" + ) + } + + guard let output = String(data: result.stdout, encoding: .utf8) else { + throw SSHTransportError.invalidListing + } + let tokens = output.split(separator: "\0", omittingEmptySubsequences: true).map(String.init) + let rootPaths = tokens.compactMap { token -> String? in + guard let marker = token.range(of: listingRootMarker) else { return nil } + return String(token[marker.upperBound...]) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + guard rootPaths.count == 1, + let root = SSHTarget(userHost: profile.hostname, remotePath: rootPaths[0]), + root == profile.rootTarget else { + throw SSHTransportError.invalidListing + } + + let files = tokens.compactMap { token -> SSHTarget? in + guard !token.contains(listingRootMarker) else { return nil } + let path = token.trimmingCharacters(in: .newlines) + guard let target = SSHTarget(userHost: root.userHost, remotePath: path), + profile.contains(target), + target.remotePath != root.remotePath, + browsableDocumentExtensions.contains( + (target.remotePath as NSString).pathExtension.lowercased() + ) else { + return nil + } + return target + } + .sorted { + $0.remotePath.localizedCaseInsensitiveCompare($1.remotePath) == .orderedAscending + } + return RemoteDocumentListing(root: root, files: files) + } + + static func fetchReferencedImages( + in markdown: String, + for document: SSHTarget, + profileRoot: SSHTarget, + cacheDir: URL, + runner: ProcessRunner = SystemProcessRunner() + ) async -> RemoteAssetFetchReport { + let relativePaths = RemoteMarkdownAssets.relativePaths(in: markdown) + let selectedPaths = Array(relativePaths.prefix(RemoteMarkdownAssets.maxAssetsPerDocument)) + var failures: [RemoteAssetFetchFailure] = [] + var fetchedCount = 0 + + for relativePath in selectedPaths { + let target: SSHTarget + do { + target = try RemoteMarkdownAssets.target( + for: relativePath, + from: document, + confinedTo: profileRoot + ) + } catch { + failures.append(RemoteAssetFetchFailure( + path: relativePath, + message: error.localizedDescription + )) + continue + } + + do { + let resolvedTarget = try await resolveRemoteAsset( + target, + profileRoot: profileRoot, + runner: runner + ) + try await fetch( + resolvedTarget, + to: target.proxyURL(cacheDir: cacheDir), + runner: runner + ) + fetchedCount += 1 + } catch { + failures.append(RemoteAssetFetchFailure( + path: relativePath, + message: error.localizedDescription + )) + } + } + + return RemoteAssetFetchReport( + failures: failures, + fetchedCount: fetchedCount, + skippedForLimit: max(0, relativePaths.count - selectedPaths.count) + ) + } + + private static func resolveRemoteAsset( + _ target: SSHTarget, + profileRoot: SSHTarget, + runner: ProcessRunner + ) async throws -> SSHTarget { + let result = try await runner.run( + launchPath: sshPath, + arguments: resolveAssetArgs(target, profileRoot: profileRoot) + ) + switch result.status { + case missingRootExitStatus: + throw SSHTransportError.missingRemoteRoot(profileRoot.remotePath) + case assetOutsideRootExitStatus: + throw SSHTransportError.remoteAssetOutsideRoot(target.remotePath) + case assetResolutionExitStatus: + throw SSHTransportError.remoteAssetResolutionFailed(target.remotePath) + case 0: + break + default: + throw SSHTransportError.nonZeroExit( + status: result.status, + stderr: String(data: result.stderr, encoding: .utf8) ?? "" + ) + } + + guard let output = String(data: result.stdout, encoding: .utf8) else { + throw SSHTransportError.remoteAssetResolutionFailed(target.remotePath) + } + let tokens = output.split(separator: "\0", omittingEmptySubsequences: true) + let resolvedRoots = tokens.compactMap { token -> String? in + guard let marker = token.range(of: listingRootMarker) else { return nil } + return String(token[marker.upperBound...]) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + let resolvedPaths = tokens.compactMap { token -> String? in + guard let marker = token.range(of: resolvedAssetMarker) else { return nil } + return String(token[marker.upperBound...]) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + guard resolvedRoots.count == 1, + resolvedPaths.count == 1, + let resolvedRoot = SSHTarget( + userHost: target.userHost, + remotePath: resolvedRoots[0] + ), + let resolved = SSHTarget( + userHost: target.userHost, + remotePath: resolvedPaths[0] + ) else { + throw SSHTransportError.remoteAssetResolutionFailed(target.remotePath) + } + try RemoteMarkdownAssets.validate( + resolved, + confinedTo: resolvedRoot, + originalPath: target.remotePath + ) + return resolved } /// Upload `proxyURL` to a remote temp path, then remote-`mv` it onto the diff --git a/Sources/mindle/SettingsView.swift b/Sources/mindle/SettingsView.swift index 533bccc..5371a07 100644 --- a/Sources/mindle/SettingsView.swift +++ b/Sources/mindle/SettingsView.swift @@ -1,7 +1,11 @@ import SwiftUI +import AppKit struct SettingsView: View { @AppStorage("mindle.fontScale") private var defaultFontScale: Double = 1.0 + @State private var sshProfiles: [SSHProfile] = [] + @State private var sshProfilesError: String? + @State private var sshProfilesURL: URL? var body: some View { Form { @@ -35,8 +39,71 @@ struct SettingsView: View { .frame(maxWidth: .infinity, alignment: .leading) } } + + Section("SSH Profiles") { + if let error = sshProfilesError { + Label(error, systemImage: "exclamationmark.triangle") + .foregroundStyle(.secondary) + } else { + ForEach(sshProfiles) { profile in + HStack(spacing: 10) { + Image(systemName: profile.favorite ? "star.fill" : "server.rack") + .foregroundStyle(profile.favorite ? .yellow : .secondary) + .frame(width: 18) + VStack(alignment: .leading, spacing: 2) { + Text(profile.name) + Text("\(profile.hostname):\(profile.rootPath)") + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + Spacer() + if profile.favorite { + Text("Favorite") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + + HStack { + Button("Open YAML") { + if let url = sshProfilesURL { + NSWorkspace.shared.open(url) + } + } + .disabled(sshProfilesURL == nil) + + Button("Reload") { + loadSSHProfiles() + } + + Spacer() + if let url = sshProfilesURL { + Text(url.path) + .font(.caption2.monospaced()) + .foregroundStyle(.tertiary) + .lineLimit(1) + .truncationMode(.middle) + } + } + } } .formStyle(.grouped) .frame(width: 450) + .onAppear { + loadSSHProfiles() + } + } + + private func loadSSHProfiles() { + do { + sshProfilesURL = try SSHProfileConfiguration.ensureConfigExists() + sshProfiles = try SSHProfileConfiguration.load() + sshProfilesError = nil + } catch { + sshProfiles = [] + sshProfilesError = error.localizedDescription + } } } diff --git a/Sources/mindle/WebReaderView.swift b/Sources/mindle/WebReaderView.swift index 0769aba..1e57513 100644 --- a/Sources/mindle/WebReaderView.swift +++ b/Sources/mindle/WebReaderView.swift @@ -41,7 +41,10 @@ struct WebReaderView: NSViewRepresentable { // Re-render whenever rawText OR lastSyncedText changes — the diff // pipeline keys off the pair, so a baseline change has to flow // through to the view even when rawText is unchanged. - if store.rawText != coord.lastSource || store.lastSyncedText != coord.lastSyncedText { + if store.rawText != coord.lastSource + || store.lastSyncedText != coord.lastSyncedText + || store.fileURL != coord.lastFileURL + || store.remoteAssetRevision != coord.lastRemoteAssetRevision { // Same file, rawText changed → live reload, preserve scroll. // Different file (or first load) → fresh load, start at top. // Baseline-only change (accept/reject) preserves scroll too. @@ -49,6 +52,7 @@ struct WebReaderView: NSViewRepresentable { coord.lastSource = store.rawText coord.lastSyncedText = store.lastSyncedText coord.lastFileURL = store.fileURL + coord.lastRemoteAssetRevision = store.remoteAssetRevision let baseDir = store.fileURL?.deletingLastPathComponent().path ?? "" // mindleLoad's third arg is the diff baseline: when it differs // from arg one, the JS layer renders track-changes with chips. @@ -157,6 +161,7 @@ struct WebReaderView: NSViewRepresentable { var lastSource: String = "" var lastSyncedText: String = "" var lastFileURL: URL? + var lastRemoteAssetRevision = 0 var lastTheme: String = "" var lastFontScale: Double = 0 var lastReadingWidth: String = "" diff --git a/Tests/fixtures/image-rendering/README.md b/Tests/fixtures/image-rendering/README.md new file mode 100644 index 0000000..a1c5c28 --- /dev/null +++ b/Tests/fixtures/image-rendering/README.md @@ -0,0 +1,6 @@ +# Local image fixture + +This fixture verifies that Mindle rewrites a relative image to its local +`mindle-file://` URL and that the custom scheme handler returns image bytes. + +![Mindle local fixture](images/sample.svg) diff --git a/Tests/fixtures/image-rendering/images/sample.svg b/Tests/fixtures/image-rendering/images/sample.svg new file mode 100644 index 0000000..e367278 --- /dev/null +++ b/Tests/fixtures/image-rendering/images/sample.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Tests/harness/RemoteMarkdownAssetsChecks.swift b/Tests/harness/RemoteMarkdownAssetsChecks.swift new file mode 100644 index 0000000..1eb45a7 --- /dev/null +++ b/Tests/harness/RemoteMarkdownAssetsChecks.swift @@ -0,0 +1,121 @@ +import Foundation + +func runRemoteMarkdownAssetsChecks() -> Int { + let c = Checks("RemoteMarkdownAssets") + let markdown = """ + ![plain](image.png) + ![nested](assets/diagram%20one.webp "Diagram") + ![parent](../shared/chart.svg) + ![remote](https://example.com/no.png) + ![absolute](/srv/static/no.png) + ![brand][logo] + [logo]: + [ordinary-link]: docs/readme.md + """ + c.equal( + RemoteMarkdownAssets.relativePaths(in: markdown), + [ + "image.png", + "assets/diagram one.webp", + "../shared/chart.svg", + "https://example.com/no.png", + "/srv/static/no.png", + "images/logo.png" + ], + "image extraction and percent decoding" + ) + + let root = SSHTarget(userHostPath: "test:/workspace")! + let document = SSHTarget(userHostPath: "test:/workspace/book/README.md")! + do { + c.equal( + try RemoteMarkdownAssets.target( + for: "image.png", + from: document, + confinedTo: root + ).canonical, + "test:/workspace/book/image.png", + "sibling image target" + ) + c.equal( + try RemoteMarkdownAssets.target( + for: "../shared/chart.svg", + from: document, + confinedTo: root + ).canonical, + "test:/workspace/shared/chart.svg", + "parent image stays inside root" + ) + c.equal( + try RemoteMarkdownAssets.target( + for: "images/plot%23final%3F2.png", + from: document, + confinedTo: root + ).canonical, + "test:/workspace/book/images/plot#final?2.png", + "encoded filename characters preserved" + ) + } catch { + c.expect(false, "valid remote image path failed: \(error)") + } + + let escapingPaths = [ + "../../outside.png", + "%2e%2e/%2e%2e/outside.png", + "%252e%252e/%252e%252e/outside.png", + "..%2f..%2foutside.png", + "../%2e%2e/outside.png" + ] + for path in escapingPaths { + do { + _ = try RemoteMarkdownAssets.target( + for: path, + from: document, + confinedTo: root + ) + c.expect(false, "root escape should fail: \(path)") + } catch RemoteAssetPathError.outsideProfileRoot { + c.expect(true, "root escape rejected: \(path)") + } catch { + c.expect(false, "root escape returned wrong error for \(path): \(error)") + } + } + + for path in ["notes.txt", "diagram.pdf", ".ssh/id_rsa", "image.png.exe"] { + do { + _ = try RemoteMarkdownAssets.target( + for: path, + from: document, + confinedTo: root + ) + c.expect(false, "unsupported image extension should fail: \(path)") + } catch RemoteAssetPathError.unsupportedExtension { + c.expect(true, "unsupported image extension rejected: \(path)") + } catch { + c.expect(false, "unsupported extension returned wrong error for \(path): \(error)") + } + } + + do { + _ = try RemoteMarkdownAssets.target( + for: "image.png", + from: SSHTarget(userHostPath: "test:/outside/README.md")!, + confinedTo: root + ) + c.expect(false, "document outside root should fail") + } catch RemoteAssetPathError.outsideProfileRoot { + c.expect(true, "document outside root rejected") + } catch { + c.expect(false, "document outside root returned wrong error: \(error)") + } + + c.equal( + RemoteMarkdownAssets.allowedExtensions, + ["avif", "gif", "jpeg", "jpg", "png", "svg", "webp"], + "explicit image extension allowlist" + ) + c.equal(RemoteMarkdownAssets.maxAssetsPerDocument, 32, "explicit per-document fetch cap") + + if c.failures == 0 { print("✓ RemoteMarkdownAssets: \(c.passed) checks passed") } + return c.failures +} diff --git a/Tests/harness/SSHProfileChecks.swift b/Tests/harness/SSHProfileChecks.swift new file mode 100644 index 0000000..a686fad --- /dev/null +++ b/Tests/harness/SSHProfileChecks.swift @@ -0,0 +1,114 @@ +import Foundation + +func runSSHProfileChecks() -> Int { + let c = Checks("SSHProfile") + let yaml = """ + profiles: + - name: "test" + hostname: 'test' + path: /workspace + favorite: true + - name: docs + hostname: docs.example + path: /srv/docs # inline comment + """ + + do { + let profiles = try SSHProfileConfiguration.parse(yaml) + c.equal(profiles.count, 2, "profile count") + c.equal(profiles[0].name, "test", "quoted name") + c.equal(profiles[0].hostname, "test", "quoted hostname") + c.equal(profiles[0].rootPath, "/workspace", "root path") + c.expect(profiles[0].favorite, "favorite parsed") + c.equal( + SSHProfileConfiguration.favoriteProfile(in: profiles)?.name, + "test", + "favorite selected" + ) + c.equal( + SSHProfileConfiguration.profile( + containing: SSHTarget(userHostPath: "docs.example:/srv/docs/book/README.md")!, + in: profiles + )?.name, + "docs", + "profile root contains nested target" + ) + c.expect( + SSHProfileConfiguration.profile( + containing: SSHTarget(userHostPath: "docs.example:/srv/private/README.md")!, + in: profiles + ) == nil, + "profile root rejects sibling target" + ) + } catch { + c.expect(false, "valid YAML failed: \(error)") + } + + let activeDefaultLines = SSHProfileConfiguration.defaultYAML + .components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty && !$0.hasPrefix("#") } + c.equal(activeDefaultLines, [], "default profile template is fully disabled") + c.expect( + !SSHProfileConfiguration.defaultYAML.contains("hostname: test"), + "default profile template never activates test hostname" + ) + do { + _ = try SSHProfileConfiguration.parse(SSHProfileConfiguration.defaultYAML) + c.expect(false, "disabled default config should contain no profiles") + } catch SSHProfileConfigurationError.noProfiles { + c.expect(true, "disabled default config reports no profiles") + } catch { + c.expect(false, "disabled default config returned wrong error: \(error)") + } + + do { + _ = try SSHProfileConfiguration.parse(""" + profiles: + - name: bad + hostname: test + path: relative + """) + c.expect(false, "relative path should fail") + } catch SSHProfileConfigurationError.invalidPath { + c.expect(true, "relative path rejected") + } catch { + c.expect(false, "relative path returned wrong error: \(error)") + } + + do { + _ = try SSHProfileConfiguration.parse(""" + profiles: + - name: one + hostname: one + path: /one + favorite: true + - name: two + hostname: two + path: /two + favorite: true + """) + c.expect(false, "multiple favorites should fail") + } catch SSHProfileConfigurationError.duplicateFavorite { + c.expect(true, "multiple favorites rejected") + } catch { + c.expect(false, "multiple favorites returned wrong error: \(error)") + } + + do { + _ = try SSHProfileConfiguration.parse(""" + profiles: + - name: unsafe + hostname: -oProxyCommand=bad + path: /workspace + """) + c.expect(false, "option-like hostname should fail") + } catch SSHProfileConfigurationError.invalidHostname { + c.expect(true, "option-like hostname rejected") + } catch { + c.expect(false, "option-like hostname returned wrong error: \(error)") + } + + if c.failures == 0 { print("✓ SSHProfile: \(c.passed) checks passed") } + return c.failures +} diff --git a/Tests/harness/SSHTargetChecks.swift b/Tests/harness/SSHTargetChecks.swift index 8cc2919..1642543 100644 --- a/Tests/harness/SSHTargetChecks.swift +++ b/Tests/harness/SSHTargetChecks.swift @@ -20,6 +20,12 @@ func runSSHTargetChecks() -> Int { c.expect(SSHTarget(userHostPath: "no-colon-here") == nil, "rejects missing colon") c.expect(SSHTarget(userHostPath: ":/empty/host.md") == nil, "rejects empty host") c.expect(SSHTarget(userHostPath: " ") == nil, "rejects blank") + c.expect(SSHTarget(userHostPath: "-oProxyCommand=bad:/file.md") == nil, "rejects option-like host") + c.equal( + SSHTarget(userHostPath: "devbox:/../../etc/notes.md")?.remotePath, + "/etc/notes.md", + "normalizes parent components" + ) // sourceURL round-trips let t3 = SSHTarget(userHostPath: "fabio@devbox:/home/fabio/my notes.md")! @@ -29,13 +35,52 @@ func runSSHTargetChecks() -> Int { c.equal(SSHTarget(sourceURL: url), t3, "sourceURL round-trip") // proxyURL deterministic + hash-keyed - let dir = URL(fileURLWithPath: "/tmp/ssh-cache", isDirectory: true) + let dir = testFixtureURL("ssh-cache-layout") let a = SSHTarget(userHostPath: "fabio@devbox:/a/spec.md")! let b = SSHTarget(userHostPath: "fabio@devbox:/a/spec.md")! let other = SSHTarget(userHostPath: "fabio@other:/a/spec.md")! c.equal(a.proxyURL(cacheDir: dir), b.proxyURL(cacheDir: dir), "proxy deterministic") c.expect(a.proxyURL(cacheDir: dir) != other.proxyURL(cacheDir: dir), "proxy differs by host") c.equal(a.proxyURL(cacheDir: dir).lastPathComponent, "spec.md", "proxy basename") + c.equal( + a.proxyURL(cacheDir: dir).deletingLastPathComponent().lastPathComponent, + "a", + "proxy mirrors remote parent directory" + ) + + do { + let migrationRoot = try resetTestFixture("ssh-cache-migration") + let migrationTarget = SSHTarget(userHostPath: "devbox:/docs/guide.md")! + let legacyProxy = migrationRoot + .appendingPathComponent(SSHTarget.fnv1a(migrationTarget.canonical), isDirectory: true) + .appendingPathComponent("guide.md") + let legacySidecar = legacyProxy.deletingLastPathComponent() + .appendingPathComponent(".guide.md.mindle.json") + try FileManager.default.createDirectory( + at: legacyProxy.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try "legacy document".write(to: legacyProxy, atomically: true, encoding: .utf8) + try "legacy annotations".write(to: legacySidecar, atomically: true, encoding: .utf8) + try migrationTarget.migrateLegacyCacheIfNeeded(cacheDir: migrationRoot) + + let migratedProxy = migrationTarget.proxyURL(cacheDir: migrationRoot) + let migratedSidecar = migratedProxy.deletingLastPathComponent() + .appendingPathComponent(".guide.md.mindle.json") + c.equal( + try String(contentsOf: migratedProxy, encoding: .utf8), + "legacy document", + "legacy proxy migrated" + ) + c.equal( + try String(contentsOf: migratedSidecar, encoding: .utf8), + "legacy annotations", + "legacy annotation sidecar migrated" + ) + c.expect(!FileManager.default.fileExists(atPath: legacyProxy.path), "legacy proxy removed") + } catch { + c.expect(false, "legacy cache migration failed: \(error)") + } if c.failures == 0 { print("✓ SSHTarget: \(c.passed) checks passed") } return c.failures diff --git a/Tests/harness/SSHTransportChecks.swift b/Tests/harness/SSHTransportChecks.swift index a30c8fd..fab9df7 100644 --- a/Tests/harness/SSHTransportChecks.swift +++ b/Tests/harness/SSHTransportChecks.swift @@ -2,60 +2,397 @@ import Foundation func runSSHTransportChecks() async -> Int { let c = Checks("SSHTransport") + let fixtureRoot: URL + do { + fixtureRoot = try resetTestFixture("ssh-transport") + } catch { + c.expect(false, "fixture setup failed: \(error)") + return c.failures + } + + let target = SSHTarget(userHostPath: "fabio@devbox:/a/spec.md")! + let tmp = fixtureRoot.appendingPathComponent("spec.md.fetch") + let fetchArgs = SSHTransport.fetchArgs(target, tmp: tmp) + c.expect(fetchArgs.contains("BatchMode=yes"), "fetchArgs has BatchMode") + c.expect(fetchArgs.contains("ConnectTimeout=10"), "fetchArgs has ConnectTimeout") + c.equal(fetchArgs.last, tmp.path, "fetchArgs local tmp last") + c.expect(fetchArgs.contains("fabio@devbox:/a/spec.md"), "fetchArgs unquoted remote source") + c.expect(!fetchArgs.contains(where: { $0.contains("'") }), "fetchArgs has no shell quotes") + + let proxy = fixtureRoot.appendingPathComponent("spec.md") + let pushArgs = SSHTransport.pushArgs(proxy, to: target) + c.equal(pushArgs.first, "-o", "pushArgs flags precede positionals") + c.equal(pushArgs.last, "fabio@devbox:/a/spec.md.mindle-tmp", "pushArgs remote temp last") + c.expect(pushArgs.contains("BatchMode=yes"), "pushArgs has BatchMode") + + let spacedTarget = SSHTarget(userHostPath: "fabio@devbox:/a/my notes.md")! + c.expect( + SSHTransport.remoteMvArgs(spacedTarget) + .contains("mv '/a/my notes.md.mindle-tmp' '/a/my notes.md'"), + "remoteMvArgs quotes remote paths" + ) + c.equal(SSHTransport.shellSingleQuote("it's"), "'it'\\''s'", "shell quote escapes apostrophe") + + let profile = SSHProfile( + name: "test", + hostname: "test", + rootPath: "/workspace", + favorite: true + )! + let listArgs = SSHTransport.listDocumentsArgs(profile) + let listCommand = listArgs.last ?? "" + c.expect(listArgs.contains("test"), "listDocumentsArgs has hostname") + c.expect(listCommand.contains("root='/workspace'"), "listing quotes configured root") + c.expect( + listCommand.contains("Mindle SSH profile root does not exist"), + "listing emits clear missing-root error" + ) + c.expect( + listCommand.contains("exit \(SSHTransport.missingRootExitStatus)"), + "listing uses explicit missing-root exit status" + ) + c.expect(!listCommand.contains("root=$HOME"), "listing never falls back to remote home") + c.expect(listCommand.contains("find \"$root\""), "listing searches only configured root") + c.expect(listCommand.contains("-iname '*.md'"), "listing includes Markdown") + let resolveCommand = SSHTransport.resolveAssetArgs( + SSHTarget(userHostPath: "test:/workspace/images/logo.png")!, + profileRoot: profile.rootTarget + ).last ?? "" + c.expect(resolveCommand.contains("realpath \"$asset\""), "asset resolution follows canonical path") + c.expect( + resolveCommand.contains("resolves outside profile root"), + "asset resolution rejects symlink escape" + ) + + let listed = """ + Welcome banner + \(SSHTransport.listingRootMarker)/workspace\u{0}/workspace/book/README.md\u{0}/workspace/notes.txt\u{0}/workspace/image.png\u{0}/outside/no.md\u{0} + """ + do { + let listing = try await SSHTransport.listDocuments( + in: profile, + runner: StaticRunner(result: ProcessResult( + status: 0, + stdout: Data(listed.utf8), + stderr: Data() + )) + ) + c.equal(listing.root.canonical, "test:/workspace", "configured root reported") + c.equal( + listing.files.map(\.canonical), + ["test:/workspace/book/README.md", "test:/workspace/notes.txt"], + "listing accepts documents only inside configured root" + ) + } catch { + c.expect(false, "valid listing failed: \(error)") + } + + do { + _ = try await SSHTransport.listDocuments( + in: profile, + runner: StaticRunner(result: ProcessResult( + status: SSHTransport.missingRootExitStatus, + stdout: Data(), + stderr: Data("Mindle SSH profile root does not exist: /workspace\n".utf8) + )) + ) + c.expect(false, "missing configured root should fail") + } catch SSHTransportError.missingRemoteRoot(let path) { + c.equal(path, "/workspace", "missing root error names configured path") + } catch { + c.expect(false, "missing root returned wrong error: \(error)") + } + + do { + _ = try await SSHTransport.listDocuments( + in: profile, + runner: StaticRunner(result: ProcessResult( + status: 0, + stdout: Data("\(SSHTransport.listingRootMarker)/home/test\u{0}/home/test/README.md\u{0}".utf8), + stderr: Data() + )) + ) + c.expect(false, "listing a substituted home root should fail") + } catch SSHTransportError.invalidListing { + c.expect(true, "substituted home root rejected") + } catch { + c.expect(false, "substituted home root returned wrong error: \(error)") + } + + do { + let missingRoot = fixtureRoot.appendingPathComponent("missing", isDirectory: true) + let localProfile = SSHProfile( + name: "missing", + hostname: "test", + rootPath: missingRoot.path, + favorite: false + )! + let homeDecoy = fixtureRoot.appendingPathComponent("home", isDirectory: true) + try FileManager.default.createDirectory(at: homeDecoy, withIntermediateDirectories: true) + try "# Decoy".write( + to: homeDecoy.appendingPathComponent("README.md"), + atomically: true, + encoding: .utf8 + ) + let shellResult = try await SystemProcessRunner().run( + launchPath: "/bin/sh", + arguments: ["-c", SSHTransport.listDocumentsArgs(localProfile).last!] + ) + c.equal( + shellResult.status, + SSHTransport.missingRootExitStatus, + "generated shell command fails for missing root" + ) + c.expect( + String(data: shellResult.stderr, encoding: .utf8)?.contains(missingRoot.path) == true, + "generated shell command reports exact missing root" + ) + c.equal(shellResult.stdout, Data(), "missing root command produces no listing") + } catch { + c.expect(false, "shell-level missing-root fixture failed: \(error)") + } + + do { + let realpathFixture = fixtureRoot.appendingPathComponent("realpath", isDirectory: true) + let confinedRoot = realpathFixture.appendingPathComponent("root", isDirectory: true) + let outsideRoot = realpathFixture.appendingPathComponent("outside", isDirectory: true) + try FileManager.default.createDirectory(at: confinedRoot, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: outsideRoot, withIntermediateDirectories: true) + let insideImage = confinedRoot.appendingPathComponent("inside.png") + let outsideImage = outsideRoot.appendingPathComponent("outside.png") + try Data("inside".utf8).write(to: insideImage) + try Data("outside".utf8).write(to: outsideImage) + let escapedLink = confinedRoot.appendingPathComponent("escaped.png") + try FileManager.default.createSymbolicLink( + at: escapedLink, + withDestinationURL: outsideImage + ) + let localRoot = SSHTarget(userHost: "test", remotePath: confinedRoot.path)! + + let insideResult = try await SystemProcessRunner().run( + launchPath: "/bin/sh", + arguments: [ + "-c", + SSHTransport.resolveAssetArgs( + SSHTarget(userHost: "test", remotePath: insideImage.path)!, + profileRoot: localRoot + ).last! + ] + ) + c.equal(insideResult.status, 0, "canonical image inside root resolves") + c.expect( + String(data: insideResult.stdout, encoding: .utf8)?.contains(insideImage.path) == true, + "canonical image reports resolved path" + ) - // fetchArgs carry BatchMode + ConnectTimeout; UNQUOTED remote source - // (scp is SFTP-default → literal path); tmp last - let t = SSHTarget(userHostPath: "fabio@devbox:/a/spec.md")! - let tmp = URL(fileURLWithPath: "/tmp/x/spec.md.fetch") - let fargs = SSHTransport.fetchArgs(t, tmp: tmp) - c.expect(fargs.contains("BatchMode=yes"), "fetchArgs has BatchMode") - c.expect(fargs.contains("ConnectTimeout=10"), "fetchArgs has ConnectTimeout") - c.equal(fargs.last, tmp.path, "fetchArgs local tmp last") - c.expect(fargs.contains("fabio@devbox:/a/spec.md"), "fetchArgs unquoted remote source") - c.expect(!fargs.contains(where: { $0.contains("'") }), "fetchArgs has no shell quotes") - - // pushArgs: FLAGS first (BSD getopt stops at first non-option), then - // local source, then UNQUOTED remote temp as the final arg - let proxy = URL(fileURLWithPath: "/tmp/x/spec.md") - let pargs = SSHTransport.pushArgs(proxy, to: t) - c.equal(pargs.first, "-o", "pushArgs flags precede positionals") - c.equal(pargs.last, "fabio@devbox:/a/spec.md.mindle-tmp", "pushArgs remote temp last") - let pProxyIdx = pargs.firstIndex(of: proxy.path) - let pRemoteIdx = pargs.firstIndex(of: "fabio@devbox:/a/spec.md.mindle-tmp") - c.expect(pProxyIdx != nil && pRemoteIdx != nil && pProxyIdx! < pRemoteIdx!, "pushArgs local source before remote dest") - c.expect(pargs.contains("BatchMode=yes"), "pushArgs has BatchMode") - c.expect(!pargs.contains(where: { $0.contains("'") }), "pushArgs has no shell quotes") - - // remoteMvArgs: userHost present + quoted mv command + BatchMode - let tSpace = SSHTarget(userHostPath: "fabio@devbox:/a/my notes.md")! - let margs = SSHTransport.remoteMvArgs(tSpace) - c.expect(margs.contains("fabio@devbox"), "remoteMvArgs has userHost") - c.expect(margs.contains("mv '/a/my notes.md.mindle-tmp' '/a/my notes.md'"), "remoteMvArgs quoted mv") - c.expect(margs.contains("BatchMode=yes"), "remoteMvArgs has BatchMode") - - // shellSingleQuote - c.equal(SSHTransport.shellSingleQuote("/a/b"), "'/a/b'", "shellSingleQuote simple") - c.equal(SSHTransport.shellSingleQuote("it's"), "'it'\\''s'", "shellSingleQuote escapes quote") - - // fetch throws on non-zero exit (fake runner — no real ssh) - let runner = FakeRunner(result: ProcessResult(status: 1, stdout: Data(), - stderr: "ssh: connect to host devbox port 22: Connection refused\n".data(using: .utf8)!)) - let proxy2 = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("spec.md") - do { - try await SSHTransport.fetch(t, to: proxy2, runner: runner) + let escapedResult = try await SystemProcessRunner().run( + launchPath: "/bin/sh", + arguments: [ + "-c", + SSHTransport.resolveAssetArgs( + SSHTarget(userHost: "test", remotePath: escapedLink.path)!, + profileRoot: localRoot + ).last! + ] + ) + c.equal( + escapedResult.status, + SSHTransport.assetOutsideRootExitStatus, + "symlink escape outside root is rejected" + ) + } catch { + c.expect(false, "shell-level canonical path fixture failed: \(error)") + } + + let assetCache = fixtureRoot.appendingPathComponent("asset-cache", isDirectory: true) + let linkedAsset = "/workspace/book/images/link.png" + let assetRunner = RecordingAssetRunner( + failingPath: "missing.png", + resolvedPaths: [linkedAsset: "/outside/secret.png"] + ) + let attackMarkdown = """ + ![ok](images/ok.png) + ![missing](images/missing.png) + ![linked escape](images/link.png) + ![private](../../.ssh/id_rsa) + ![script](images/payload.sh) + ![encoded escape](%252e%252e/%252e%252e/outside.png) + """ + let report = await SSHTransport.fetchReferencedImages( + in: attackMarkdown, + for: SSHTarget(userHostPath: "test:/workspace/book/README.md")!, + profileRoot: profile.rootTarget, + cacheDir: assetCache, + runner: assetRunner + ) + c.equal(report.fetchedCount, 1, "only valid successful image counted") + c.equal(report.skippedForLimit, 0, "small document does not hit cap") + c.equal( + await assetRunner.fetchedTargets(), + ["test:/workspace/book/images/ok.png", "test:/workspace/book/images/missing.png"], + "transport never copies rejected paths" + ) + c.expect( + report.failures.contains(where: { + $0.path == "images/link.png" && $0.message.contains("configured SSH root") + }), + "canonical symlink escape is reported" + ) + c.expect( + report.failures.contains(where: { + $0.path == "../../.ssh/id_rsa" && $0.message.contains("configured SSH root") + }), + "root escape is reported" + ) + c.expect( + report.failures.contains(where: { + $0.path == "images/payload.sh" && $0.message.contains("unsupported extension") + }), + "non-image extension is reported" + ) + + let cappedMarkdown = (0..<(RemoteMarkdownAssets.maxAssetsPerDocument + 5)) + .map { "![image \($0)](images/\($0).png)" } + .joined(separator: "\n") + let capRunner = RecordingAssetRunner() + let capReport = await SSHTransport.fetchReferencedImages( + in: cappedMarkdown, + for: SSHTarget(userHostPath: "test:/workspace/book/README.md")!, + profileRoot: profile.rootTarget, + cacheDir: fixtureRoot.appendingPathComponent("cap-cache", isDirectory: true), + runner: capRunner + ) + c.equal( + await capRunner.fetchedTargets().count, + RemoteMarkdownAssets.maxAssetsPerDocument, + "asset fetch count is capped" + ) + c.equal(capReport.fetchedCount, RemoteMarkdownAssets.maxAssetsPerDocument, "cap fetch report") + c.equal(capReport.skippedForLimit, 5, "cap reports skipped references") + + do { + let concurrentCache = fixtureRoot.appendingPathComponent("concurrent", isDirectory: true) + let concurrentTarget = SSHTarget(userHostPath: "test:/workspace/concurrent.md")! + let concurrentProxy = concurrentTarget.proxyURL(cacheDir: concurrentCache) + async let first: Void = SSHTransport.fetch( + concurrentTarget, + to: concurrentProxy, + runner: DelayedFetchRunner(content: "first", delayNanoseconds: 30_000_000) + ) + async let second: Void = SSHTransport.fetch( + concurrentTarget, + to: concurrentProxy, + runner: DelayedFetchRunner(content: "second", delayNanoseconds: 0) + ) + _ = try await (first, second) + let content = try String(contentsOf: concurrentProxy, encoding: .utf8) + c.expect(content == "first" || content == "second", "concurrent fetch leaves complete proxy") + } catch { + c.expect(false, "concurrent fetch failed: \(error)") + } + + let failedFetchRunner = StaticRunner(result: ProcessResult( + status: 1, + stdout: Data(), + stderr: Data("ssh: connection refused\n".utf8) + )) + do { + try await SSHTransport.fetch( + target, + to: fixtureRoot.appendingPathComponent("failed.md"), + runner: failedFetchRunner + ) c.expect(false, "fetch should throw on non-zero exit") } catch let SSHTransportError.nonZeroExit(status, stderr) { c.equal(status, 1, "fetch error status") - c.expect(stderr.contains("Connection refused"), "fetch error stderr") + c.expect(stderr.contains("connection refused"), "fetch error stderr") } catch { c.expect(false, "fetch threw wrong error: \(error)") } + do { + let largeOutput = String(repeating: "x", count: 100_000) + let result = try await SystemProcessRunner().run( + launchPath: "/usr/bin/printf", + arguments: ["%s", largeOutput] + ) + c.equal(result.status, 0, "large-output process status") + c.equal(result.stdout.count, largeOutput.utf8.count, "large output drains without deadlock") + } catch { + c.expect(false, "large-output process failed: \(error)") + } + if c.failures == 0 { print("✓ SSHTransport: \(c.passed) checks passed") } return c.failures } -private struct FakeRunner: ProcessRunner { +private struct StaticRunner: ProcessRunner { let result: ProcessResult func run(launchPath: String, arguments: [String]) async throws -> ProcessResult { result } } + +private actor RecordingAssetRunner: ProcessRunner { + let failingPath: String? + let resolvedPaths: [String: String] + private var targets: [String] = [] + + init( + failingPath: String? = nil, + resolvedPaths: [String: String] = [:] + ) { + self.failingPath = failingPath + self.resolvedPaths = resolvedPaths + } + + func run(launchPath: String, arguments: [String]) async throws -> ProcessResult { + if launchPath == SSHTransport.sshPath { + let command = arguments.last ?? "" + guard let start = command.range(of: "; asset='"), + let end = command[start.upperBound...].firstIndex(of: "'") else { + return ProcessResult( + status: SSHTransport.assetResolutionExitStatus, + stdout: Data(), + stderr: Data("asset parse failed".utf8) + ) + } + let path = String(command[start.upperBound.. [String] { + targets + } +} + +private struct DelayedFetchRunner: ProcessRunner { + let content: String + let delayNanoseconds: UInt64 + + func run(launchPath: String, arguments: [String]) async throws -> ProcessResult { + try Data(content.utf8).write(to: URL(fileURLWithPath: arguments.last!)) + if delayNanoseconds > 0 { + try await Task.sleep(nanoseconds: delayNanoseconds) + } + return ProcessResult(status: 0, stdout: Data(), stderr: Data()) + } +} diff --git a/Tests/harness/TestHarness.swift b/Tests/harness/TestHarness.swift index b13e8d9..4275a2f 100644 --- a/Tests/harness/TestHarness.swift +++ b/Tests/harness/TestHarness.swift @@ -1,5 +1,18 @@ import Foundation +func testFixtureURL(_ name: String) -> URL { + URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true) + .appendingPathComponent(".build/test-fixtures", isDirectory: true) + .appendingPathComponent(name, isDirectory: true) +} + +func resetTestFixture(_ name: String) throws -> URL { + let url = testFixtureURL(name) + try? FileManager.default.removeItem(at: url) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url +} + /// Minimal assert harness for swiftc-compiled tests. This project builds /// with Command Line Tools only (no Xcode → no XCTest), so tests are plain /// Swift compiled alongside the pure-logic sources by `run-tests.sh`. Each diff --git a/Tests/harness/main.swift b/Tests/harness/main.swift index 0192d27..3446239 100644 --- a/Tests/harness/main.swift +++ b/Tests/harness/main.swift @@ -2,6 +2,8 @@ import Foundation var failures = 0 failures += runSSHTargetChecks() +failures += runSSHProfileChecks() +failures += runRemoteMarkdownAssetsChecks() failures += await runSSHTransportChecks() if failures > 0 { diff --git a/Tests/web/ReaderImageHarness.swift b/Tests/web/ReaderImageHarness.swift new file mode 100644 index 0000000..1e3e609 --- /dev/null +++ b/Tests/web/ReaderImageHarness.swift @@ -0,0 +1,206 @@ +import AppKit +import Foundation +import WebKit + +final class HarnessNavigationDelegate: NSObject, WKNavigationDelegate { + var finished = false + var error: Error? + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + finished = true + } + + func webView( + _ webView: WKWebView, + didFail navigation: WKNavigation!, + withError error: Error + ) { + self.error = error + finished = true + } + + func webView( + _ webView: WKWebView, + didFailProvisionalNavigation navigation: WKNavigation!, + withError error: Error + ) { + self.error = error + finished = true + } +} + +@discardableResult +func spinRunLoop(until deadline: Date, condition: () -> Bool) -> Bool { + while !condition() && Date() < deadline { + RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.02)) + } + return condition() +} + +func loadWebView(htmlURL: URL) throws -> WKWebView { + let config = WKWebViewConfiguration() + config.setURLSchemeHandler(ImageSchemeHandler(), forURLScheme: ImageSchemeHandler.scheme) + let webView = WKWebView(frame: NSRect(x: 0, y: 0, width: 900, height: 700), configuration: config) + let navigation = HarnessNavigationDelegate() + webView.navigationDelegate = navigation + webView.loadFileURL( + htmlURL, + allowingReadAccessTo: URL( + fileURLWithPath: FileManager.default.currentDirectoryPath, + isDirectory: true + ) + ) + guard spinRunLoop( + until: Date().addingTimeInterval(15), + condition: { navigation.finished } + ) else { + throw NSError(domain: "ReaderImageHarness", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Timed out loading \(htmlURL.path)" + ]) + } + if let error = navigation.error { throw error } + return webView +} + +func waitForJavaScriptResult( + in webView: WKWebView, + expression: String, + timeout: TimeInterval = 15 +) throws -> Any { + var result: Any? + var evaluationError: Error? + var evaluationInFlight = false + let deadline = Date().addingTimeInterval(timeout) + + let completed = spinRunLoop(until: deadline) { + if result != nil || evaluationError != nil { return true } + if !evaluationInFlight { + evaluationInFlight = true + webView.evaluateJavaScript(expression) { value, error in + evaluationInFlight = false + if let error { + evaluationError = error + } else if let value, !(value is NSNull) { + result = value + } + } + } + return false + } + if let evaluationError { throw evaluationError } + guard completed, let result else { + throw NSError(domain: "ReaderImageHarness", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "Timed out waiting for JavaScript result." + ]) + } + return result +} + +func runPipelineHarness(htmlPath: String = "Tests/web/reader-image-harness.html") throws { + let htmlURL = URL( + fileURLWithPath: htmlPath, + relativeTo: URL( + fileURLWithPath: FileManager.default.currentDirectoryPath, + isDirectory: true + ) + ).standardizedFileURL + let webView = try loadWebView(htmlURL: htmlURL) + guard let result = try waitForJavaScriptResult( + in: webView, + expression: "window.__mindleHarnessResult || null" + ) as? [String: Any] else { + throw NSError(domain: "ReaderImageHarness", code: 3, userInfo: [ + NSLocalizedDescriptionKey: "Harness returned an unexpected result." + ]) + } + guard result["passed"] as? Bool == true else { + throw NSError(domain: "ReaderImageHarness", code: 4, userInfo: [ + NSLocalizedDescriptionKey: "Reader pipeline checks failed: \(result)" + ]) + } + print("✓ Reader image pipeline: path encoding and post-image passes verified") +} + +func runManualLocalFixture() throws { + let root = URL( + fileURLWithPath: FileManager.default.currentDirectoryPath, + isDirectory: true + ) + let fixtureDirectory = root + .appendingPathComponent("Tests/fixtures/image-rendering", isDirectory: true) + let markdown = try String( + contentsOf: fixtureDirectory.appendingPathComponent("README.md"), + encoding: .utf8 + ) + let readerURL = root.appendingPathComponent("Resources/web/reader.html") + let webView = try loadWebView(htmlURL: readerURL) + let script = """ + window.__mindleManualResult = null; + window.mindleSetBaseDir(\(javascriptString(fixtureDirectory.path))); + window.mindleLoad(\(javascriptString(markdown)), false, null).then(() => { + setTimeout(() => { + const image = document.querySelector("img"); + window.__mindleManualResult = { + src: image ? image.getAttribute("src") : null, + complete: image ? image.complete : false, + width: image ? image.naturalWidth : 0, + height: image ? image.naturalHeight : 0, + heading: document.querySelector("h1")?.textContent || "" + }; + }, 250); + }).catch(error => { + window.__mindleManualResult = { fatal: String(error) }; + }); + """ + webView.evaluateJavaScript(script) + guard let result = try waitForJavaScriptResult( + in: webView, + expression: "window.__mindleManualResult || null" + ) as? [String: Any] else { + throw NSError(domain: "ReaderImageHarness", code: 5, userInfo: [ + NSLocalizedDescriptionKey: "Manual fixture returned an unexpected result." + ]) + } + let complete = result["complete"] as? Bool == true + let width = result["width"] as? Int ?? 0 + let height = result["height"] as? Int ?? 0 + let heading = result["heading"] as? String + guard complete, width == 120, height == 80, heading == "Local image fixture" else { + throw NSError(domain: "ReaderImageHarness", code: 6, userInfo: [ + NSLocalizedDescriptionKey: "Manual local rendering failed: \(result)" + ]) + } + print("✓ Manual local image fixture: sample.svg rendered at \(width)×\(height)") +} + +func javascriptString(_ value: String) -> String { + let data = try! JSONSerialization.data(withJSONObject: [value]) + let array = String(data: data, encoding: .utf8)! + return String(array.dropFirst().dropLast()) +} + +@main +struct ReaderImageHarnessMain { + static func main() { + _ = NSApplication.shared + do { + let arguments = CommandLine.arguments + if !arguments.contains("--manual-only") { + let htmlPath: String + if let index = arguments.firstIndex(of: "--html"), + arguments.indices.contains(index + 1) { + htmlPath = arguments[index + 1] + } else { + htmlPath = "Tests/web/reader-image-harness.html" + } + try runPipelineHarness(htmlPath: htmlPath) + } + if !arguments.contains("--skip-manual") { + try runManualLocalFixture() + } + } catch { + fputs("Reader image harness failed: \(error.localizedDescription)\n", stderr) + exit(1) + } + } +} diff --git a/Tests/web/reader-image-harness.html b/Tests/web/reader-image-harness.html new file mode 100644 index 0000000..5d68a59 --- /dev/null +++ b/Tests/web/reader-image-harness.html @@ -0,0 +1,144 @@ + + + + + + + + + + + + + +
+ + + + diff --git a/run-tests.sh b/run-tests.sh index 0bb472e..418faba 100755 --- a/run-tests.sh +++ b/run-tests.sh @@ -8,10 +8,24 @@ cd "$(dirname "$0")" mkdir -p .build swiftc -O \ Sources/mindle/SSHTarget.swift \ + Sources/mindle/SSHProfile.swift \ + Sources/mindle/RemoteMarkdownAssets.swift \ Sources/mindle/SSHTransport.swift \ Tests/harness/TestHarness.swift \ Tests/harness/SSHTargetChecks.swift \ + Tests/harness/SSHProfileChecks.swift \ + Tests/harness/RemoteMarkdownAssetsChecks.swift \ Tests/harness/SSHTransportChecks.swift \ Tests/harness/main.swift \ -o .build/run-tests .build/run-tests + +swiftc -O \ + -framework AppKit \ + -framework Foundation \ + -framework UniformTypeIdentifiers \ + -framework WebKit \ + Sources/mindle/ImageSchemeHandler.swift \ + Tests/web/ReaderImageHarness.swift \ + -o .build/run-reader-image-tests +.build/run-reader-image-tests