mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 20:15:40 +00:00
246 lines
8.8 KiB
Swift
246 lines
8.8 KiB
Swift
import Foundation
|
|
#if os(Linux)
|
|
import Glibc
|
|
#else
|
|
import Darwin
|
|
#endif
|
|
import CCSBarCore
|
|
|
|
/// Reads `~/.ccs/bar/launch.json` and spawns the CCS bar server detached,
|
|
/// redirecting stdout/stderr to `~/.ccs/bar/serve.log`.
|
|
///
|
|
/// If `launch.json` is absent the launcher falls back to resolving `ccs` on
|
|
/// PATH via `/bin/zsh -lic "command -v ccs"` and spawning `ccs bar serve`.
|
|
///
|
|
/// This type is intentionally NOT on the main actor: `Process` is blocking and
|
|
/// must never run on the main thread. Explicit `Sendable` is safe because the
|
|
/// only stored property is an immutable `String`.
|
|
struct BarServerLauncher: Sendable {
|
|
let home: String
|
|
|
|
init(home: String = NSHomeDirectory()) {
|
|
self.home = home
|
|
}
|
|
|
|
// MARK: - Public
|
|
|
|
/// Attempt to start the CCS bar server.
|
|
/// Returns `true` when the process was successfully spawned (detached),
|
|
/// `false` when the launch is not possible (no descriptor, no ccs on PATH).
|
|
/// This does NOT wait for the server to become reachable — callers must
|
|
/// poll BarServerProbe after calling start().
|
|
@discardableResult
|
|
func start() -> Bool {
|
|
if let descriptor = loadDescriptor() {
|
|
return spawnFromDescriptor(descriptor)
|
|
}
|
|
return spawnFallback()
|
|
}
|
|
|
|
// MARK: - Descriptor loading
|
|
|
|
private func loadDescriptor() -> BarLaunchDescriptor? {
|
|
let path = BarLaunchDescriptor.defaultPath(home: home)
|
|
guard isSafeDescriptorFile(path),
|
|
let data = FileManager.default.contents(atPath: path),
|
|
let descriptor = try? JSONDecoder().decode(BarLaunchDescriptor.self, from: data),
|
|
isSafeDescriptor(descriptor)
|
|
else { return nil }
|
|
return descriptor
|
|
}
|
|
|
|
/// Treat launch.json as untrusted because it lives under a user-writable CCS
|
|
/// directory. Refuse symlinks, files not owned by the current user, and files
|
|
/// writable by group/other before decoding executable details.
|
|
private func isSafeDescriptorFile(_ path: String) -> Bool {
|
|
let fm = FileManager.default
|
|
guard fm.fileExists(atPath: path) else { return false }
|
|
|
|
guard let linkValues = try? URL(fileURLWithPath: path).resourceValues(forKeys: [.isSymbolicLinkKey]),
|
|
linkValues.isSymbolicLink != true,
|
|
let attrs = try? fm.attributesOfItem(atPath: path),
|
|
(attrs[.type] as? FileAttributeType) == .typeRegular,
|
|
let owner = attrs[.ownerAccountID] as? NSNumber,
|
|
owner.uint32Value == getuid(),
|
|
let permissions = attrs[.posixPermissions] as? NSNumber
|
|
else { return false }
|
|
|
|
// Disallow group/other write bits. User-writable is expected so CCS can refresh it.
|
|
return (permissions.uint16Value & 0o022) == 0
|
|
}
|
|
|
|
/// Validate the descriptor schema and constrain it to the expected CCS bar
|
|
/// server command shape: runtime absolute path + absolute entry point +
|
|
/// exactly "bar serve". This blocks shell descriptors such as
|
|
/// /bin/sh -c attacker-command while preserving the installed launch path.
|
|
private func isSafeDescriptor(_ descriptor: BarLaunchDescriptor) -> Bool {
|
|
guard descriptor.schema == 1, descriptor.args.count == 3 else { return false }
|
|
guard descriptor.args[1] == "bar", descriptor.args[2] == "serve" else { return false }
|
|
guard isAbsolutePath(descriptor.runtime), isAbsolutePath(descriptor.args[0]) else { return false }
|
|
guard descriptor.home == home else { return false }
|
|
if let ccsHome = descriptor.ccsHome, !ccsHome.isEmpty, !isAbsolutePath(ccsHome) {
|
|
return false
|
|
}
|
|
|
|
let runtimeName = URL(fileURLWithPath: descriptor.runtime).lastPathComponent.lowercased()
|
|
let allowedRuntimes: Set<String> = ["node", "nodejs", "bun"]
|
|
guard allowedRuntimes.contains(runtimeName) else { return false }
|
|
guard FileManager.default.isExecutableFile(atPath: descriptor.runtime) else { return false }
|
|
|
|
let entry = descriptor.args[0]
|
|
let entryName = URL(fileURLWithPath: entry).lastPathComponent.lowercased()
|
|
guard entryName == "ccs.js" || entryName == "ccs.ts" else { return false }
|
|
guard isSafeEntrypointFile(entry), !isUnderCcsDir(entry) else { return false }
|
|
|
|
return true
|
|
}
|
|
|
|
private func isSafeEntrypointFile(_ path: String) -> Bool {
|
|
guard let attrs = try? FileManager.default.attributesOfItem(atPath: path),
|
|
(attrs[.type] as? FileAttributeType) == .typeRegular,
|
|
let permissions = attrs[.posixPermissions] as? NSNumber
|
|
else { return false }
|
|
|
|
return (permissions.uint16Value & 0o022) == 0
|
|
}
|
|
|
|
private func isUnderCcsDir(_ path: String) -> Bool {
|
|
let ccsPath = pathForComparison(
|
|
URL(fileURLWithPath: home)
|
|
.appendingPathComponent(".ccs")
|
|
)
|
|
let targetPath = pathForComparison(URL(fileURLWithPath: path))
|
|
return targetPath == ccsPath || targetPath.hasPrefix(ccsPath + "/")
|
|
}
|
|
|
|
private func pathForComparison(_ url: URL) -> String {
|
|
url.standardizedFileURL
|
|
.resolvingSymlinksInPath()
|
|
.path
|
|
.lowercased()
|
|
}
|
|
|
|
private func isAbsolutePath(_ path: String) -> Bool {
|
|
path.hasPrefix("/")
|
|
}
|
|
|
|
// MARK: - Spawn from descriptor
|
|
|
|
private func spawnFromDescriptor(_ descriptor: BarLaunchDescriptor) -> Bool {
|
|
guard !descriptor.runtime.isEmpty, !descriptor.args.isEmpty else { return false }
|
|
|
|
let logURL = serveLogURL()
|
|
ensureLogDirectory()
|
|
|
|
let proc = Process()
|
|
proc.executableURL = URL(fileURLWithPath: descriptor.runtime)
|
|
// Drop the first arg (the entry point itself) when the runtime IS the
|
|
// entry point; args[0] is the entry script, args[1..] are the remaining.
|
|
// Per the plan: args = [absoluteEntryScript, "bar", "serve"].
|
|
proc.arguments = Array(descriptor.args.dropFirst())
|
|
|
|
// Prepend the entry script as the first positional arg to the runtime.
|
|
// e.g. node /path/to/ccs.js bar serve
|
|
if let first = descriptor.args.first {
|
|
proc.arguments = [first] + (proc.arguments ?? [])
|
|
}
|
|
|
|
proc.currentDirectoryURL = URL(fileURLWithPath: descriptor.home)
|
|
|
|
var env = ProcessInfo.processInfo.environment
|
|
if let ccsHome = descriptor.ccsHome, !ccsHome.isEmpty {
|
|
env["CCS_HOME"] = ccsHome
|
|
}
|
|
proc.environment = env
|
|
|
|
attachLog(proc, logURL: logURL)
|
|
return launchDetached(proc)
|
|
}
|
|
|
|
// MARK: - Fallback: resolve `ccs` then spawn
|
|
|
|
private func spawnFallback() -> Bool {
|
|
guard let ccsPath = resolveCCSPath() else { return false }
|
|
|
|
let logURL = serveLogURL()
|
|
ensureLogDirectory()
|
|
|
|
let proc = Process()
|
|
proc.executableURL = URL(fileURLWithPath: ccsPath)
|
|
proc.arguments = ["bar", "serve"]
|
|
proc.currentDirectoryURL = URL(fileURLWithPath: home)
|
|
proc.environment = ProcessInfo.processInfo.environment
|
|
attachLog(proc, logURL: logURL)
|
|
return launchDetached(proc)
|
|
}
|
|
|
|
/// Resolve the `ccs` binary path by running `/bin/zsh -lic "command -v ccs"`.
|
|
/// Synchronous; called only when launch.json is absent.
|
|
private func resolveCCSPath() -> String? {
|
|
let proc = Process()
|
|
proc.executableURL = URL(fileURLWithPath: "/bin/zsh")
|
|
proc.arguments = ["-lic", "command -v ccs"]
|
|
|
|
let pipe = Pipe()
|
|
proc.standardOutput = pipe
|
|
proc.standardError = Pipe() // discard stderr
|
|
|
|
guard (try? proc.run()) != nil else { return nil }
|
|
proc.waitUntilExit()
|
|
|
|
guard proc.terminationStatus == 0 else { return nil }
|
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
|
let raw = String(data: data, encoding: .utf8)?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
return raw.isEmpty ? nil : raw
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
/// URL for the serve log file: `~/.ccs/bar/serve.log`.
|
|
private func serveLogURL() -> URL {
|
|
URL(fileURLWithPath: home)
|
|
.appendingPathComponent(".ccs")
|
|
.appendingPathComponent("bar")
|
|
.appendingPathComponent("serve.log")
|
|
}
|
|
|
|
/// Ensure `~/.ccs/bar/` directory exists before writing the log.
|
|
private func ensureLogDirectory() {
|
|
let dir = URL(fileURLWithPath: home)
|
|
.appendingPathComponent(".ccs")
|
|
.appendingPathComponent("bar")
|
|
.path
|
|
try? FileManager.default.createDirectory(
|
|
atPath: dir, withIntermediateDirectories: true, attributes: nil)
|
|
}
|
|
|
|
/// Wire up stdout + stderr to the log file (appending). Creates the file if absent.
|
|
private func attachLog(_ proc: Process, logURL: URL) {
|
|
// Create or open the file for appending.
|
|
if !FileManager.default.fileExists(atPath: logURL.path) {
|
|
FileManager.default.createFile(atPath: logURL.path, contents: nil)
|
|
}
|
|
if let handle = try? FileHandle(forWritingTo: logURL) {
|
|
handle.seekToEndOfFile()
|
|
proc.standardOutput = handle
|
|
proc.standardError = handle
|
|
} else {
|
|
// Fallback: discard output rather than block the launch.
|
|
proc.standardOutput = FileHandle.nullDevice
|
|
proc.standardError = FileHandle.nullDevice
|
|
}
|
|
}
|
|
|
|
/// Launch `proc` detached (does not wait for it). Returns success/failure.
|
|
private func launchDetached(_ proc: Process) -> Bool {
|
|
do {
|
|
try proc.run()
|
|
// Do NOT call waitUntilExit() — the process must outlive this app launch.
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
}
|