clients/swift: apply audit findings — multipart fix + token redaction (e4e8192 → HEAD)
P1 (release blocker):
- multipart now RFC 7578 compliant (was injecting bare LF before file
content via Swift """...""" multi-line literals; corrupted binary
uploads — PNG/PDF/JPEG). Body now built via explicit "\r\n"
concatenation so every byte on the wire is auditable.
P2:
- CustomStringConvertible redacts token on ForgeClient + AppToken
(default mirror was leaking plaintext via print / String(reflecting:)
/ SwiftUI string interpolation).
- revokeToken now pre-validates name against ^[a-z0-9_-]{1,64}$ and
rejects path-traversal sequences with ForgeError.invalidArgument
before percent-encoding (urlPathAllowed left /, +, ;, =, ,, @
unescaped).
- baseURL with non-empty path/query/fragment rejected at construct.
init is now `throws` — host-only URLs only, since the SDK builds
request URLs by string concatenation.
P3:
- Fixed misleading "custom encoding" comment on RunRequest (it's just
Optional + JSONEncoder default behavior).
- public init on RunFailure (was decode-only).
- Task.checkCancellation() inside the multipart chunk loop — multi-GB
uploads now abort promptly when the parent Task is cancelled.
- 0o600 perms on the staged temp upload file (was inheriting umask,
typically 0o644 — unwanted in multi-tenant /tmp).
- Documented JSONValue.number Double precision limit (loses precision
for ints > 2^53).
Tests:
- testMultipartIsCRLFCompliant: writes a PNG-signature payload, scans
the captured body for the `\r\n\n` bare-LF pattern AND verifies the
bytes after `Content-Type: image/png\r\n\r\n` match the payload
exactly.
- testForgeClientDescriptionRedactsToken
- testAppTokenDescriptionRedactsToken (covers both nil and non-nil
token cases)
- testRevokeTokenRejectsTraversalName: foo/../bar, FOO, spaces, +, ;,
=, @, 65-char names, empty
- testBaseURLWithPathRejected: /api, /v1, ?query, #fragment; host-only
variants still accepted
- testRunFailurePublicInit
- testTempFilePerms: scans /tmp during the in-flight upload to verify
the staged clawdforge-upload-* file is 0o600
- Existing tests updated for the now-throwing init.
README + Examples updated for the throwing init.
Audit: memory/clawdforge-audits/swift-e4e8192.md
Note: untested locally — Swift toolchain not present in this sandbox.
Needs `swift build -c release` + `swift test` verification on a Swift
5.9+ host (macOS or Linux) before tagging the next release.
This commit is contained in:
parent
104f49c441
commit
7e878e6f45
5 changed files with 372 additions and 36 deletions
|
|
@ -15,9 +15,9 @@ guard let token = ProcessInfo.processInfo.environment["CLAWDFORGE_TOKEN"] else {
|
|||
exit(1)
|
||||
}
|
||||
|
||||
let client = ForgeClient(baseURL: baseURL, token: token)
|
||||
|
||||
do {
|
||||
let client = try ForgeClient(baseURL: baseURL, token: token)
|
||||
|
||||
// 1. Health check
|
||||
let h = try await client.healthz()
|
||||
print("claude_present: \(h.claudePresent)")
|
||||
|
|
|
|||
|
|
@ -46,7 +46,9 @@ select the `Clawdforge` library product.
|
|||
```swift
|
||||
import Clawdforge
|
||||
|
||||
let client = ForgeClient(
|
||||
// `baseURL` must be host-only — scheme + host (+ port). A path/query/fragment
|
||||
// is rejected at construct with `ForgeError.invalidArgument`.
|
||||
let client = try ForgeClient(
|
||||
baseURL: URL(string: "http://192.168.0.5:8800")!,
|
||||
token: ProcessInfo.processInfo.environment["CLAWDFORGE_TOKEN"]!
|
||||
)
|
||||
|
|
@ -79,7 +81,7 @@ is thread-safe.
|
|||
|
||||
```swift
|
||||
public struct ForgeClient: Sendable {
|
||||
public init(baseURL: URL, token: String, session: URLSession = .shared)
|
||||
public init(baseURL: URL, token: String, session: URLSession = .shared) throws
|
||||
|
||||
public func healthz() async throws -> HealthStatus
|
||||
public func run(_ request: RunRequest) async throws -> RunResult
|
||||
|
|
@ -111,7 +113,7 @@ load-into-`Data()` for the file payload, so multi-megabyte files are fine.
|
|||
Pass the `ADMIN_BOOTSTRAP_TOKEN` as the client's `token` to use these.
|
||||
|
||||
```swift
|
||||
let admin = ForgeClient(baseURL: url, token: adminToken)
|
||||
let admin = try ForgeClient(baseURL: url, token: adminToken)
|
||||
|
||||
let new = try await admin.createToken(
|
||||
CreateTokenRequest(name: "myapp", ipCidrs: ["10.0.0.0/8"])
|
||||
|
|
|
|||
|
|
@ -54,21 +54,38 @@ public struct ForgeClient: Sendable {
|
|||
///
|
||||
/// - Parameters:
|
||||
/// - baseURL: Root URL of the clawdforge instance. e.g.
|
||||
/// `URL(string: "http://192.168.0.5:8800")!`.
|
||||
/// `URL(string: "http://192.168.0.5:8800")!`. **Must be host-only**
|
||||
/// (scheme + host [+ optional port]). A non-empty path, query, or
|
||||
/// fragment is rejected with ``ForgeError/invalidArgument(_:)`` —
|
||||
/// the SDK builds request URLs as `baseURL.absoluteString + "/path"`
|
||||
/// so e.g. `http://x/api` would silently produce malformed URLs.
|
||||
/// - token: Bearer token (`cf_…` for app endpoints, the admin
|
||||
/// bootstrap token for `/admin/*`).
|
||||
/// - session: Optional `URLSession`. Defaults to `.shared`. Provide
|
||||
/// a custom one for timeouts or for tests using `URLProtocol`
|
||||
/// stubs.
|
||||
/// - Throws: ``ForgeError/invalidArgument(_:)`` if `baseURL` carries a
|
||||
/// path, query, or fragment.
|
||||
public init(
|
||||
baseURL: URL,
|
||||
token: String,
|
||||
session: URLSession = .shared
|
||||
) {
|
||||
) throws {
|
||||
// Trim a trailing slash so path joins are predictable.
|
||||
var s = baseURL.absoluteString
|
||||
while s.hasSuffix("/") { s.removeLast() }
|
||||
self.baseURL = URL(string: s) ?? baseURL
|
||||
let trimmed = URL(string: s) ?? baseURL
|
||||
|
||||
// Reject baseURLs that carry their own path/query/fragment. The SDK
|
||||
// constructs request URLs as `baseURL.absoluteString + "/path"` and
|
||||
// a non-empty trailing path on baseURL would silently break that.
|
||||
if !trimmed.path.isEmpty || trimmed.query != nil || trimmed.fragment != nil {
|
||||
throw ForgeError.invalidArgument(
|
||||
"baseURL must be host-only (scheme + host [+ port]); got \(baseURL.absoluteString)"
|
||||
)
|
||||
}
|
||||
|
||||
self.baseURL = trimmed
|
||||
self.token = token
|
||||
self.session = session
|
||||
|
||||
|
|
@ -184,16 +201,42 @@ public struct ForgeClient: Sendable {
|
|||
|
||||
/// `DELETE /admin/tokens/{name}`. Revoke a token by app name.
|
||||
///
|
||||
/// `name` must match the server-side constraint `^[a-z0-9_-]{1,64}$`
|
||||
/// (lowercase alphanumerics, hyphen, underscore). Anything else —
|
||||
/// including path-traversal sequences like `foo/../bar` — is rejected
|
||||
/// client-side with ``ForgeError/invalidArgument(_:)``. This is stricter
|
||||
/// than `.urlPathAllowed`, which leaves `/`, `+`, `;`, `=`, `,`, `@`
|
||||
/// unescaped.
|
||||
///
|
||||
/// Requires the admin bootstrap token.
|
||||
public func revokeToken(name: String) async throws {
|
||||
guard !name.isEmpty else {
|
||||
throw ForgeError.invalidArgument("revokeToken: name must not be empty")
|
||||
}
|
||||
let escaped = name.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? name
|
||||
let req = try makeRequest(method: "DELETE", path: "/admin/tokens/\(escaped)")
|
||||
guard Self.isValidTokenName(name) else {
|
||||
throw ForgeError.invalidArgument(
|
||||
"revokeToken: name must match [a-z0-9_-]{1,64}; got \"\(name)\""
|
||||
)
|
||||
}
|
||||
let req = try makeRequest(method: "DELETE", path: "/admin/tokens/\(name)")
|
||||
_ = try await sendVoid(req)
|
||||
}
|
||||
|
||||
/// Server-side constraint: `^[a-z0-9_-]{1,64}$`. Cheap manual scan to
|
||||
/// avoid pulling in `NSRegularExpression` for one tiny check.
|
||||
static func isValidTokenName(_ name: String) -> Bool {
|
||||
guard (1...64).contains(name.count) else { return false }
|
||||
for ch in name.unicodeScalars {
|
||||
switch ch {
|
||||
case "a"..."z", "0"..."9", "-", "_":
|
||||
continue
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: Internals
|
||||
|
||||
/// Build a `URLRequest` with auth + accept headers. If `jsonBody` is
|
||||
|
|
@ -292,41 +335,60 @@ public struct ForgeClient: Sendable {
|
|||
|
||||
/// Stream a multipart/form-data body into a temp file. Stays disk-bound:
|
||||
/// reads the source file in 1 MiB chunks rather than slurping it.
|
||||
///
|
||||
/// All line terminators are explicit `\r\n` (CRLF) per RFC 7578. Swift's
|
||||
/// `"""…"""` multi-line literals use bare LF for source newlines, which
|
||||
/// would inject a stray `\n` between the headers `\r\n\r\n` separator
|
||||
/// and the file content — corrupting any binary upload (PNG, PDF, JPEG)
|
||||
/// because the receiver would treat that `\n` as the first byte of the
|
||||
/// part body. We use plain string concatenation here so every byte on
|
||||
/// the wire is auditable.
|
||||
///
|
||||
/// The temp file is created with 0o600 perms — the staged body holds
|
||||
/// the user's plaintext bearer token via the `Authorization` header on
|
||||
/// the live request, but we still don't want world-readable copies of
|
||||
/// arbitrary user uploads sitting in `/tmp`.
|
||||
private func writeMultipartBody(
|
||||
to dest: URL,
|
||||
boundary: String,
|
||||
ttlSecs: Int,
|
||||
fileURL: URL
|
||||
) throws {
|
||||
FileManager.default.createFile(atPath: dest.path, contents: nil)
|
||||
FileManager.default.createFile(
|
||||
atPath: dest.path,
|
||||
contents: nil,
|
||||
attributes: [.posixPermissions: 0o600]
|
||||
)
|
||||
guard let out = FileHandle(forWritingAtPath: dest.path) else {
|
||||
throw ForgeError.invalidArgument("cannot open temp upload at \(dest.path)")
|
||||
}
|
||||
defer { try? out.close() }
|
||||
|
||||
// ttl_secs field
|
||||
let ttlPart = """
|
||||
--\(boundary)\r
|
||||
Content-Disposition: form-data; name="ttl_secs"\r
|
||||
\r
|
||||
\(ttlSecs)\r
|
||||
let CRLF = "\r\n"
|
||||
|
||||
"""
|
||||
// ttl_secs field — explicit CRLFs everywhere.
|
||||
let ttlPart =
|
||||
"--" + boundary + CRLF
|
||||
+ "Content-Disposition: form-data; name=\"ttl_secs\"" + CRLF
|
||||
+ CRLF
|
||||
+ String(ttlSecs) + CRLF
|
||||
try out.write(contentsOf: Data(ttlPart.utf8))
|
||||
|
||||
// file field header
|
||||
// file field header — explicit CRLFs everywhere. The blank line
|
||||
// between headers and body is a single CRLF; together with the CRLF
|
||||
// that ends `Content-Type:` it forms the required `\r\n\r\n`
|
||||
// separator.
|
||||
let filename = fileURL.lastPathComponent
|
||||
let mime = mimeType(for: fileURL)
|
||||
let fileHeader = """
|
||||
--\(boundary)\r
|
||||
Content-Disposition: form-data; name="file"; filename="\(escapeFilename(filename))"\r
|
||||
Content-Type: \(mime)\r
|
||||
\r
|
||||
|
||||
"""
|
||||
let fileHeader =
|
||||
"--" + boundary + CRLF
|
||||
+ "Content-Disposition: form-data; name=\"file\"; filename=\"" + escapeFilename(filename) + "\"" + CRLF
|
||||
+ "Content-Type: " + mime + CRLF
|
||||
+ CRLF
|
||||
try out.write(contentsOf: Data(fileHeader.utf8))
|
||||
|
||||
// file body — chunked
|
||||
// file body — chunked. Cooperative cancellation inside the loop so
|
||||
// a multi-GB upload aborts quickly when the parent Task is cancelled.
|
||||
guard let input = FileHandle(forReadingAtPath: fileURL.path) else {
|
||||
throw ForgeError.invalidArgument("cannot open \(fileURL.path) for reading")
|
||||
}
|
||||
|
|
@ -334,13 +396,15 @@ public struct ForgeClient: Sendable {
|
|||
|
||||
let chunkSize = 1 * 1024 * 1024
|
||||
while true {
|
||||
try Task.checkCancellation()
|
||||
let chunk = input.readData(ofLength: chunkSize)
|
||||
if chunk.isEmpty { break }
|
||||
try out.write(contentsOf: chunk)
|
||||
}
|
||||
|
||||
// closing boundary
|
||||
let trailer = "\r\n--\(boundary)--\r\n"
|
||||
// closing boundary — leading CRLF terminates the file part body,
|
||||
// then the dash-boundary-dash sequence + final CRLF.
|
||||
let trailer = CRLF + "--" + boundary + "--" + CRLF
|
||||
try out.write(contentsOf: Data(trailer.utf8))
|
||||
}
|
||||
|
||||
|
|
@ -373,3 +437,17 @@ public struct ForgeClient: Sendable {
|
|||
.replacingOccurrences(of: "\r", with: " ")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Redacted reflection
|
||||
//
|
||||
// The default `String(reflecting:)` mirror walks every stored property and
|
||||
// would surface the bearer token in plain text via `print(client)`,
|
||||
// `String(describing: client)`, SwiftUI `Text("\(client)")`, and any logging
|
||||
// framework that calls `String(reflecting:)`. Override to redact.
|
||||
|
||||
extension ForgeClient: CustomStringConvertible, CustomDebugStringConvertible {
|
||||
public var description: String {
|
||||
"ForgeClient(baseURL: \(baseURL), token: <redacted>)"
|
||||
}
|
||||
public var debugDescription: String { description }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,9 +38,9 @@ public struct HealthStatus: Codable, Sendable, Equatable {
|
|||
|
||||
/// Body for `POST /run`.
|
||||
///
|
||||
/// Only ``prompt`` is required. Fields left `nil` are omitted from the
|
||||
/// wire payload via custom encoding so the server sees the same shape
|
||||
/// the Python/Go/Rust clients send.
|
||||
/// Only ``prompt`` is required. Optional fields are declared as Swift
|
||||
/// `Optional`s so `JSONEncoder` omits them from the wire payload when `nil`,
|
||||
/// matching the shape the Python/Go/Rust clients send.
|
||||
public struct RunRequest: Codable, Sendable, Equatable {
|
||||
/// The user prompt to feed to `claude -p`.
|
||||
public let prompt: String
|
||||
|
|
@ -114,6 +114,20 @@ public struct RunFailure: Codable, Sendable, Equatable {
|
|||
public let durationMs: Int?
|
||||
public let stopReason: String?
|
||||
|
||||
public init(
|
||||
ok: Bool,
|
||||
error: String,
|
||||
stderr: String? = nil,
|
||||
durationMs: Int? = nil,
|
||||
stopReason: String? = nil
|
||||
) {
|
||||
self.ok = ok
|
||||
self.error = error
|
||||
self.stderr = stderr
|
||||
self.durationMs = durationMs
|
||||
self.stopReason = stopReason
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ok
|
||||
case error
|
||||
|
|
@ -225,6 +239,20 @@ public struct AppToken: Codable, Sendable, Equatable {
|
|||
}
|
||||
}
|
||||
|
||||
// AppToken redacts the plaintext `token` from `print` / `String(reflecting:)`
|
||||
// / SwiftUI string interpolation. When `token` is non-nil (the create-token
|
||||
// response) the default mirror would dump the secret straight to stdout.
|
||||
extension AppToken: CustomStringConvertible, CustomDebugStringConvertible {
|
||||
public var description: String {
|
||||
let redacted = (token == nil) ? "nil" : "<redacted>"
|
||||
return "AppToken(name: \(name), token: \(redacted), "
|
||||
+ "ipCidrs: \(ipCidrs ?? []), createdAt: \(createdAt.map(String.init) ?? "nil"), "
|
||||
+ "lastUsed: \(lastUsed.map(String.init) ?? "nil"), "
|
||||
+ "enabled: \(enabled.map(String.init) ?? "nil"))"
|
||||
}
|
||||
public var debugDescription: String { description }
|
||||
}
|
||||
|
||||
/// Wire envelope for `GET /admin/tokens`.
|
||||
struct TokenList: Codable {
|
||||
let tokens: [AppToken]
|
||||
|
|
@ -249,6 +277,12 @@ public enum JSONValue: Codable, Sendable, Equatable {
|
|||
case object([String: JSONValue])
|
||||
case array([JSONValue])
|
||||
case string(String)
|
||||
/// JSON number, represented as `Double`. Note: Swift's `Double` is IEEE
|
||||
/// 754 binary64 with 53 bits of integer precision, so JSON integers
|
||||
/// larger than `2^53` (≈ 9.007e15) will lose precision on decode. If the
|
||||
/// server-side `result` ever contains 64-bit identifiers (e.g. snowflake
|
||||
/// IDs, nanosecond timestamps) embed them as JSON strings rather than
|
||||
/// numbers — the lossless `.string` case will preserve them verbatim.
|
||||
case number(Double)
|
||||
case bool(Bool)
|
||||
case null
|
||||
|
|
|
|||
|
|
@ -79,11 +79,11 @@ final class URLProtocolMock: URLProtocol, @unchecked Sendable {
|
|||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func makeClient() -> ForgeClient {
|
||||
private func makeClient() throws -> ForgeClient {
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.protocolClasses = [URLProtocolMock.self]
|
||||
let session = URLSession(configuration: config)
|
||||
return ForgeClient(
|
||||
return try ForgeClient(
|
||||
baseURL: URL(string: "http://forge.test")!,
|
||||
token: "cf_test_abcdef",
|
||||
session: session
|
||||
|
|
@ -346,7 +346,7 @@ final class ForgeClientTests: XCTestCase {
|
|||
}
|
||||
|
||||
// 12. Cancellation propagates from Task to URLSession
|
||||
func testCancellationThrows() async {
|
||||
func testCancellationThrows() async throws {
|
||||
URLProtocolMock.handler = { _ in
|
||||
// Block "forever" so the cancel path is the only way out. We
|
||||
// simulate by throwing a cancellation-like URLError if the test
|
||||
|
|
@ -354,7 +354,7 @@ final class ForgeClientTests: XCTestCase {
|
|||
Thread.sleep(forTimeInterval: 0.5)
|
||||
throw URLError(.timedOut)
|
||||
}
|
||||
let client = makeClient()
|
||||
let client = try makeClient()
|
||||
let task = Task {
|
||||
try await client.run(RunRequest(prompt: "x"))
|
||||
}
|
||||
|
|
@ -370,4 +370,226 @@ final class ForgeClientTests: XCTestCase {
|
|||
XCTFail("wrong error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Audit fixes (2026-04-28)
|
||||
|
||||
// 13. P1 — multipart body is RFC 7578 compliant. We write a binary file
|
||||
// whose content begins with the canonical PNG signature
|
||||
// (0x89 0x50 0x4E 0x47 0x0D 0x0A 0x1A 0x0A). The bytes immediately
|
||||
// following the headers/body separator (`\r\n\r\n`) MUST equal the PNG
|
||||
// signature exactly — no leading `\n`, which the previous bare-LF
|
||||
// `"""..."""` literals injected.
|
||||
func testMultipartIsCRLFCompliant() async throws {
|
||||
let pngSig: [UInt8] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]
|
||||
// Append a few more bytes so we can also verify nothing was eaten
|
||||
// off the front of the body.
|
||||
let payload = Data(pngSig + [0x00, 0x01, 0x02, 0x03])
|
||||
|
||||
let tmp = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clawdforge-test-\(UUID().uuidString).png")
|
||||
try payload.write(to: tmp)
|
||||
defer { try? FileManager.default.removeItem(at: tmp) }
|
||||
|
||||
URLProtocolMock.handler = { req in
|
||||
let resp = #"""
|
||||
{"file_token": "ff_png", "ttl_secs": 3600, "size": 12}
|
||||
"""#
|
||||
return (makeResponse(url: req.url!, status: 200), Data(resp.utf8))
|
||||
}
|
||||
|
||||
_ = try await makeClient().uploadFile(at: tmp, ttlSecs: 3600)
|
||||
|
||||
let body = URLProtocolMock.lastBody ?? Data()
|
||||
XCTAssertGreaterThan(body.count, payload.count, "body should be larger than the payload")
|
||||
|
||||
// The original `"""…"""` multi-line literal bug produced sequences
|
||||
// like `\r\n\n` (a CRLF followed by a stray bare LF where the next
|
||||
// CRLF was expected). We can detect that pattern directly: scan
|
||||
// every CRLF and assert the byte AFTER the LF is not itself a bare
|
||||
// LF. The PNG signature contains `\r\n` internally so we have to
|
||||
// skip the part-body region — scan only up to the end of the
|
||||
// headers (i.e. up to the first occurrence of the `\r\n\r\n`
|
||||
// separator that precedes the file payload), and from after the
|
||||
// payload to the end (the trailer).
|
||||
let needle = Data("Content-Type: image/png\r\n\r\n".utf8)
|
||||
guard let range = body.range(of: needle) else {
|
||||
return XCTFail("could not find Content-Type: image/png header in multipart body")
|
||||
}
|
||||
let bodyStart = range.upperBound
|
||||
let bodyEnd = bodyStart + payload.count
|
||||
|
||||
let bytes = [UInt8](body)
|
||||
let scanRanges: [Range<Int>] = [
|
||||
0..<bodyStart,
|
||||
bodyEnd..<bytes.count,
|
||||
]
|
||||
for r in scanRanges {
|
||||
// Slide a 3-byte window: `\r \n \n` in any of these ranges is
|
||||
// the smoking gun for the original bug.
|
||||
guard r.count >= 3 else { continue }
|
||||
for i in r.lowerBound..<(r.upperBound - 2) {
|
||||
if bytes[i] == 0x0D && bytes[i + 1] == 0x0A && bytes[i + 2] == 0x0A {
|
||||
XCTFail("found `\\r\\n\\n` (bare-LF bug) at byte \(i)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The bytes immediately after the `Content-Type: image/png\r\n\r\n`
|
||||
// separator must equal the payload exactly — no leading bare `\n`.
|
||||
XCTAssertLessThanOrEqual(bodyEnd, body.count, "body truncated before payload")
|
||||
let extracted = body.subdata(in: bodyStart..<bodyEnd)
|
||||
XCTAssertEqual([UInt8](extracted), [UInt8](payload),
|
||||
"bytes after \\r\\n\\r\\n separator must match payload exactly (no bare LF injection)")
|
||||
}
|
||||
|
||||
// 14. P2 — print(client) and String(reflecting: client) MUST NOT leak
|
||||
// the bearer token.
|
||||
func testForgeClientDescriptionRedactsToken() throws {
|
||||
let client = try makeClient()
|
||||
let s1 = String(describing: client)
|
||||
let s2 = String(reflecting: client)
|
||||
XCTAssertFalse(s1.contains("cf_test_abcdef"))
|
||||
XCTAssertFalse(s2.contains("cf_test_abcdef"))
|
||||
XCTAssertTrue(s1.contains("<redacted>"))
|
||||
XCTAssertTrue(s2.contains("<redacted>"))
|
||||
}
|
||||
|
||||
// 15. P2 — AppToken with non-nil token MUST NOT leak via print or
|
||||
// reflection.
|
||||
func testAppTokenDescriptionRedactsToken() {
|
||||
let tk = AppToken(
|
||||
name: "cauldron",
|
||||
token: "cf_SECRET_xyz",
|
||||
ipCidrs: ["10.0.0.0/8"],
|
||||
createdAt: 1_700_000_000,
|
||||
lastUsed: nil,
|
||||
enabled: 1
|
||||
)
|
||||
let s1 = String(describing: tk)
|
||||
let s2 = String(reflecting: tk)
|
||||
XCTAssertFalse(s1.contains("cf_SECRET_xyz"))
|
||||
XCTAssertFalse(s2.contains("cf_SECRET_xyz"))
|
||||
XCTAssertTrue(s1.contains("<redacted>"))
|
||||
|
||||
// When token is nil, redaction prints `nil`, not `<redacted>`.
|
||||
let tk2 = AppToken(name: "petalparse", token: nil)
|
||||
let s3 = String(describing: tk2)
|
||||
XCTAssertFalse(s3.contains("<redacted>"))
|
||||
XCTAssertTrue(s3.contains("token: nil"))
|
||||
}
|
||||
|
||||
// 16. P2 — revokeToken rejects path-traversal and other illegal names.
|
||||
func testRevokeTokenRejectsTraversalName() async throws {
|
||||
let client = try makeClient()
|
||||
let invalid = ["foo/../bar", "../etc/passwd", "FOO", "name with space",
|
||||
"a;b", "a@b", "a+b", "a=b",
|
||||
String(repeating: "a", count: 65), ""]
|
||||
for bad in invalid {
|
||||
do {
|
||||
try await client.revokeToken(name: bad)
|
||||
XCTFail("expected ForgeError.invalidArgument for \"\(bad)\"")
|
||||
} catch ForgeError.invalidArgument {
|
||||
// expected
|
||||
} catch {
|
||||
XCTFail("wrong error for \"\(bad)\": \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// The valid character set MUST still be accepted.
|
||||
URLProtocolMock.handler = { req in
|
||||
(makeResponse(url: req.url!, status: 200), Data(#"{"ok": true}"#.utf8))
|
||||
}
|
||||
try await client.revokeToken(name: "valid_name-1")
|
||||
}
|
||||
|
||||
// 17. P2 — baseURL with non-empty path/query is rejected at construct.
|
||||
func testBaseURLWithPathRejected() {
|
||||
let cases = [
|
||||
URL(string: "http://x/api")!,
|
||||
URL(string: "http://x/")!.appendingPathComponent("v1"),
|
||||
URL(string: "http://x?foo=bar")!,
|
||||
URL(string: "http://x#frag")!,
|
||||
]
|
||||
for u in cases {
|
||||
do {
|
||||
_ = try ForgeClient(baseURL: u, token: "t")
|
||||
XCTFail("expected ForgeError.invalidArgument for \(u)")
|
||||
} catch ForgeError.invalidArgument {
|
||||
// expected
|
||||
} catch {
|
||||
XCTFail("wrong error for \(u): \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// Host-only URLs MUST still work.
|
||||
XCTAssertNoThrow(try ForgeClient(baseURL: URL(string: "http://x")!, token: "t"))
|
||||
XCTAssertNoThrow(try ForgeClient(baseURL: URL(string: "http://x:8800")!, token: "t"))
|
||||
// Trailing slash should be tolerated by the trim logic.
|
||||
XCTAssertNoThrow(try ForgeClient(baseURL: URL(string: "http://x:8800/")!, token: "t"))
|
||||
}
|
||||
|
||||
// 18. P3 — RunFailure can be constructed from outside the module.
|
||||
func testRunFailurePublicInit() {
|
||||
let f = RunFailure(
|
||||
ok: false,
|
||||
error: "claude exited 1",
|
||||
stderr: "boom",
|
||||
durationMs: 9,
|
||||
stopReason: "error"
|
||||
)
|
||||
XCTAssertFalse(f.ok)
|
||||
XCTAssertEqual(f.error, "claude exited 1")
|
||||
XCTAssertEqual(f.stderr, "boom")
|
||||
XCTAssertEqual(f.durationMs, 9)
|
||||
XCTAssertEqual(f.stopReason, "error")
|
||||
}
|
||||
|
||||
// 19. P3 — staged temp upload file is mode 0o600.
|
||||
//
|
||||
// We use a custom URLSession that lets uploadFile() run all the way
|
||||
// through writeMultipartBody (which creates the temp file), but stub
|
||||
// the actual transport so we can intercept and inspect the temp file
|
||||
// before it's torn down by the `defer` in uploadFile.
|
||||
//
|
||||
// Trick: the URLProtocol handler can read the temp path off the
|
||||
// request's body stream and inspect its perms. But Foundation copies
|
||||
// the file before handing it to URLProtocol, so we instead do the
|
||||
// perms check via a custom mock that runs inside the upload.
|
||||
func testTempFilePerms() async throws {
|
||||
let tmp = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clawdforge-test-perms-\(UUID().uuidString).bin")
|
||||
try Data("perms test".utf8).write(to: tmp)
|
||||
defer { try? FileManager.default.removeItem(at: tmp) }
|
||||
|
||||
// The body bytes, captured by URLProtocolMock, are sufficient proof
|
||||
// the staged file existed; for the perms assertion we list the temp
|
||||
// dir before/after and check perms on the staged file.
|
||||
let beforeNames = Set((try? FileManager.default.contentsOfDirectory(
|
||||
atPath: FileManager.default.temporaryDirectory.path
|
||||
)) ?? [])
|
||||
|
||||
URLProtocolMock.handler = { req in
|
||||
// While the upload is in flight the staged temp file still
|
||||
// exists — capture perms NOW.
|
||||
let now = Set((try? FileManager.default.contentsOfDirectory(
|
||||
atPath: FileManager.default.temporaryDirectory.path
|
||||
)) ?? [])
|
||||
let new = now.subtracting(beforeNames)
|
||||
for name in new where name.hasPrefix("clawdforge-upload-") {
|
||||
let path = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(name).path
|
||||
let attrs = try FileManager.default.attributesOfItem(atPath: path)
|
||||
if let perms = attrs[.posixPermissions] as? NSNumber {
|
||||
XCTAssertEqual(perms.int16Value & 0o777, 0o600,
|
||||
"staged temp upload file must be mode 0o600")
|
||||
} else {
|
||||
XCTFail("could not read posixPermissions on \(path)")
|
||||
}
|
||||
}
|
||||
let resp = #"{"file_token": "ff_x", "ttl_secs": 3600, "size": 10}"#
|
||||
return (makeResponse(url: req.url!, status: 200), Data(resp.utf8))
|
||||
}
|
||||
|
||||
_ = try await makeClient().uploadFile(at: tmp, ttlSecs: 3600)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue