diff --git a/README.md b/README.md index 2b98b21..deae3c3 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Get `LeftOpen-release.zip` from [GitHub Releases](https://github.com/SonghaiFan/ - **Knows whose port it is.** Walks up from the process's working directory to `.git`, `package.json`, `pyproject.toml`, `Cargo.toml` or `go.mod`, or resolves the owning `.app`. Global npm packages, `python -m` modules and standalone services (Redis, Postgres, Ollama…) are named too, so you rarely see a bare `node` or `python`. - **Real icons, nothing bundled.** Uses the `.app` icon when there is one; otherwise the icon the project or package ships itself (Tauri/Electron app icon, the favicon declared in `index.html`, `public/` conventions); otherwise the favicon served by the local server; otherwise a symbol. -- **Grouped by who started them.** Dev Servers (from a project, terminal, editor or agent), Background Services (launchd: brew services, login items), Apps and System. The group is also how a port is closed for good, and each one says so: SIGTERM for dev servers, `brew services stop …` for services launchd would restart, quitting the app for apps. +- **Grouped by who started them.** Dev Servers (from a project, terminal, editor or agent), Background Services (launchd: brew services, login items), Containers (forwarded by Docker Desktop, OrbStack or colima; the Engine API resolves the container's name, compose project and mounted directories), Apps and System. The group is also how a port is closed for good, and each one says so: SIGTERM for dev servers, `brew services stop …` for services launchd would restart, one click `docker stop` for containers on their port page, quitting the app for apps. - **Closes gently first.** Sends `SIGTERM` and re-checks process identity. If a single-process close still leaves it listening after five seconds, that process is marked and the warning explains that closing it again within two minutes sends `SIGKILL`. The second click or swipe is the deliberate force-close action; existing safety protections and identity checks still apply. - **Local vs LAN.** Tells apart ports bound to `127.0.0.1` from ones on `0.0.0.0` or a LAN address. - **Hidden ports.** Hide selected ports from the list, search, menu bar count, and change sounds in **Settings → Ports → Hidden Ports**, or choose **Hide Port** from its context menu. Enter ports with Return or commas, or paste several at once. Each saved port becomes a tag; click its × to show that port again. Services keep running. @@ -64,6 +64,8 @@ Get `LeftOpen-release.zip` from [GitHub Releases](https://github.com/SonghaiFan/ - **Fixed project addresses.** Give a dev server a stable `https://myapp.localhost` URL that follows it across port changes, powered by bundled [Portless](https://github.com/vercel-labs/portless). See [below](#fixed-project-addresses). - **Same engine in the terminal.** The app ships a `leftopen` CLI with identical inference and safety rules. +Container stop actions are limited to verified local runtime endpoints. Ambiguous ownership stays read-only; see [container safety](docs/container-stop-safety.md). + --- ## Fixed project addresses diff --git a/README.zh-CN.md b/README.zh-CN.md index bdef88d..6c447d8 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -56,7 +56,7 @@ brew install --cask songhaifan/tap/leftopen - **知道端口是谁的。** 从进程的工作目录向上找 `.git`、`package.json`、`pyproject.toml`、`Cargo.toml`、`go.mod`,或者解析它所属的 `.app`。全局 npm 包、`python -m` 模块和 Redis / Postgres / Ollama 这类独立服务也按名字识别,很少再看到一个光秃秃的 `node` 或 `python`。 - **真实图标,不预存。** 有 `.app` 就用它的图标;否则用项目或包自己带的(Tauri / Electron app icon、`index.html` 声明的 favicon、`public/` 约定);否则向本机服务器抓 favicon;再没有才用符号。 -- **按来历分组。** 开发服务器(从项目、终端、编辑器或 agent 启动)、后台服务(由 launchd 管理:brew services、登录项)、App 和系统。分组即关闭方式,每组都写明:开发服务器发 SIGTERM,launchd 会重启的服务提示 `brew services stop …`,App 的端口需要退出 App。 +- **按来历分组。** 开发服务器(从项目、终端、编辑器或 agent 启动)、后台服务(由 launchd 管理:brew services、登录项)、容器(由 Docker Desktop、OrbStack、colima 转发,通过 Engine API 解析出容器名、compose 项目和挂载目录)、App 和系统。分组即关闭方式,每组都写明:开发服务器发 SIGTERM,launchd 会重启的服务提示 `brew services stop …`,容器可在详情页一键 `docker stop`,App 的端口需要退出 App。 - **先轻轻关闭。** 发送 `SIGTERM`,关闭前重新核对进程身份。单独关闭一个进程后等待五秒,如果仍在监听,该进程会显示失败提示;警告会说明两分钟内再次点击或右滑关闭将发送 `SIGKILL`。第二次关闭操作就是明确的强制关闭,原有安全保护和身份核验仍然生效。 - **本机还是局域网。** 区分绑在 `127.0.0.1` 的端口和绑在 `0.0.0.0` / 局域网地址上的端口。 - **隐藏端口。** 在 **设置 → 端口 → 隐藏端口** 中添加端口,或右键选择 **隐藏端口**,即可从列表、搜索、菜单栏计数和变化提示音中排除。输入端口后按回车或逗号添加,也可一次粘贴多个端口。已保存的端口显示为标签,点击 × 后恢复显示。服务仍继续运行。 @@ -65,6 +65,8 @@ brew install --cask songhaifan/tap/leftopen - **固定项目地址。** 为开发服务器分配稳定的 `https://myapp.localhost`,端口变化后自动跟随,由内置的 [Portless](https://github.com/vercel-labs/portless) 提供。详见[下文](#固定项目地址)。 - **终端里是同一个引擎。** App 附带 `leftopen` 命令行工具,推断和安全规则完全一致。 +停止容器仅适用于已验证的本机运行时端点;归属不明确时只展示,不提供停止操作。详见 [容器安全机制](docs/container-stop-safety.md)。 + --- ## 固定项目地址 diff --git a/Sources/LeftOpenApp/AppMain.swift b/Sources/LeftOpenApp/AppMain.swift index 47fd98b..5a3ac57 100644 --- a/Sources/LeftOpenApp/AppMain.swift +++ b/Sources/LeftOpenApp/AppMain.swift @@ -73,6 +73,7 @@ final class MenuModel: ObservableObject { @Published var isClosing = false @Published var pendingPlan: ClosePlan? @Published var pendingBatchPlans: [ClosePlan]? + @Published var pendingContainerStop: ContainerStopPlan? @Published private(set) var notice: Notice? @Published private(set) var forceCloseOffer: ForceCloseOffer? @Published var selectedActivityID: String? @@ -81,7 +82,7 @@ final class MenuModel: ObservableObject { @Published private(set) var closingActivityIDs: Set = [] private var refreshLoop: Task? - private var scanLoop: Task? + private var scanLoop: Task? private var rescanRequested = false private var settingsObserver: AnyCancellable? private var languageObserver: AnyCancellable? @@ -132,6 +133,7 @@ final class MenuModel: ObservableObject { MainActor.assumeIsolated { self?.notice = nil self?.forceCloseOffer = nil + self?.pendingContainerStop = nil Task { await self?.refresh() } } } @@ -142,6 +144,7 @@ final class MenuModel: ObservableObject { MainActor.assumeIsolated { self?.pendingPlan = nil self?.pendingBatchPlans = nil + self?.pendingContainerStop = nil self?.forceCloseOffer = nil } } @@ -174,18 +177,23 @@ final class MenuModel: ObservableObject { /// scan loop: a request that arrives mid-scan queues a single rescan instead of a parallel one, /// so a refresh after a close never settles for a snapshot taken before the signal. func refresh() async { + _ = await refreshWithResult() + } + + /// Nil means the requested fresh scan failed; the last displayed snapshot is not evidence. + private func refreshWithResult() async -> ScanSnapshot? { if let scanLoop { rescanRequested = true - await scanLoop.value - return + return await scanLoop.value } let loop = Task { await runScans() } scanLoop = loop - await loop.value + return await loop.value } - private func runScans() async { + private func runScans() async -> ScanSnapshot? { isRefreshing = true + var freshSnapshot: ScanSnapshot? defer { isRefreshing = false scanLoop = nil @@ -194,6 +202,7 @@ final class MenuModel: ObservableObject { rescanRequested = false do { snapshot = try await Task.detached(priority: .utility) { try Scanner.scan() }.value + freshSnapshot = snapshot lastRefresh = Date() if let offer = forceCloseOffer { if !snapshot.activities.contains(where: { @@ -214,10 +223,12 @@ final class MenuModel: ObservableObject { } previousListeningPorts = listeningPorts } catch { + freshSnapshot = nil forceCloseOffer = nil post(Notice(kind: .error, text: L("Scan failed: \(error.localizedDescription)", "扫描失败:\(error.localizedDescription)"))) } } while rescanRequested + return freshSnapshot } func previewClose(_ activity: Activity) async { @@ -246,6 +257,58 @@ final class MenuModel: ObservableObject { await execute(plan) } + /// Freeze the daemon, immutable ID and all published bindings in a fresh, reviewed plan. + func previewContainerStop(_ activity: Activity) async { + guard !isPreparingClose && !isClosing, activity.container != nil else { return } + isPreparingClose = true + notice = nil + defer { isPreparingClose = false } + do { + pendingContainerStop = try await Task.detached(priority: .utility) { + try ContainerResolver.prepareStop(activity) + }.value + } catch { + post(Notice(kind: .warning, text: L("Stop unavailable: \(error.localizedDescription)", + "无法停止:\(error.localizedDescription)"))) + } + } + + func confirmContainerStop() async { + guard let plan = pendingContainerStop, !isClosing else { return } + isClosing = true + defer { isClosing = false } + // Consume the confirmation once. The immutable plan, never the polling snapshot, supplies impact. + pendingContainerStop = nil + do { + _ = try await Task.detached(priority: .utility) { + try ContainerResolver.stop(plan) + }.value + selectedActivityID = nil + let fresh = await refreshWithResult() + let outcome: String + let kind: NoticeKind + switch ContainerStopVerification.evaluate(plan, freshSnapshot: fresh) { + case .free: + outcome = L("Container \(plan.container.name) stopped; TCP port \(plan.activity.listener.port) is free.", + "容器 \(plan.container.name) 已停止,TCP 端口 \(plan.activity.listener.port) 已释放。") + kind = .success + case let .stillListening(ports): + let held = ports.map(String.init).joined(separator: ", ") + outcome = L("Container \(plan.container.name) stopped, but TCP ports \(held) still listen. Another owner may hold them.", + "容器 \(plan.container.name) 已停止,但 TCP 端口 \(held) 仍在监听,可能已由其他进程占用。") + kind = .warning + case .unavailable: + outcome = L("Container \(plan.container.name) stopped; verification unavailable because the fresh scan failed. Port release is not confirmed.", + "容器 \(plan.container.name) 已停止,但最新扫描失败,无法验证端口是否已释放。") + kind = .warning + } + post(Notice(kind: kind, text: outcome)) + } catch { + post(Notice(kind: .error, text: L("Container stop failed or was refused: \(error.localizedDescription)", + "容器停止失败或已拒绝:\(error.localizedDescription)"))) + } + } + /// Swipe-to-close: the swipe past the threshold is the confirmation, so the plan is prepared /// and executed without the review page. Identity checks in CloseService still apply. func closeNow(_ activity: Activity) async { diff --git a/Sources/LeftOpenApp/MenuPanel.swift b/Sources/LeftOpenApp/MenuPanel.swift index 298aacb..4c025db 100644 --- a/Sources/LeftOpenApp/MenuPanel.swift +++ b/Sources/LeftOpenApp/MenuPanel.swift @@ -130,7 +130,9 @@ struct MenuPanel: View { var groups: [String: [Activity]] = [:] var orderedIDs: [String] = [] for activity in activities { - let id = activity.listenerGroupID + // Preserve preview isolation while splitting a forwarder into container/project rows. + let owner = activity.container.map { $0.composeProject ?? $0.identity } + let id = activity.listenerGroupID + (owner.map { ":container:\($0)" } ?? "") if groups[id] == nil { orderedIDs.append(id) } groups[id, default: []].append(activity) } @@ -150,7 +152,9 @@ struct MenuPanel: View { private var path: [Page] { var pages: [Page] = [.list] if model.selectedActivityID != nil { pages.append(.port) } - if model.pendingPlan != nil || model.pendingBatchPlans != nil { pages.append(.review) } + if model.pendingPlan != nil || model.pendingBatchPlans != nil || model.pendingContainerStop != nil { + pages.append(.review) + } return pages } @@ -167,6 +171,8 @@ struct MenuPanel: View { model.pendingBatchPlans = nil } else if model.pendingPlan != nil { model.pendingPlan = nil + } else if model.pendingContainerStop != nil { + model.pendingContainerStop = nil } else { model.selectedActivityID = nil } @@ -299,6 +305,8 @@ struct MenuPanel: View { batchReviewPage(plans) } else if let plan = model.pendingPlan { reviewPage(plan) + } else if let stop = model.pendingContainerStop { + containerStopReviewPage(stop) } } } @@ -700,7 +708,7 @@ struct MenuPanel: View { port: expanded ? nil : group.ports[0], extraPorts: expanded ? 0 : group.ports.count - 1, icon: group.primary, - title: group.primary.inference.label, + title: group.title, subtitle: group.subtitle, isLAN: group.scope == .lan, closeFailed: model.awaitsForceClose(group.primary), @@ -726,7 +734,7 @@ struct MenuPanel: View { PortRow( port: activity.listener.port, icon: activity, - title: activity.inference.label, + title: group.title, subtitle: fixed.binding(for: activity).map { "\($0.name).localhost" } ?? group.subtitle, fixedName: fixed.binding(for: activity)?.name, trailing: activity.process.compactUptime, @@ -748,7 +756,8 @@ struct MenuPanel: View { return PortRow( port: activity.listener.port, icon: nil, - title: activity.listener.addresses.joined(separator: ", "), + title: activity.container.map(\.displayName) + ?? activity.listener.addresses.joined(separator: ", "), subtitle: fixed.binding(for: activity).map { "\($0.name).localhost" } ?? (activity.scope == .lan ? L("LAN-facing", "局域网可见") : L("Local only", "仅本机")), fixedName: fixed.binding(for: activity)?.name, @@ -874,6 +883,22 @@ struct MenuPanel: View { .background(AppAppearance.fill, in: RoundedRectangle(cornerRadius: AppAppearance.cornerRadius, style: .continuous)) } + if let container = activity.container { + HStack { + Button { + Task { await model.previewContainerStop(activity) } + } label: { + Label(L("Stop Container…", "停止容器…"), systemImage: "stop.fill") + .foregroundStyle(Color(nsColor: .systemRed)) + } + .disabled(model.isPreparingClose || model.isClosing) + .help(L("Review stopping container \(container.name) with docker stop", "确认用 docker stop 停止容器 \(container.name)")) + Spacer(minLength: 0) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + Divider() VStack(alignment: .leading, spacing: 8) { infoRow(L("Command", "命令"), activity.process.command) @@ -886,6 +911,18 @@ struct MenuPanel: View { } infoRow(L("Executable", "可执行文件"), compactPath(activity.process.executablePath)) infoRow(L("Folder", "目录"), compactPath(activity.process.cwd)) + if let container = activity.container { + if let project = container.composeProject { + let location = container.composeDir.map { " · \(compactPath($0))" } ?? "" + infoRow(L("Compose", "Compose"), project + location) + } + if !container.mounts.isEmpty { + let mounts = container.mounts + .map { "\(compactPath($0.source)) → \($0.destination)" } + .joined(separator: "\n") + infoRow(L("Mounts", "挂载"), mounts) + } + } infoRow(L("Addresses", "地址"), activity.listener.addresses.joined(separator: ", ")) if let webURL { infoRow(L("Web URL", "网址"), webURL.absoluteString) @@ -1029,6 +1066,49 @@ struct MenuPanel: View { } } + /// Stopping a container is LeftOpen's one shell action: `docker stop` through the CLI, which + /// gives the container its grace period and leaves the runtime itself running. + private func containerStopReviewPage(_ plan: ContainerStopPlan) -> some View { + let container = plan.container + let publishedBindings = plan.reviewedBindings.map(\.description).sorted().joined(separator: ", ") + return VStack(spacing: 0) { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + Text(L("Stop container \(container.name)?", "停止容器 \(container.name)?")) + .font(.title3.weight(.semibold)) + Text(L("LeftOpen runs `docker stop`, which asks the container to exit and gives it about 10 seconds before forcing it. The runtime itself keeps running.", + "LeftOpen 会执行 `docker stop`,先请求容器自行退出,约 10 秒后才强制结束。容器运行时本身不受影响。")) + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Divider() + infoRow(L("Command", "命令"), container.stopCommand) + infoRow(L("Image", "镜像"), container.image) + if let project = container.composeProject { + infoRow(L("Compose", "Compose"), project + (container.composeDir.map { " · \($0)" } ?? "")) + } + infoRow(L("Local daemon", "本机守护进程"), container.endpoint?.host ?? "—") + infoRow(L("Container ID", "容器 ID"), container.id) + if !publishedBindings.isEmpty { + caution(L("All published bindings close: \(publishedBindings).", + "全部发布映射都会关闭:\(publishedBindings)。")) + } + if plan.resurrectsOnDaemonRestart { + caution(L("This container restarts when Docker does. To keep it down across restarts, run `\(container.restartDisableCommand)`.", + "该容器会在 Docker 重启后再次运行。如需一直保持停止,可执行 `\(container.restartDisableCommand)`。")) + } else if plan.restartPolicy == nil { + caution(L("Restart policy is unknown. The container may return after the daemon restarts.", + "无法确定重启策略。守护进程重启后,容器可能再次运行。")) + } + } + .padding(14) + } + confirmBar(L("Stop Container", "停止容器")) { + Task { await model.confirmContainerStop() } + } + } + } + /// Shared by both review pages: Cancel is the header's back button (Esc), so the bar only /// carries progress and the destructive default action. private func confirmBar(_ title: String, action: @escaping () -> Void) -> some View { @@ -1146,6 +1226,22 @@ struct MenuPanel: View { } } Button(L("Copy PID", "复制 PID")) { copy(String(primary.process.pid)) } + // A compose row spans several containers; each one gets its own stop item. + let stopTargets = activities.reduce( + into: [(container: ContainerInfo, activity: Activity)]() + ) { partial, activity in + guard let container = activity.container, + !partial.contains(where: { $0.container.identity == container.identity }) else { return } + partial.append((container, activity)) + } + if !stopTargets.isEmpty { + Divider() + ForEach(stopTargets, id: \.container.identity) { target in + Button(L("Stop Container \(target.container.displayName)…", "停止容器 \(target.container.displayName)…")) { + Task { await model.previewContainerStop(target.activity) } + } + } + } if let folder = revealTarget(for: primary) { Divider() Button(L("Reveal in Finder", "在访达中显示")) { reveal(folder) } @@ -1172,9 +1268,12 @@ struct MenuPanel: View { } private func revealTarget(for activity: Activity) -> String? { - let candidates = [activity.projectMarker?.root, activity.applicationBundle?.path, - activity.inference.category == .project ? activity.process.cwd : nil, - activity.process.executablePath] + // The compose project directory, then a mounted code directory, say where the container + // came from; the runtime's app bundle is only the last resort. + let mounts = (activity.container?.mounts ?? []).map(\.source) + let candidates = [activity.container?.composeDir, activity.projectMarker?.root, + activity.inference.category == .project ? activity.process.cwd : nil] + + mounts + [activity.applicationBundle?.path, activity.process.executablePath] return candidates.compactMap { $0 }.first { FileManager.default.fileExists(atPath: $0) } } @@ -1289,6 +1388,18 @@ private struct ListenerGroup: Identifiable { var ports: [Int] { Array(Set(activities.map(\.listener.port))).sorted() } var pids: [Int32] { Array(Set(activities.map(\.process.pid))).sorted() } var scope: ListenerScope { activities.contains { $0.scope == .lan } ? .lan : .local } + /// The containers this row speaks for, in first-seen order. + var containers: [ContainerInfo] { + var seen = Set() + return activities.compactMap { activity in + activity.container.flatMap { seen.insert($0.identity).inserted ? $0 : nil } + } + } + + /// A compose project names the row even when it runs several containers. + var title: String { + primary.container?.composeProject ?? primary.inference.label + } /// What a swipe or ✕ closes: only offered when one closable PID owns every port in the row. func closeTarget(safetyProtectionEnabled: Bool) -> Activity? { @@ -1298,6 +1409,10 @@ private struct ListenerGroup: Identifiable { var subtitle: String { if pids.count > 1 { return L("\(pids.count) processes", "\(pids.count) 个进程") } + if containers.count > 1 { + return L("\(containers.count) containers · PID \(primary.process.pid)", + "\(containers.count) 个容器 · PID \(primary.process.pid)") + } let pid = "PID \(primary.process.pid)" if primary.editorPreview != nil, let app = primary.applicationBundle { return "\(app.name) · \(pid)" diff --git a/Sources/LeftOpenCore/CloseService.swift b/Sources/LeftOpenCore/CloseService.swift index a6a3f22..98ef2fc 100644 --- a/Sources/LeftOpenCore/CloseService.swift +++ b/Sources/LeftOpenCore/CloseService.swift @@ -118,6 +118,14 @@ public enum CloseService { guard uid == currentUID else { throw CloseError(L("PID \(target.pid) belongs to another user.", "PID \(target.pid) 属于其他用户。")) } guard let path = target.executablePath else { throw CloseError(L("PID \(target.pid) has no verified executable path.", "无法确认 PID \(target.pid) 的可执行文件路径。")) } if safetyProtectionEnabled { + if PortCategory.isContainerRuntime(activity) { + if let container = activity.container { + throw CloseError(L("PID \(target.pid) only forwards a port for container \(container.name). Stop it with `\(container.stopCommand)`.", + "PID \(target.pid) 只是为容器 \(container.name) 转发端口。请用 `\(container.stopCommand)` 停止该容器。")) + } + throw CloseError(L("PID \(target.pid) only forwards a port for some container. Find it with `docker ps` and stop it with `docker stop …`.", + "PID \(target.pid) 只是在为某个容器转发端口。请先用 `docker ps` 找到它,再用 `docker stop …` 停止。")) + } let protected = ["/System/", "/usr/bin/", "/usr/sbin/", "/usr/libexec/", "/bin/", "/sbin/"] guard !protected.contains(where: { path.hasPrefix($0) }) else { throw CloseError(L("PID \(target.pid) uses an operating-system executable; refusing to close it.", "PID \(target.pid) 是系统程序,拒绝关闭。")) diff --git a/Sources/LeftOpenCore/ContainerResolver.swift b/Sources/LeftOpenCore/ContainerResolver.swift new file mode 100644 index 0000000..cd2e288 --- /dev/null +++ b/Sources/LeftOpenCore/ContainerResolver.swift @@ -0,0 +1,600 @@ +import Darwin +import Foundation +import Network + +public struct ContainerMount: Sendable, Equatable { + public let source: String + public let destination: String +} + +/// A runtime-specific, canonical local socket. Never derived from Docker's active context or +/// DOCKER_HOST. Device/inode detect replacement of the socket between discovery and confirmation. +public struct ContainerEndpoint: Sendable, Equatable, Hashable { + public let socketPath: String + public let device: UInt64 + public let inode: UInt64 + public var host: String { "unix://" + socketPath } +} + +public struct ContainerPortBinding: Sendable, Equatable, Hashable { + public let address: String + public let hostPort: Int + public let containerPort: Int + public let transport: String + + public var description: String { "\(address):\(hostPort) → \(containerPort)/\(transport)" } +} + +/// Ownership evidence, including the exact daemon and full immutable container ID. +public struct ContainerInfo: Sendable, Equatable { + public let id: String + public let name: String + public let image: String + public let composeProject: String? + public let composeDir: String? + public let mounts: [ContainerMount] + public let endpoint: ContainerEndpoint? + public let daemonID: String + public let bindings: Set + + public init(id: String = "", name: String, image: String, + composeProject: String? = nil, composeDir: String? = nil, + mounts: [ContainerMount] = [], endpoint: ContainerEndpoint? = nil, + daemonID: String = "", bindings: Set = []) { + self.id = id + self.name = name + self.image = image + self.composeProject = composeProject + self.composeDir = composeDir + self.mounts = mounts + self.endpoint = endpoint + self.daemonID = daemonID + self.bindings = bindings + } + + public var identity: String { "\(endpoint?.host ?? "unverified")|\(daemonID)|\(id)" } + public var stopCommand: String { + guard let endpoint, Self.isFullID(id), !daemonID.isEmpty else { + return L("Container ownership is not verified", "尚未确认容器归属") + } + return "docker --host \(Self.shellQuote(endpoint.host)) stop \(id)" + } + public var restartDisableCommand: String { + guard let endpoint else { return "" } + return "docker --host \(Self.shellQuote(endpoint.host)) update --restart=no \(id)" + } + static func shellQuote(_ value: String) -> String { "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" } + static func isFullID(_ id: String) -> Bool { + id.count == 64 && id.allSatisfy { "0123456789abcdef".contains($0) } + } + public var displayName: String { + guard composeProject == nil, Self.isGeneratedName(name) else { return name } + let last = (image as NSString).lastPathComponent + if let colon = last.firstIndex(of: ":") { return String(last[.. Bool { + let pieces = name.split(separator: "_") + return pieces.count == 2 && pieces.allSatisfy { $0.allSatisfy(\.isLowercase) && $0.allSatisfy(\.isLetter) } + } +} + +/// A fresh inspection freezes all published protocols/addresses, restart policy and incarnation. +struct ContainerInspection: Sendable, Equatable { + let container: ContainerInfo + let restartPolicy: String? + let maximumRetryCount: Int? + let startedAt: String + let imageID: String +} + +public struct ContainerStopPlan: Sendable, Equatable { + public let activity: Activity + public let container: ContainerInfo + public let restartPolicy: String? + public let reviewedBindings: Set + /// Always contains the selected TCP port, even if later scans lose all container metadata. + public let reviewedTCPPorts: Set + let inspection: ContainerInspection + + init(activity: Activity, inspection: ContainerInspection) { + self.activity = activity + self.inspection = inspection + self.container = inspection.container + self.restartPolicy = inspection.restartPolicy + self.reviewedBindings = inspection.container.bindings + self.reviewedTCPPorts = Set(inspection.container.bindings.filter { $0.transport == "tcp" }.map(\.hostPort)) + .union([activity.listener.port]) + } + public var resurrectsOnDaemonRestart: Bool { restartPolicy == "always" } +} + +public enum ContainerStopVerification: Equatable, Sendable { + case free + case stillListening([Int]) + case unavailable + + public static func evaluate(_ plan: ContainerStopPlan, freshSnapshot: ScanSnapshot?) -> Self { + guard let freshSnapshot else { return .unavailable } + // Intentionally conservative: a new owner on any address must never become "port free". + let remaining = Set(freshSnapshot.activities.map(\.listener.port)).intersection(plan.reviewedTCPPorts).sorted() + return remaining.isEmpty ? .free : .stillListening(remaining) + } +} + +public enum ContainerResolver { + /// Injected per test; production shares a lock-protected, per-endpoint/transport backoff. + final class Backoff: @unchecked Sendable { + private let lock = NSLock() + private var failures: [String: Date] = [:] + func blocked(_ key: String, now: Date) -> Bool { + lock.lock(); defer { lock.unlock() } + return failures[key].map { now.timeIntervalSince($0) < 60 } ?? false + } + func failed(_ key: String, now: Date) { + lock.lock(); defer { lock.unlock() }; failures[key] = now + } + func succeeded(_ key: String) { + lock.lock(); defer { lock.unlock() }; failures.removeValue(forKey: key) + } + } + private static let backoff = Backoff() + + struct Dependencies { + var endpoint: (Activity) -> ContainerEndpoint? = { ContainerResolver.verifiedEndpoint(for: $0) } + var get: (ContainerEndpoint, String) -> Data? = { endpoint, path in + guard ContainerResolver.endpointIsCurrent(endpoint) else { return nil } + return UnixSocketHTTP.get(endpoint.socketPath, path: path, timeout: 2) + } + var command: (ContainerEndpoint, [String], TimeInterval) throws -> String = ContainerResolver.runDocker + } + + /// The listener itself must be a known forwarder; an app name or ancestor alone is not proof. + /// Unknown helpers/profiles remain runtime-only rows, with no Stop Container action. + static func socketCandidate(for activity: Activity, home: String = NSHomeDirectory()) -> String? { + guard activity.process.uid == Int32(getuid()), activity.editorPreview == nil, + let path = activity.process.executablePath else { return nil } + let name = (path as NSString).lastPathComponent + if name == "com.docker.backend", path.contains("/Docker.app/Contents/") { + return home + "/.docker/run/docker.sock" + } + if ["OrbStack", "OrbStack Helper", "OrbStack-helper", "orbstack-helper"].contains(name), + path.contains("/OrbStack.app/Contents/") { + return home + "/.orbstack/run/docker.sock" + } + // A profile-specific forwarder path is required. Generic lima/gvproxy ancestry cannot + // distinguish multiple local VMs and must not silently select the default Docker context. + let prefix = home + "/.colima/_lima/" + if path.hasPrefix(prefix), ["docker-proxy", "gvproxy"].contains(name), + let vm = path.dropFirst(prefix.count).split(separator: "/").first { + guard vm == "colima" || vm.hasPrefix("colima-") else { return nil } + let profile = vm == "colima" ? "default" : String(vm.dropFirst("colima-".count)) + guard !profile.isEmpty, profile != ".", profile != "..", + profile.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" }) else { return nil } + return home + "/.colima/\(profile)/docker.sock" + } + return nil + } + + static func verifiedEndpoint(for activity: Activity) -> ContainerEndpoint? { + guard let candidate = socketCandidate(for: activity) else { return nil } + let canonical = URL(fileURLWithPath: candidate).resolvingSymlinksInPath().path + let home = NSHomeDirectory() + let allowedRoots: [String] + if candidate.contains("/.docker/") { + allowedRoots = [home + "/.docker/run/", home + "/Library/Containers/com.docker.docker/Data/"] + } else { + allowedRoots = [(candidate as NSString).deletingLastPathComponent + "/"] + } + guard allowedRoots.contains(where: canonical.hasPrefix) else { return nil } + var stats = stat() + guard stat(canonical, &stats) == 0, (stats.st_mode & S_IFMT) == S_IFSOCK else { return nil } + return ContainerEndpoint(socketPath: canonical, device: UInt64(UInt32(bitPattern: stats.st_dev)), inode: UInt64(stats.st_ino)) + } + + /// Check every IO operation as well as confirmation: a replaced socket/symlink is not the + /// endpoint that was reviewed, even if the replacement happens to expose similar containers. + static func endpointIsCurrent(_ endpoint: ContainerEndpoint) -> Bool { + guard URL(fileURLWithPath: endpoint.socketPath).resolvingSymlinksInPath().path == endpoint.socketPath else { return false } + var stats = stat() + return stat(endpoint.socketPath, &stats) == 0 && (stats.st_mode & S_IFMT) == S_IFSOCK + && UInt64(UInt32(bitPattern: stats.st_dev)) == endpoint.device && UInt64(stats.st_ino) == endpoint.inode + } + + public static func containers(for activities: [Activity]) -> [String: ContainerInfo] { + resolve(activities, dependencies: Dependencies(), backoff: backoff, now: Date()) + } + + static func resolve(_ activities: [Activity], dependencies: Dependencies, + backoff: Backoff, now: Date) -> [String: ContainerInfo] { + var grouped: [ContainerEndpoint: [Activity]] = [:] + for activity in activities { + guard let endpoint = dependencies.endpoint(activity) else { continue } + grouped[endpoint, default: []].append(activity) + } + var result: [String: ContainerInfo] = [:] + for (endpoint, members) in grouped { + let key = "\(endpoint.host)|\(endpoint.device)|\(endpoint.inode)" + var inventory: [ContainerInfo]? + if !backoff.blocked(key + ":socket", now: now) { + if let info = dependencies.get(endpoint, "/info"), let daemon = parseDaemonID(info), + let data = dependencies.get(endpoint, "/containers/json"), + let parsed = parseEngineList(data, endpoint: endpoint, daemonID: daemon) { + inventory = parsed + backoff.succeeded(key + ":socket") + } else { + backoff.failed(key + ":socket", now: now) + } + } + if inventory == nil, !backoff.blocked(key + ":cli", now: now) { + inventory = try? cliInventory(endpoint, command: dependencies.command) + if inventory == nil { backoff.failed(key + ":cli", now: now) } + else { backoff.succeeded(key + ":cli") } + } + guard let inventory else { continue } + for activity in members { + if let owner = match(activity.listener, in: inventory) { result[activity.id] = owner } + } + } + return result + } + + /// Exact normalized TCP binds only. lsof's family-less `*` is accepted only for a wildcard + /// mapping, with one possible container on the entire port. Never pick a first match. + static func match(_ listener: Listener, in containers: [ContainerInfo]) -> ContainerInfo? { + guard !listener.addresses.isEmpty, + containers.allSatisfy({ ContainerInfo.isFullID($0.id) && $0.endpoint != nil && !$0.daemonID.isEmpty }) + else { return nil } + var owners: [ContainerInfo] = [] + for rawAddress in listener.addresses { + guard let address = normalizedAddress(rawAddress) else { return nil } + let candidates = containers.filter { container in + container.bindings.contains { + $0.transport == "tcp" && $0.hostPort == listener.port + && (address == "*" ? ["0.0.0.0", "::"].contains($0.address) : address == $0.address) + } + } + let allOnPort = containers.filter { $0.bindings.contains { $0.transport == "tcp" && $0.hostPort == listener.port } } + guard Set(candidates.map(\.identity)).count == 1, let owner = candidates.first, + address != "*" || Set(allOnPort.map(\.identity)).count == 1 else { return nil } + owners.append(owner) + } + guard Set(owners.map(\.identity)).count == 1 else { return nil } + return owners.first + } + + static func normalizedAddress(_ raw: String) -> String? { + let text = raw.trimmingCharacters(in: CharacterSet(charactersIn: "[]")).lowercased() + if text == "*" { return text } + var v4 = in_addr() + var v6 = in6_addr() + var buffer = [CChar](repeating: 0, count: Int(INET6_ADDRSTRLEN)) + if inet_pton(AF_INET, text, &v4) == 1, inet_ntop(AF_INET, &v4, &buffer, socklen_t(INET6_ADDRSTRLEN)) != nil { + return String(cString: buffer) + } + if inet_pton(AF_INET6, text, &v6) == 1, inet_ntop(AF_INET6, &v6, &buffer, socklen_t(INET6_ADDRSTRLEN)) != nil { + return String(cString: buffer) + } + return nil + } + + static func parseDaemonID(_ data: Data) -> String? { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let id = json["ID"] as? String, !id.isEmpty else { return nil } + return id + } + + static func parseEngineList(_ data: Data, endpoint: ContainerEndpoint, daemonID: String) -> [ContainerInfo]? { + guard !daemonID.isEmpty, + let entries = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return nil } + var result: [ContainerInfo] = [] + for entry in entries { + guard let id = entry["Id"] as? String, ContainerInfo.isFullID(id), + let names = entry["Names"] as? [String], let name = names.first, + let ports = entry["Ports"] as? [[String: Any]] else { return nil } + var bindings: Set = [] + for port in ports { + guard let hostPort = port["PublicPort"] as? Int else { continue } + guard let address = port["IP"] as? String, let normalized = normalizedAddress(address), normalized != "*", + let transport = port["Type"] as? String, ["tcp", "udp", "sctp"].contains(transport), + let privatePort = port["PrivatePort"] as? Int, + (1...65535).contains(hostPort), (1...65535).contains(privatePort) else { return nil } + bindings.insert(ContainerPortBinding(address: normalized, hostPort: hostPort, + containerPort: privatePort, transport: transport)) + } + result.append(makeInfo(entry, id: id, name: name, image: entry["Image"] as? String ?? "", + labels: entry["Labels"] as? [String: String], bindings: bindings, + endpoint: endpoint, daemonID: daemonID)) + } + return result + } + + static func parseInspection(_ entry: [String: Any], endpoint: ContainerEndpoint, daemonID: String) -> ContainerInspection? { + guard let id = entry["Id"] as? String, ContainerInfo.isFullID(id), !daemonID.isEmpty, + let name = entry["Name"] as? String, + let config = entry["Config"] as? [String: Any], let image = config["Image"] as? String, + let imageID = entry["Image"] as? String, !imageID.isEmpty, + let state = entry["State"] as? [String: Any], state["Running"] as? Bool == true, + let startedAt = state["StartedAt"] as? String, !startedAt.isEmpty, + let network = entry["NetworkSettings"] as? [String: Any], + let ports = network["Ports"] as? [String: Any] else { return nil } + var bindings: Set = [] + for (key, value) in ports { + let parts = key.split(separator: "/") + guard parts.count == 2, let privatePort = Int(parts[0]), (1...65535).contains(privatePort), + ["tcp", "udp", "sctp"].contains(String(parts[1])) else { return nil } + if value is NSNull { continue } + guard let published = value as? [[String: String]] else { return nil } + for mapping in published { + guard let raw = mapping["HostIp"], let address = normalizedAddress(raw), address != "*", + let host = mapping["HostPort"], let hostPort = Int(host), (1...65535).contains(hostPort) else { return nil } + bindings.insert(ContainerPortBinding(address: address, hostPort: hostPort, + containerPort: privatePort, transport: String(parts[1]))) + } + } + let host = entry["HostConfig"] as? [String: Any] + let policy = host?["RestartPolicy"] as? [String: Any] + let policyName = (policy?["Name"] as? String).flatMap { + ["no", "always", "unless-stopped", "on-failure"].contains($0) ? $0 : nil + } + let info = makeInfo(entry, id: id, name: name, image: image, labels: config["Labels"] as? [String: String], + bindings: bindings, endpoint: endpoint, daemonID: daemonID) + return ContainerInspection(container: info, restartPolicy: policyName?.isEmpty == false ? policyName : nil, + maximumRetryCount: policy?["MaximumRetryCount"] as? Int, startedAt: startedAt, imageID: imageID) + } + + private static func makeInfo(_ entry: [String: Any], id: String, name: String, image: String, + labels: [String: String]?, bindings: Set, + endpoint: ContainerEndpoint, daemonID: String) -> ContainerInfo { + let mounts = (entry["Mounts"] as? [[String: Any]] ?? []).compactMap { mount -> ContainerMount? in + guard mount["Type"] as? String == "bind", let source = mount["Source"] as? String, + let destination = mount["Destination"] as? String else { return nil } + return ContainerMount(source: source, destination: destination) + }.sorted { ($0.source, $0.destination) < ($1.source, $1.destination) } + return ContainerInfo(id: id, name: name.hasPrefix("/") ? String(name.dropFirst()) : name, image: image, + composeProject: labels?["com.docker.compose.project"], composeDir: composeDirectory(labels: labels), + mounts: mounts, endpoint: endpoint, daemonID: daemonID, bindings: bindings) + } + + static func composeDirectory(labels: [String: String]?) -> String? { + if let file = labels?["com.docker.compose.project.config_files"]?.split(separator: ",").first.map(String.init) { + let dir = (file as NSString).deletingLastPathComponent + if !dir.isEmpty { return dir } + } + return labels?["com.docker.compose.project.working_dir"] + } + + private static func cliInventory(_ endpoint: ContainerEndpoint, + command: (ContainerEndpoint, [String], TimeInterval) throws -> String) throws -> [ContainerInfo] { + let daemon = try command(endpoint, ["info", "--format", "{{.ID}}"], 3).trimmingCharacters(in: .whitespacesAndNewlines) + guard !daemon.isEmpty else { throw unavailable() } + let ids = try command(endpoint, ["container", "ls", "--quiet", "--no-trunc"], 3) + .split(whereSeparator: \.isWhitespace).map(String.init) + guard ids.allSatisfy(ContainerInfo.isFullID), ids.count <= 256 else { throw unavailable() } + if ids.isEmpty { return [] } + let output = try command(endpoint, ["container", "inspect"] + ids, 3) + guard let entries = try JSONSerialization.jsonObject(with: Data(output.utf8)) as? [[String: Any]], + entries.count == ids.count else { throw unavailable() } + let inspections = entries.compactMap { parseInspection($0, endpoint: endpoint, daemonID: daemon) } + guard inspections.count == ids.count, Set(inspections.map { $0.container.id }) == Set(ids) else { throw unavailable() } + return inspections.map(\.container) + } + + /// No action uses a current context or a mutable name. Clear inherited Docker overrides even + /// with --host, so DOCKER_CONTEXT/TLS/API-version settings cannot redirect or alter this call. + static func dockerEnvironment(_ environment: [String: String]) -> [String: String] { + environment.filter { !$0.key.hasPrefix("DOCKER_") } + } + static func runDocker(_ endpoint: ContainerEndpoint, _ arguments: [String], _ timeout: TimeInterval) throws -> String { + guard endpointIsCurrent(endpoint) else { throw unavailable() } + guard let docker = dockerExecutable() else { + throw CloseError(L("The `docker` command is not available on this Mac.", "这台 Mac 上没有 `docker` 命令。")) + } + return try CommandRunner.output(docker, ["--host", endpoint.host] + arguments, timeout: timeout, + environment: dockerEnvironment(ProcessInfo.processInfo.environment)) + } + static func dockerExecutable() -> String? { + ["/usr/local/bin/docker", "/opt/homebrew/bin/docker", "/usr/bin/docker", NSHomeDirectory() + "/.orbstack/bin/docker"] + .first { FileManager.default.isExecutableFile(atPath: $0) } + } + static func unavailable() -> CloseError { + CloseError(L("Local container ownership could not be verified. Refresh and review again; nothing was stopped.", + "无法确认本机容器归属。请刷新后重新确认,未停止任何容器。")) + } + + static func inspect(_ container: ContainerInfo, dependencies: Dependencies = Dependencies()) throws -> ContainerInspection { + guard let endpoint = container.endpoint, ContainerInfo.isFullID(container.id), !container.daemonID.isEmpty else { throw unavailable() } + if let info = dependencies.get(endpoint, "/info"), let daemon = parseDaemonID(info) { + guard daemon == container.daemonID else { throw unavailable() } + if let data = dependencies.get(endpoint, "/containers/\(container.id)/json"), + let entry = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let inspection = parseInspection(entry, endpoint: endpoint, daemonID: daemon) { + guard inspection.container.id == container.id else { throw unavailable() } + return inspection + } + } + let daemon = try dependencies.command(endpoint, ["info", "--format", "{{.ID}}"], 3).trimmingCharacters(in: .whitespacesAndNewlines) + guard daemon == container.daemonID else { throw unavailable() } + let output = try dependencies.command(endpoint, ["container", "inspect", container.id], 3) + guard let entries = try JSONSerialization.jsonObject(with: Data(output.utf8)) as? [[String: Any]], entries.count == 1, + let inspection = parseInspection(entries[0], endpoint: endpoint, daemonID: daemon), + inspection.container.id == container.id else { throw unavailable() } + return inspection + } + + public static func prepareStop(_ activity: Activity) throws -> ContainerStopPlan { + let snapshot = try Scanner.scan() + guard let old = activity.container, let fresh = snapshot.activities.first(where: { $0.id == activity.id }), + let owner = fresh.container, owner.identity == old.identity, owner.endpoint == old.endpoint, + fresh.listener == activity.listener else { throw unavailable() } + return try makePlan(activity: fresh, inspection: inspect(owner)) + } + static func makePlan(activity: Activity, inspection: ContainerInspection) throws -> ContainerStopPlan { + guard let selected = activity.container, selected.identity == inspection.container.identity, + selected.endpoint == inspection.container.endpoint, + match(activity.listener, in: [inspection.container])?.identity == selected.identity else { throw unavailable() } + return ContainerStopPlan(activity: activity, inspection: inspection) + } + + public static func stop(_ plan: ContainerStopPlan) throws -> String { + try execute(plan, scan: Scanner.scan, inspect: { try inspect($0) }, + endpoint: { verifiedEndpoint(for: $0) }, command: runDocker, + checkCancellation: { try Task.checkCancellation() }) + } + static func execute(_ plan: ContainerStopPlan, scan: () throws -> ScanSnapshot, + inspect: (ContainerInfo) throws -> ContainerInspection, + endpoint: (Activity) -> ContainerEndpoint?, + command: (ContainerEndpoint, [String], TimeInterval) throws -> String, + checkCancellation: () throws -> Void) throws -> String { + try checkCancellation() + guard ContainerInfo.isFullID(plan.container.id), !plan.container.daemonID.isEmpty else { throw unavailable() } + let fresh = try scan() + guard let activity = fresh.activities.first(where: { $0.id == plan.activity.id }), + activity.listener == plan.activity.listener, + activity.process.executablePath == plan.activity.process.executablePath, + activity.process.uid == plan.activity.process.uid, + activity.container?.identity == plan.container.identity, + let pinned = plan.container.endpoint, endpoint(activity) == pinned else { throw unavailable() } + // Fresh inspection is deliberately after the fresh scan and immediately before the write. + let current = try inspect(plan.container) + guard current == plan.inspection else { + throw CloseError(L("The container or its impact changed. Review it again; nothing was stopped.", + "容器或影响范围已改变。请重新确认,未停止任何容器。")) + } + try checkCancellation() + return try command(pinned, ["stop", plan.container.id], 20) + } +} + +/// The minimal HTTP/1.1 client the Engine API needs over its unix socket. Foundation's URLSession +/// cannot speak unix sockets; `Connection: close` keeps the response framing trivial — the daemon +/// closes the socket, which ends the read. +enum UnixSocketHTTP { + /// Blocking GET; safe off the main thread (the scan runs on a detached utility task). + static func get(_ socketPath: String, path: String, timeout: TimeInterval) -> Data? { + let connection = NWConnection(to: .unix(path: socketPath), using: .tcp) + let finished = DispatchSemaphore(value: 0) + let box = ResultBox() + let queue = DispatchQueue(label: "leftopen.docker.socket") + let deadline = Date().addingTimeInterval(timeout) + + queue.asyncAfter(deadline: .now() + timeout) { + box.cancelIfUnsettled(connection) + finished.signal() + } + connection.stateUpdateHandler = { state in + switch state { + case .ready: + let request = "GET \(path) HTTP/1.1\r\nHost: docker\r\nConnection: close\r\n\r\n" + connection.send(content: Data(request.utf8), completion: .contentProcessed { error in + if error != nil { + box.cancelIfUnsettled(connection) + finished.signal() + } else { + receive(connection, into: box, on: queue, deadline: deadline, done: finished) + } + }) + case .failed, .cancelled: + box.cancelIfUnsettled(connection) + finished.signal() + default: + break + } + } + connection.start(queue: queue) + _ = finished.wait() + guard let raw = box.data else { return nil } + return responseBody(raw) + } + + /// Splits status, headers and body, and decodes a chunked body — the Docker daemon chunks + /// `/containers/json` even when asked for HTTP/1.0, so the framing has to be undone before + /// the JSON can parse. + static func responseBody(_ raw: Data) -> Data? { + guard let headerEnd = raw.range(of: Data("\r\n\r\n".utf8)) else { return nil } + let head = String(decoding: raw[raw.startIndex..= 2, fields[1] == "200" else { return nil } + var body = raw.subdata(in: headerEnd.upperBound..\r\n\r\n` chunks ending in a zero-size chunk. + static func dechunked(_ data: Data) -> Data? { + var payload = Data() + var index = data.startIndex + while index < data.endIndex { + guard let lineEnd = data.range(of: Data("\r\n".utf8), in: index..= 0 else { return nil } + index = lineEnd.upperBound + if size == 0 { return payload } + guard data.distance(from: index, to: data.endIndex) >= size else { return nil } + let chunkStart = index + index = data.index(index, offsetBy: size) + payload.append(data[chunkStart..= 2 else { return nil } + index = data.index(index, offsetBy: 2) + } + return nil + } + + private static func receive(_ connection: NWConnection, into box: ResultBox, on queue: DispatchQueue, + deadline: Date, done: DispatchSemaphore) { + connection.receive(minimumIncompleteLength: 1, maximumLength: 256 * 1024) { data, _, isComplete, error in + if let data, !data.isEmpty { box.append(data) } + if error != nil || isComplete || Date() >= deadline { + box.settle(connection) + done.signal() + return + } + receive(connection, into: box, on: queue, deadline: deadline, done: done) + } + } + + /// One answer per request: the first terminal event wins and cancels the connection. + private final class ResultBox: @unchecked Sendable { + private let lock = NSLock() + private var buffer = Data() + private var settled = false + + var data: Data? { + lock.lock(); defer { lock.unlock() } + return settled ? buffer : nil + } + + func append(_ chunk: Data) { + lock.lock(); defer { lock.unlock() } + guard !settled else { return } + buffer.append(chunk) + } + + func settle(_ connection: NWConnection) { + lock.lock() + if !settled { settled = true } else { lock.unlock(); return } + lock.unlock() + connection.cancel() + } + + /// A timeout or failure path for requests that never produced a complete response. + func cancelIfUnsettled(_ connection: NWConnection) { + lock.lock() + if settled { lock.unlock(); return } + buffer = Data() + settled = true + lock.unlock() + connection.cancel() + } + } +} diff --git a/Sources/LeftOpenCore/Models.swift b/Sources/LeftOpenCore/Models.swift index 34d5cb3..936e233 100644 --- a/Sources/LeftOpenCore/Models.swift +++ b/Sources/LeftOpenCore/Models.swift @@ -124,9 +124,11 @@ public struct Activity: Sendable, Equatable, Identifiable { public let projectMarker: ProjectMarker? public let applicationBundle: ApplicationBundle? public let scope: ListenerScope - public let inference: OwnerInference + public var inference: OwnerInference public var editorPreview: EditorPreview? = nil public var launchdJob: LaunchdJob? = nil + /// Set only when a verified local forwarder and TCP bind match one immutable container identity. + public var container: ContainerInfo? = nil public var id: String { "\(listener.port):\(listener.pid)" } diff --git a/Sources/LeftOpenCore/PortCategory.swift b/Sources/LeftOpenCore/PortCategory.swift index c4f6d12..5b22070 100644 --- a/Sources/LeftOpenCore/PortCategory.swift +++ b/Sources/LeftOpenCore/PortCategory.swift @@ -2,13 +2,16 @@ import Foundation /// Where a listening process comes from, which is also how it is closed for good. The same program /// can land in any category: Postgres started in a terminal is a dev server, from `brew services` -/// a background service, inside Docker Desktop an app. So the category rests on who started the -/// process, never on lists of program names or port numbers, and the row name says what it is. +/// a background service, inside Docker Desktop a container. So the category rests on who started +/// the process, never on lists of program names or port numbers, and the row name says what it is. +/// Container runtimes are the one name-based exception: they forward container ports through their +/// own host processes, so the runtime itself is the owner the row should name. /// /// Verified editor previews are classified per listener; other sockets keep their host category. public enum PortCategory: String, CaseIterable, Sendable { case devServer = "dev-server" case background + case container case app case system case other @@ -17,6 +20,7 @@ public enum PortCategory: String, CaseIterable, Sendable { switch self { case .devServer: L("Dev Servers", "开发服务器") case .background: L("Background Services", "后台服务") + case .container: L("Containers", "容器") case .app: L("Apps", "App") case .system: L("System", "系统") case .other: L("Other", "其他") @@ -30,6 +34,8 @@ public enum PortCategory: String, CaseIterable, Sendable { "从项目、终端、编辑器或 agent 启动。编辑器内的预览请在编辑器中停止;其他服务通过 SIGTERM 关闭。") case .background: L("Run by launchd (brew services, login items). Stop the service, or launchd may start it again.", "由 launchd 管理(brew services、登录项)。请停止对应服务,否则 launchd 可能会再次启动它。") + case .container: L("Forwarded by a container runtime (Docker Desktop, OrbStack, colima). Open a port to stop its container with docker stop.", + "由容器运行时转发(Docker Desktop、OrbStack、colima)。点开端口详情,即可用 docker stop 停止对应容器。") case .app: L("Opened by a running app. Quit the app to free them.", "由正在运行的 App 打开。退出该 App 即可释放。") case .system: L("Belong to macOS or another user. Sharing features are turned off in System Settings.", "属于 macOS 或其他用户。共享类功能可在“系统设置”中关闭。") @@ -54,6 +60,7 @@ public enum PortCategory: String, CaseIterable, Sendable { public static func classify(_ activities: [Activity]) -> PortCategory { guard let activity = activities.first else { return .other } if isSystem(activity) { return .system } + if isContainerRuntime(activity) { return .container } if activity.launchdJob != nil { return .background } if activities.allSatisfy({ $0.editorPreview != nil }) { return .devServer } if activity.applicationBundle?.direct == true { return .app } @@ -70,6 +77,24 @@ public enum PortCategory: String, CaseIterable, Sendable { .contains(where: path.hasPrefix) } + /// Container runtimes forward container ports through their own host processes: Docker Desktop + /// via `com.docker.backend` inside its app bundle, OrbStack through helpers in its bundle, + /// colima or a standalone daemon via `docker-proxy`/`gvproxy`. The forwarder, not the container, + /// holds the port — killing it only breaks the mapping while the container keeps running, so + /// these rows name the runtime (or the container itself, once resolved) and close via + /// `docker stop`. The check looks at the listener and its ancestors, never at port numbers. + static func isContainerRuntime(_ activity: Activity) -> Bool { + if let bundle = activity.applicationBundle, + ["docker", "orbstack"].contains(bundle.name.lowercased()) { return true } + let forwarders: Set = [ + "com.docker.backend", "docker-proxy", "dockerd", "gvproxy", "colima", "lima", "limactl", + ] + let names = ([activity.process.executablePath] + activity.parentChain.map(\.executablePath)) + .compactMap { $0 } + .map { ($0 as NSString).lastPathComponent.lowercased() } + return names.contains { forwarders.contains($0) } + } + /// A shell among the ancestors means someone ran it: a terminal, an editor's terminal or task, /// tmux, or an agent's command. Login shells show up as `-zsh`. private static func startedFromShell(_ activity: Activity) -> Bool { diff --git a/Sources/LeftOpenCore/Scanner.swift b/Sources/LeftOpenCore/Scanner.swift index 8fd1edf..2321cad 100644 --- a/Sources/LeftOpenCore/Scanner.swift +++ b/Sources/LeftOpenCore/Scanner.swift @@ -26,7 +26,8 @@ enum CommandRunner { static func output(_ executable: String, _ arguments: [String], allowEmptyLsof: Bool = false, timeout: TimeInterval = defaultTimeout, - maxOutputBytes: Int = defaultMaxOutputBytes) throws -> String { + maxOutputBytes: Int = defaultMaxOutputBytes, + environment: [String: String]? = nil) throws -> String { guard FileManager.default.isExecutableFile(atPath: executable) else { throw ScanError.missingTool(executable) } @@ -34,6 +35,7 @@ enum CommandRunner { let stdout = Pipe() process.executableURL = URL(fileURLWithPath: executable) process.arguments = arguments + if let environment { process.environment = environment } process.standardOutput = stdout process.standardError = FileHandle.nullDevice let exited = DispatchSemaphore(value: 0) @@ -318,7 +320,33 @@ public enum Scanner { activity.editorPreview = preview return activity } - return ScanSnapshot(activities: activities, limitations: limitations) + return ScanSnapshot(activities: resolveContainers(activities), limitations: limitations) + } + + /// Resolve only verified local forwarders, then repeat the endpoint/address guard on assignment. + /// Native processes sharing a numeric port must never inherit container metadata. + static func resolveContainers(_ activities: [Activity], + lookup: ([Activity]) -> [String: ContainerInfo] = ContainerResolver.containers, + endpoint: (Activity) -> ContainerEndpoint? = { ContainerResolver.verifiedEndpoint(for: $0) }) -> [Activity] { + let forwarded = activities.filter { endpoint($0) != nil } + let containers = lookup(forwarded) + guard !containers.isEmpty else { return activities } + return activities.map { activity in + guard let verified = endpoint(activity), let container = containers[activity.id], + container.endpoint == verified, + ContainerResolver.match(activity.listener, in: [container])?.identity == container.identity + else { return activity } + let detail = container.composeProject.map { project in + L("compose project \(project)", "compose 项目 \(project)") + } ?? container.image + let inference = OwnerInference(label: container.displayName, category: .service, confidence: "high", + reason: L("Port \(activity.listener.port) is forwarded to container \(container.name) (\(detail)).", + "端口 \(activity.listener.port) 转发到容器 \(container.name)(\(detail))。")) + var updated = activity + updated.inference = inference + updated.container = container + return updated + } } /// launchd jobs whose PID is one of the listeners or their direct parents (a service may run a diff --git a/Tests/LeftOpenCoreTests/ContainerSafetyTests.swift b/Tests/LeftOpenCoreTests/ContainerSafetyTests.swift new file mode 100644 index 0000000..031d44c --- /dev/null +++ b/Tests/LeftOpenCoreTests/ContainerSafetyTests.swift @@ -0,0 +1,328 @@ +import Foundation +import XCTest +@testable import LeftOpenCore + +/// Pure fixtures and injected IO: no Docker socket, context, process-global backoff or local daemon. +final class ContainerSafetyTests: XCTestCase { + private let idA = String(repeating: "a", count: 64) + private let idB = String(repeating: "b", count: 64) + private let endpoint = ContainerEndpoint(socketPath: "/Users/test/.docker/run/docker.sock", device: 1, inode: 2) + private let otherEndpoint = ContainerEndpoint(socketPath: "/Users/test/.orbstack/run/docker.sock", device: 1, inode: 3) + + private func binding(_ address: String = "127.0.0.1", port: Int = 5353, + transport: String = "tcp") -> ContainerPortBinding { + ContainerPortBinding(address: address, hostPort: port, containerPort: 80, transport: transport) + } + private func container(id: String? = nil, name: String = "web", endpoint: ContainerEndpoint? = nil, + daemon: String = "local-daemon", bindings: Set? = nil) -> ContainerInfo { + ContainerInfo(id: id ?? idA, name: name, image: "nginx", endpoint: endpoint ?? self.endpoint, + daemonID: daemon, bindings: bindings ?? [binding()]) + } + private func activity(_ info: ContainerInfo? = nil, addresses: [String] = ["127.0.0.1"], + port: Int = 5353, pid: Int32 = 42, + path: String = "/Applications/Docker.app/Contents/MacOS/com.docker.backend") -> Activity { + let listener = Listener(pid: pid, command: "fixture", uid: Int32(getuid()), user: nil, port: port, addresses: addresses) + let process = ProcessFact(pid: pid, ppid: 1, command: "fixture", executablePath: path, + uid: Int32(getuid()), user: nil, cwd: nil) + var result = Activity(listener: listener, process: process, parentChain: [], projectMarker: nil, + applicationBundle: nil, scope: .local, + inference: OwnerInference(label: "Fixture", category: .service, confidence: "high", reason: "Fixture")) + result.container = info + return result + } + private func inspection(_ info: ContainerInfo? = nil, policy: String? = "no", started: String = "2026-10-01T00:00:00Z") -> ContainerInspection { + ContainerInspection(container: info ?? container(), restartPolicy: policy, maximumRetryCount: nil, startedAt: started, imageID: "sha256:image") + } + private func snapshot(_ activities: [Activity]) -> ScanSnapshot { ScanSnapshot(activities: activities, limitations: []) } + private func data(_ object: Any) throws -> Data { try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) } + private func engine(_ id: String, address: String, transport: String = "tcp", name: String = "web") -> [String: Any] { + ["Id": id, "Names": ["/" + name], "Image": "nginx", "Ports": [ + ["IP": address, "PublicPort": 5353, "PrivatePort": 80, "Type": transport] + ], "Labels": ["com.docker.compose.project": "demo", + "com.docker.compose.project.config_files": "/Users/test/project/compose.yaml"], + "Mounts": [["Type": "bind", "Source": "/Users/test/project", "Destination": "/app"]]] + } + private func inspectJSON(id: String? = nil, name: String = "web", transport: String = "tcp") -> [String: Any] { + ["Id": id ?? idA, "Name": "/" + name, "Image": "sha256:image", "Config": ["Image": "nginx"], + "State": ["Running": true, "StartedAt": "2026-10-01T00:00:00Z"], + "HostConfig": ["RestartPolicy": ["Name": "no"]], + "NetworkSettings": ["Ports": ["80/" + transport: [["HostIp": "127.0.0.1", "HostPort": "5353"]]]]] + } + private func plan(_ info: ContainerInfo? = nil) throws -> ContainerStopPlan { + let info = info ?? container() + return try ContainerResolver.makePlan(activity: activity(info), inspection: inspection(info)) + } + + func testUDPDoesNotOwnTCPListenerAndOrderDoesNotMatter() throws { + let udp = engine(idA, address: "127.0.0.1", transport: "udp", name: "udp-only") + let tcp = engine(idB, address: "127.0.0.1", name: "web") + for entries in [[udp, tcp], [tcp, udp]] { + let parsed = try XCTUnwrap(ContainerResolver.parseEngineList(try data(entries), endpoint: endpoint, daemonID: "local")) + XCTAssertEqual(ContainerResolver.match(activity().listener, in: parsed)?.id, idB) + } + let onlyUDP = try XCTUnwrap(ContainerResolver.parseEngineList(try data([udp]), endpoint: endpoint, daemonID: "local")) + XCTAssertNil(ContainerResolver.match(activity().listener, in: onlyUDP)) + } + + func testAddressMatchingRefusesPortOnlyAndDuplicateOwners() { + let first = container(bindings: [binding("127.0.0.1")]) + let second = container(id: idB, bindings: [binding("127.0.0.2")]) + XCTAssertEqual(ContainerResolver.match(activity().listener, in: [first, second])?.id, idA) + XCTAssertEqual(ContainerResolver.match(activity(addresses: ["127.0.0.2"]).listener, in: [first, second])?.id, idB) + XCTAssertNil(ContainerResolver.match(activity(addresses: ["*"]).listener, in: [first, second])) + XCTAssertNil(ContainerResolver.match(activity().listener, in: [first, container(id: idB)])) + XCTAssertNil(ContainerResolver.match(activity(addresses: ["127.0.0.1", "127.0.0.2"]).listener, in: [first, second])) + } + + func testWildcardRequiresOneOwnerAcrossAllAddresses() { + let wildcard = container(bindings: [binding("0.0.0.0"), binding("::")]) + XCTAssertEqual(ContainerResolver.match(activity(addresses: ["*"]).listener, in: [wildcard])?.id, idA) + XCTAssertNil(ContainerResolver.match(activity(addresses: ["*"]).listener, + in: [wildcard, container(id: idB, bindings: [binding("127.0.0.2")])])) + XCTAssertNil(ContainerResolver.match(activity().listener, in: [wildcard]), "Do not guess wildcard-to-specific forwarding") + } + + func testIPv6NormalizationAndUnknownAddresses() { + XCTAssertEqual(ContainerResolver.normalizedAddress("[0:0:0:0:0:0:0:1]"), "::1") + XCTAssertEqual(ContainerResolver.normalizedAddress("[::]"), "::") + XCTAssertNil(ContainerResolver.normalizedAddress("some-host.example")) + let owner = container(bindings: [binding("::1")]) + XCTAssertEqual(ContainerResolver.match(activity(addresses: ["[::1]"]).listener, in: [owner])?.id, idA) + } + + func testEngineKeepsFullIdentityComposeAndBindMounts() throws { + let parsed = try XCTUnwrap(ContainerResolver.parseEngineList(try data([engine(idA, address: "127.0.0.1")]), endpoint: endpoint, daemonID: "local")) + let info = try XCTUnwrap(parsed.first) + XCTAssertEqual(info.id, idA) + XCTAssertEqual(info.endpoint, endpoint) + XCTAssertEqual(info.daemonID, "local") + XCTAssertEqual(info.composeProject, "demo") + XCTAssertEqual(info.composeDir, "/Users/test/project") + XCTAssertEqual(info.mounts, [ContainerMount(source: "/Users/test/project", destination: "/app")]) + XCTAssertEqual(info.bindings, [binding()]) + } + + func testIncompleteEngineOwnershipFailsClosed() throws { + XCTAssertNil(ContainerResolver.parseEngineList(try data([engine("aaaaaaaaaaaa", address: "127.0.0.1")]), endpoint: endpoint, daemonID: "local")) + var missingIP = engine(idA, address: "127.0.0.1") + missingIP["Ports"] = [["PublicPort": 5353, "PrivatePort": 80, "Type": "tcp"]] + XCTAssertNil(ContainerResolver.parseEngineList(try data([missingIP]), endpoint: endpoint, daemonID: "local")) + XCTAssertNil(ContainerResolver.parseEngineList(Data("[]".utf8), endpoint: endpoint, daemonID: "")) + } + + func testCLIInspectionDistinguishesUDPAndTCP() throws { + let udp = try XCTUnwrap(ContainerResolver.parseInspection(inspectJSON(transport: "udp"), endpoint: endpoint, daemonID: "local")) + let tcp = try XCTUnwrap(ContainerResolver.parseInspection(inspectJSON(id: idB), endpoint: endpoint, daemonID: "local")) + XCTAssertEqual(ContainerResolver.match(activity().listener, in: [udp.container, tcp.container])?.id, idB) + XCTAssertEqual(tcp.container.id, idB) + } + + func testOnlyKnownDirectForwarderGetsEndpointCandidate() { + XCTAssertEqual(ContainerResolver.socketCandidate(for: activity(), home: "/Users/test"), "/Users/test/.docker/run/docker.sock") + XCTAssertEqual(ContainerResolver.socketCandidate(for: activity(path: "/Applications/OrbStack.app/Contents/MacOS/OrbStack-helper"), home: "/Users/test"), "/Users/test/.orbstack/run/docker.sock") + XCTAssertEqual(ContainerResolver.socketCandidate(for: activity(path: "/Users/test/.colima/_lima/colima-work/docker-proxy"), home: "/Users/test"), "/Users/test/.colima/work/docker.sock") + XCTAssertNil(ContainerResolver.socketCandidate(for: activity(path: "/opt/homebrew/bin/gvproxy"), home: "/Users/test")) + XCTAssertNil(ContainerResolver.socketCandidate(for: activity(path: "/opt/homebrew/bin/node"), home: "/Users/test")) + XCTAssertNil(ContainerResolver.socketCandidate(for: activity(path: "/Applications/Docker.app/Contents/MacOS/unrelated"), home: "/Users/test")) + } + + func testScannerNeverAttachesContainerToNativeSamePortOrWrongEndpoint() { + let runtime = activity() + let native = activity(addresses: ["127.0.0.2"], pid: 43, path: "/opt/homebrew/bin/node") + let owner = container() + let resolved = Scanner.resolveContainers([runtime, native], lookup: { candidates in + XCTAssertEqual(candidates.map(\.id), [runtime.id]) + // Even an overbroad lookup cannot smuggle metadata onto the native listener. + return [runtime.id: owner, native.id: owner] + }, endpoint: { $0.id == runtime.id ? self.endpoint : nil }) + XCTAssertEqual(resolved[0].container, owner) + XCTAssertNil(resolved[1].container) + XCTAssertEqual(resolved[1].inference, native.inference) + let wrong = Scanner.resolveContainers([runtime], lookup: { _ in [runtime.id: self.container(endpoint: self.otherEndpoint)] }, endpoint: { _ in self.endpoint }) + XCTAssertNil(wrong[0].container) + } + + func testSocketFailureFallbackBackoffAndRecoveryAreDeterministic() throws { + let output = String(decoding: try data([inspectJSON()]), as: UTF8.self) + let list = try data([engine(idA, address: "127.0.0.1")]) + var socketCalls = 0 + var cliCalls = 0 + var socketHealthy = false + var cliHealthy = true + let deps = ContainerResolver.Dependencies(endpoint: { _ in self.endpoint }, get: { _, path in + socketCalls += 1 + guard socketHealthy else { return nil } + return path == "/info" ? Data(#"{"ID":"local-daemon"}"#.utf8) : list + }, command: { endpoint, args, _ in + XCTAssertEqual(endpoint, self.endpoint) + cliCalls += 1 + guard cliHealthy else { throw ContainerResolver.unavailable() } + if args.first == "info" { return "local-daemon\n" } + if args.contains("ls") { return self.idA + "\n" } + return output + }) + let backoff = ContainerResolver.Backoff() + let start = Date(timeIntervalSince1970: 100) + XCTAssertNotNil(ContainerResolver.resolve([activity()], dependencies: deps, backoff: backoff, now: start)[activity().id]) + XCTAssertEqual(socketCalls, 1) + XCTAssertEqual(cliCalls, 3) + XCTAssertNotNil(ContainerResolver.resolve([activity()], dependencies: deps, backoff: backoff, now: start.addingTimeInterval(1))[activity().id]) + XCTAssertEqual(socketCalls, 1, "Socket backoff never blocks CLI") + cliHealthy = false + XCTAssertTrue(ContainerResolver.resolve([activity()], dependencies: deps, backoff: backoff, now: start.addingTimeInterval(2)).isEmpty) + let afterFailure = cliCalls + XCTAssertTrue(ContainerResolver.resolve([activity()], dependencies: deps, backoff: backoff, now: start.addingTimeInterval(3)).isEmpty) + XCTAssertEqual(cliCalls, afterFailure) + socketHealthy = true + XCTAssertNotNil(ContainerResolver.resolve([activity()], dependencies: deps, backoff: backoff, now: start.addingTimeInterval(61))[activity().id]) + XCTAssertEqual(socketCalls, 3, "Both info and inventory recover after socket delay") + socketHealthy = false + cliHealthy = true + XCTAssertNotNil(ContainerResolver.resolve([activity()], dependencies: deps, backoff: backoff, now: start.addingTimeInterval(122))[activity().id]) + XCTAssertEqual(cliCalls, afterFailure + 3, "CLI also recovers after its own failure delay") + } + + func testFailureOnOneEndpointDoesNotBackOffAnother() throws { + let inventory = try data([engine(idA, address: "127.0.0.1")]) + let backoff = ContainerResolver.Backoff() + var selected = endpoint + var calls = 0 + let deps = ContainerResolver.Dependencies(endpoint: { _ in selected }, get: { endpoint, path in + calls += 1 + guard endpoint == self.otherEndpoint else { return nil } + return path == "/info" ? Data(#"{"ID":"other-local"}"#.utf8) : inventory + }, command: { _, _, _ in throw ContainerResolver.unavailable() }) + let now = Date(timeIntervalSince1970: 100) + XCTAssertTrue(ContainerResolver.resolve([activity()], dependencies: deps, backoff: backoff, now: now).isEmpty) + selected = otherEndpoint + XCTAssertNotNil(ContainerResolver.resolve([activity()], dependencies: deps, backoff: backoff, now: now)[activity().id]) + XCTAssertEqual(calls, 3) + } + + func testUnresolvedEndpointNeverInvokesSocketOrCLI() { + let deps = ContainerResolver.Dependencies(endpoint: { _ in nil }, get: { _, _ in XCTFail("No endpoint"); return nil }, + command: { _, _, _ in XCTFail("No endpoint"); return "" }) + XCTAssertTrue(ContainerResolver.resolve([activity()], dependencies: deps, backoff: ContainerResolver.Backoff(), now: Date()).isEmpty) + } + + func testInheritedRemoteContextAndTLSAreNotPassedToCLI() { + let env = ContainerResolver.dockerEnvironment(["DOCKER_HOST": "ssh://remote", "DOCKER_CONTEXT": "production", + "DOCKER_TLS_VERIFY": "1", "DOCKER_CONFIG": "/other", "PATH": "/bin", "HOME": "/Users/test"]) + XCTAssertEqual(env, ["PATH": "/bin", "HOME": "/Users/test"]) + let info = container(name: "same-name-as-remote") + XCTAssertEqual(info.stopCommand, "docker --host 'unix:///Users/test/.docker/run/docker.sock' stop \(idA)") + XCTAssertFalse(info.stopCommand.contains(info.name)) + } + + func testPinnedInspectRejectsDifferentDaemonWithoutFallback() { + var cliCalls = 0 + let deps = ContainerResolver.Dependencies(endpoint: { _ in self.endpoint }, + get: { _, _ in Data(#"{"ID":"other-daemon"}"#.utf8) }, + command: { _, _, _ in cliCalls += 1; return "" }) + XCTAssertThrowsError(try ContainerResolver.inspect(container(), dependencies: deps)) + XCTAssertEqual(cliCalls, 0) + } + + func testPinnedCLIInspectUsesIDNotNameAndRejectsReplacement() throws { + let replacement = String(decoding: try data([inspectJSON(id: idB)]), as: UTF8.self) + var commands: [[String]] = [] + let deps = ContainerResolver.Dependencies(endpoint: { _ in self.endpoint }, get: { _, _ in nil }, command: { endpoint, args, _ in + XCTAssertEqual(endpoint, self.endpoint) + commands.append(args) + return args.first == "info" ? "local-daemon" : replacement + }) + XCTAssertThrowsError(try ContainerResolver.inspect(container(), dependencies: deps)) + XCTAssertEqual(commands, [["info", "--format", "{{.ID}}"], ["container", "inspect", idA]]) + } + + func testUnchangedConfirmedPlanStopsExactlyOnceByPinnedID() throws { + let plan = try plan() + var writes = 0 + let result = try ContainerResolver.execute(plan, scan: { self.snapshot([self.activity(self.container())]) }, + inspect: { _ in self.inspection() }, endpoint: { _ in self.endpoint }, command: { endpoint, args, _ in + writes += 1 + XCTAssertEqual(endpoint, self.endpoint) + XCTAssertEqual(args, ["stop", self.idA]) + return self.idA + }, checkCancellation: {}) + XCTAssertEqual(writes, 1) + XCTAssertEqual(result, idA) + } + + func testIdentityEndpointMetadataAndScanFailuresNeverStop() throws { + let plan = try plan() + let changedOwners: [ContainerInfo?] = [nil, container(id: idB), container(daemon: "other-daemon"), container(endpoint: otherEndpoint)] + for owner in changedOwners { + var writes = 0 + XCTAssertThrowsError(try ContainerResolver.execute(plan, scan: { self.snapshot([self.activity(owner)]) }, + inspect: { _ in self.inspection() }, endpoint: { _ in self.endpoint }, + command: { _, _, _ in writes += 1; return "" }, checkCancellation: {})) + XCTAssertEqual(writes, 0) + } + var writes = 0 + XCTAssertThrowsError(try ContainerResolver.execute(plan, scan: { throw ContainerResolver.unavailable() }, + inspect: { _ in self.inspection() }, endpoint: { _ in self.endpoint }, + command: { _, _, _ in writes += 1; return "" }, checkCancellation: {})) + XCTAssertThrowsError(try ContainerResolver.execute(plan, scan: { self.snapshot([self.activity(self.container())]) }, + inspect: { _ in self.inspection() }, endpoint: { _ in self.otherEndpoint }, + command: { _, _, _ in writes += 1; return "" }, checkCancellation: {})) + XCTAssertEqual(writes, 0) + } + + func testChangedImpactRenameRestartOrPolicyNeverStops() throws { + let plan = try plan() + let changed = [inspection(container(name: "renamed")), inspection(container(bindings: [binding("127.0.0.2")])), inspection(container(bindings: [binding(), binding(port: 8080)])), + inspection(container(bindings: [binding(), binding(port: 5353, transport: "udp")])), + inspection(policy: "always"), inspection(started: "2026-10-02T00:00:00Z")] + for value in changed { + var writes = 0 + XCTAssertThrowsError(try ContainerResolver.execute(plan, scan: { self.snapshot([self.activity(self.container())]) }, + inspect: { _ in value }, endpoint: { _ in self.endpoint }, + command: { _, _, _ in writes += 1; return "" }, checkCancellation: {})) + XCTAssertEqual(writes, 0) + } + } + + func testCancellationBeforeAndAfterRevalidationNeverStops() throws { + let plan = try plan() + for cancelAt in [1, 2] { + var checks = 0 + var writes = 0 + XCTAssertThrowsError(try ContainerResolver.execute(plan, scan: { self.snapshot([self.activity(self.container())]) }, + inspect: { _ in self.inspection() }, endpoint: { _ in self.endpoint }, + command: { _, _, _ in writes += 1; return "" }, checkCancellation: { + checks += 1 + if checks == cancelAt { throw CancellationError() } + })) + XCTAssertEqual(writes, 0) + } + } + + func testFrozenImpactSurvivesMissingMetadataAndNewOwnership() throws { + let plan = try plan(container(bindings: [binding(), binding(port: 8080), binding(port: 9999, transport: "udp")])) + XCTAssertEqual(plan.reviewedTCPPorts, [5353, 8080]) + XCTAssertEqual(plan.reviewedBindings.count, 3, "Review includes UDP impact even though scanner observes TCP") + XCTAssertEqual(ContainerStopVerification.evaluate(plan, freshSnapshot: snapshot([activity(nil)])), .stillListening([5353])) + XCTAssertEqual(ContainerStopVerification.evaluate(plan, freshSnapshot: snapshot([activity(container(id: idB))])), .stillListening([5353])) + XCTAssertEqual(ContainerStopVerification.evaluate(plan, freshSnapshot: snapshot([activity(nil, port: 8080, pid: 44)])), .stillListening([8080])) + XCTAssertEqual(ContainerStopVerification.evaluate(plan, freshSnapshot: nil), .unavailable) + XCTAssertEqual(ContainerStopVerification.evaluate(plan, freshSnapshot: snapshot([])), .free) + } + + func testSelectedPortCannotDisappearFromAnEmptyImpactSet() { + // Even a defensive/internal plan with no mappings cannot claim the selected port is free. + let info = container(bindings: []) + let plan = ContainerStopPlan(activity: activity(info), inspection: inspection(info)) + XCTAssertEqual(plan.reviewedTCPPorts, [5353]) + XCTAssertEqual(ContainerStopVerification.evaluate(plan, freshSnapshot: snapshot([activity()])), .stillListening([5353])) + XCTAssertThrowsError(try ContainerResolver.makePlan(activity: activity(info), inspection: inspection(info))) + } + + func testUnknownRestartPolicyIsPreservedAndAlwaysPolicyIsFlagged() throws { + let noPolicy = try ContainerResolver.makePlan(activity: activity(container()), inspection: inspection(policy: nil)) + XCTAssertNil(noPolicy.restartPolicy) + XCTAssertFalse(noPolicy.resurrectsOnDaemonRestart) + let always = try ContainerResolver.makePlan(activity: activity(container()), inspection: inspection(policy: "always")) + XCTAssertTrue(always.resurrectsOnDaemonRestart) + } +} diff --git a/Tests/LeftOpenCoreTests/LeftOpenCoreTests.swift b/Tests/LeftOpenCoreTests/LeftOpenCoreTests.swift index ccf6b7d..4cc67d1 100644 --- a/Tests/LeftOpenCoreTests/LeftOpenCoreTests.swift +++ b/Tests/LeftOpenCoreTests/LeftOpenCoreTests.swift @@ -496,12 +496,84 @@ final class LeftOpenCoreTests: XCTestCase { XCTAssertEqual(category(parents: ["some-daemon"]), .other) } + func testContainerRuntimesFormTheirOwnCategory() { + func runtimeActivity(path: String, bundleName: String? = nil, parents: [String] = []) -> Activity { + let listener = Listener(pid: 42, command: "x", uid: Int32(getuid()), user: nil, + port: 8080, addresses: ["127.0.0.1"]) + let process = ProcessFact(pid: 42, ppid: 500, command: "x", executablePath: path, + uid: Int32(getuid()), user: nil, cwd: nil) + let chain = parents.enumerated().map { index, name in + ProcessFact(pid: Int32(100 + index), ppid: nil, command: name, executablePath: nil, + uid: Int32(getuid()), user: nil, cwd: nil) + } + let bundle = bundleName.map { ApplicationBundle(name: $0, path: "/Applications/\($0).app", sourcePID: 99, direct: true) } + return Activity(listener: listener, process: process, parentChain: chain, + projectMarker: nil, applicationBundle: bundle, scope: .local, + inference: OwnerInference(label: bundleName ?? "x", category: .application, + confidence: "high", reason: "Fixture.")) + } + + // Docker Desktop forwards through com.docker.backend inside its app bundle. + let backend = runtimeActivity(path: "/Applications/Docker.app/Contents/MacOS/com.docker.backend", bundleName: "Docker") + XCTAssertEqual(PortCategory.classify([backend]), .container) + XCTAssertTrue(CloseService.protectionReason(for: backend)?.contains("docker ps") == true) + + // OrbStack's forwarders sit inside its bundle. + XCTAssertEqual(PortCategory.classify([runtimeActivity(path: "/Applications/OrbStack.app/Contents/MacOS/OrbStack-helper", bundleName: "OrbStack")]), .container) + + // colima / a standalone daemon forward via docker-proxy, a child of the daemon. + XCTAssertEqual(PortCategory.classify([runtimeActivity(path: "/Users/me/.colima/_lima/colima/docker-proxy", parents: ["dockerd", "colima"])]), .container) + + // Unverified legacy metadata must not produce an actionable name-only stop command. + var resolved = backend + resolved.container = ContainerInfo(name: "leftopen-db", image: "postgres:16") + XCTAssertTrue(CloseService.protectionReason(for: resolved)?.contains("ownership is not verified") == true) + + // An ordinary app still lands in Apps. + XCTAssertEqual(PortCategory.classify([runtimeActivity(path: "/Applications/Editor.app/Contents/MacOS/Editor", bundleName: "Editor")]), .app) + } + + func testContainerNamesPreferSomethingReadable() { + XCTAssertTrue(ContainerInfo.isGeneratedName("nostalgic_turing")) + XCTAssertFalse(ContainerInfo.isGeneratedName("leftopen-db"), "a chosen --name keeps the row") + XCTAssertFalse(ContainerInfo.isGeneratedName("leftopen_db_2"), "compose names have three parts") + XCTAssertFalse(ContainerInfo.isGeneratedName("LeftOpen")) + XCTAssertEqual(ContainerInfo(name: "nostalgic_turing", image: "redis:7-alpine").displayName, "redis") + XCTAssertEqual(ContainerInfo(name: "nostalgic_turing", image: "ghcr.io/acme/api:v2").displayName, "api") + XCTAssertEqual(ContainerInfo(name: "chosen", image: "redis:7").displayName, "chosen") + XCTAssertEqual(ContainerInfo(name: "proj-web-1", image: "nginx", + composeProject: "proj").displayName, "proj-web-1") + } + + /// The Docker daemon chunks `/containers/json`; an undecoded body kills every socket resolve. + func testChunkedEngineResponseIsDecoded() throws { + let json = #"[{"Names":["/leftopen-db"],"Image":"postgres:16","Ports":[{"PublicPort":5432,"Type":"tcp"}],"Labels":null,"Mounts":[]}]"# + let pieces = [json.prefix(30), json.dropFirst(30).prefix(20), json.dropFirst(50)] + var raw = Data("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n".utf8) + for piece in pieces { + raw.append(Data(String(piece.count, radix: 16).utf8)) + raw.append(Data("\r\n".utf8)) + raw.append(Data(piece.utf8)) + raw.append(Data("\r\n".utf8)) + } + raw.append(Data("0\r\n\r\n".utf8)) + + let body = try XCTUnwrap(UnixSocketHTTP.responseBody(raw)) + XCTAssertEqual(String(decoding: body, as: UTF8.self), json) + + // A truncated stream has no zero-size terminator and must not parse halfway. + let truncated = raw.subdata(in: raw.startIndex..` call. + Missing metadata, replaced IDs, renamed/restarted containers or changed impact require a new + review. Cancellation before execution sends no stop command. +- Verification uses a successful scan started after the stop. The selected TCP port is always + included, even if container metadata has disappeared. A failed scan means verification is + unavailable, not that a port is free. A reused port remains a warning regardless of its new + owner. UDP/SCTP mappings are shown as stop impact but are not claimed free by a TCP-only scan. + +As with other inspect-then-act interfaces, Docker does not offer a transactional conditional stop. +The immutable ID prevents a removed container's name from selecting a replacement; final checks +minimize the remaining interval for changes to the same container. No runtime forwarder is +signaled by this action. + +## Verification + +`swift test --filter ContainerSafetyTests` runs deterministic parser, ownership, backoff, +inspection, cancellation and post-stop verification fixtures, without requiring a local daemon. +`swift test` additionally checks the existing core/app behavior. Both require macOS because the +package uses Darwin, Network and AppKit/SwiftUI. + +Before merging, run the full macOS CI matrix and a manual smoke test on the runtimes you support: + +1. Open a TCP container listener and verify the review shows the full ID, explicit local socket, + image and all published bindings, including UDP siblings. +2. Cancel the review. Confirm the container is still running. +3. Confirm an unchanged review. Confirm exactly that container stops; the runtime keeps running. +4. With a review open, rename/restart the container or change its restart policy. Confirmation + must refuse and ask for a new review. +5. If Docker's active context points elsewhere, local discovery/stop must still use the displayed + socket. Unresolved ownership must never fall back to a remote context. + +Use disposable local test containers. Do not use production containers for destructive checks.