Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,28 @@ extension AcpmuxStatusClient {
/// `hub/file_uploads.rs`); one without it is never sent the bytes.
static let filePutFeature = "filePut"
static let filePutMethod = "_acpmux/file_put"
static let filePutChunksFeature = "filePutChunks"
static let filePutBeginMethod = "_acpmux/file_put_begin"
static let filePutChunkMethod = "_acpmux/file_put_chunk"
static let filePutFinishMethod = "_acpmux/file_put_finish"
static let legacyFilePutBytes = 10 * 1024 * 1024
static let filePutChunkBytes = 512 * 1024

/// `_acpmux/file_put {sessionId, fileName, data}` on this Mac's acpmux, which saves the file on the
/// machine that runs session `sessionId` (a peer's chat goes to that peer) and answers its path
/// there. Throws ``AgentPaneFilePutFailure``.
@concurrent static func putFile(socketPath: String, sessionId: String, name: String, data: Data,
deadline: Duration = .seconds(120)) async throws -> String {
try await putFile(sessionId: sessionId, name: name, data: data) { params in
try await call(socketPath: socketPath, method: filePutMethod, params: params, deadline: deadline,
detailed: true, requiredFeature: filePutFeature)
guard data.count <= AgentPaneUpload.maximumPeerFileBytes else { throw AgentPaneFilePutFailure.tooLarge }
if data.count <= legacyFilePutBytes {
return try await putFile(sessionId: sessionId, name: name, data: data) { params in
try await call(socketPath: socketPath, method: filePutMethod, params: params, deadline: deadline,
detailed: true, requiredFeature: filePutFeature)
}
}
return try await putFileChunked(sessionId: sessionId, name: name, data: data) { method, params in
try await call(socketPath: socketPath, method: method, params: params, deadline: deadline,
detailed: true, requiredFeature: filePutChunksFeature)
}
}

Expand All @@ -57,4 +70,32 @@ extension AcpmuxStatusClient {
guard let path = result["path"] as? String, !path.isEmpty else { throw AgentPaneFilePutFailure.failed }
return path
}

/// Streams a larger upload over begin/chunk/finish requests. Each request is a fresh bounded
/// exchange, so a peer WebSocket never receives the entire base64 file in one frame.
static func putFileChunked(sessionId: String, name: String, data: Data,
send: @Sendable (_ method: String, _ params: [String: any Sendable]) async throws -> [String: Any]) async throws -> String {
guard data.count <= AgentPaneUpload.maximumPeerFileBytes else { throw AgentPaneFilePutFailure.tooLarge }
let started: [String: Any]
do {
started = try await send(filePutBeginMethod, ["sessionId": sessionId, "fileName": name, "size": data.count])
guard let upload = started["uploadId"] as? String, !upload.isEmpty else { throw AgentPaneFilePutFailure.failed }
var offset = 0
while offset < data.count {
let end = min(offset + filePutChunkBytes, data.count)
let piece = data[offset..<end]
let answer = try await send(filePutChunkMethod, ["sessionId": sessionId, "uploadId": upload,
"offset": offset, "data": Data(piece).base64EncodedString()])
guard let next = answer["offset"] as? NSNumber, next.intValue == end else { throw AgentPaneFilePutFailure.failed }
offset = end
}
let finished = try await send(filePutFinishMethod, ["sessionId": sessionId, "uploadId": upload,
"fileName": name, "size": data.count])
guard let path = finished["path"] as? String, !path.isEmpty else { throw AgentPaneFilePutFailure.failed }
return path
} catch {
if let failure = error as? AgentPaneFilePutFailure { throw failure }
throw AgentPaneFilePutFailure(error)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,26 +23,26 @@ public nonisolated struct AgentPaneUpload: Sendable, Equatable, CustomStringConv
public var debugDescription: String { description }
public var customMirror: Mirror { Mirror(self, children: [:]) }

public static let maximumFiles = 10
public static let maximumFileBytes = 32 * 1024 * 1024
public static let maximumTotalBytes = 64 * 1024 * 1024
/// The most bytes one file for a chat on an ssh peer has (acpmux `file_uploads::MAX_BYTES`): it
/// crosses the peer link as one WebSocket frame, and an older peer reads at most 16 MiB.
public static let maximumPeerFileBytes = 10 * 1024 * 1024
/// Local and peer transports stream the bytes in bounded chunks. One GiB is also the native
/// daemon's abuse guard; Cloud uploads keep their smaller policy in ``CloudUploads``.
public static let maximumFileBytes = 1 * 1024 * 1024 * 1024
public static let maximumTotalBytes = 1 * 1024 * 1024 * 1024
public static let maximumPeerFileBytes = maximumFileBytes

/// The files of `chat.upload`'s params, nil when they break the contract: 1 to
/// ``maximumFiles`` files, each with a name a folder holds as one file and base64 bytes, at most
/// The files of `chat.upload`'s params, nil when they break the contract: one or more files,
/// each with a name a folder holds as one file and base64 bytes, at most
/// ``maximumFileBytes`` each and ``maximumTotalBytes`` in all.
static func files(_ params: [String: Any]?) -> [AgentPaneUpload]? {
guard let params, Set(params.keys) == ["files"], let list = params["files"] as? [[String: Any]],
(1...maximumFiles).contains(list.count) else { return nil }
!list.isEmpty else { return nil }
var files: [AgentPaneUpload] = []
var total = 0
for item in list {
guard Set(item.keys) == ["name", "data"], let name = item["name"] as? String, isFileName(name),
let text = item["data"] as? String, text.utf8.count <= (maximumFileBytes / 3 + 1) * 4,
let data = Data(base64Encoded: text), data.count <= maximumFileBytes else { return nil }
total += data.count
let (sum, overflow) = total.addingReportingOverflow(data.count)
total = overflow ? Int.max : sum
guard total <= maximumTotalBytes else { return nil }
files.append(AgentPaneUpload(name: name, data: data))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import Foundation
/// from anywhere else on this Mac, and the machine can't start an upload.
@MainActor
final class CloudUploads {
/// Cloud paste uploads retain an independent abuse guard even though local and peer uploads
/// stream up to the native transport's larger limit.
nonisolated static let maximumFileBytes = 32 * 1024 * 1024
nonisolated static let maximumTotalBytes = 64 * 1024 * 1024
/// `cmux-pastes` in the temporary folder the upload CLI (a child of this app, with its
/// environment) resolves: `TMPDIR`. Nil without one (the CLI would look elsewhere): no upload.
nonisolated static var pastes: URL? {
Expand Down Expand Up @@ -48,6 +52,11 @@ final class CloudUploads {
}

private static func upload(_ files: [AgentPaneUpload], to target: Target, services: AppServices) async -> [String: Any] {
let total = files.reduce(into: 0) { $0 += $1.data.count }
guard !files.isEmpty, total <= maximumTotalBytes,
files.allSatisfy({ $0.data.count <= maximumFileBytes }) else {
return AgentPaneReply.failure(code: "upload_refused", message: CloudUploadStrings.refused)
}
guard let socket = await target.machine.daemon.connection?.endpoint?.socketPath, let pastes else {
return AgentPaneReply.failure(code: "link_error", message: CloudUploadStrings.failed)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,23 @@ struct AgentPaneFilePutTests {
#expect(AgentPaneFilePutFailure(AcpmuxStatusClient.Failure.unreachable("no daemon")) == .failed)
}

@Test func aFilePastThePeerLimitIsNeverSent() async {
await #expect(throws: AgentPaneFilePutFailure.tooLarge) {
try await AcpmuxStatusClient.putFile(sessionId: "s", name: "big.bin",
data: Data(count: AgentPaneUpload.maximumPeerFileBytes + 1)) { _ in
Issue.record("an oversize file was sent")
return [:]
@Test func resumableUploadUsesBoundedChunksAndCommitsThePath() async throws {
let data = Data(repeating: 7, count: AcpmuxStatusClient.filePutChunkBytes * 2 + 3)
let calls = CallLog()
let path = try await AcpmuxStatusClient.putFileChunked(sessionId: "s", name: "big.bin", data: data) { method, params in
await calls.add(method)
switch method {
case AcpmuxStatusClient.filePutBeginMethod: return ["uploadId": "0190a000-0000-7000-8000-000000000000"]
case AcpmuxStatusClient.filePutChunkMethod:
let offset = params["offset"] as? Int ?? 0
let bytes = Data(base64Encoded: params["data"] as? String ?? "")?.count ?? 0
return ["offset": offset + bytes]
default: return ["path": "/uploads/s/big.bin"]
}
}
#expect(path == "/uploads/s/big.bin")
let methods = await calls.methods()
#expect(methods == ["_acpmux/file_put_begin", "_acpmux/file_put_chunk", "_acpmux/file_put_chunk", "_acpmux/file_put_chunk", "_acpmux/file_put_finish"])
}

@Test func aHostWithoutAnAcpmuxTakesNoFiles() async {
Expand Down Expand Up @@ -95,4 +104,10 @@ struct AgentPaneFilePutTests {
}
func writes() -> [Data] { sent }
}

private actor CallLog {
var entries: [String] = []
func add(_ method: String) { entries.append(method) }
func methods() -> [String] { entries }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import Foundation
import Testing
@testable import CmuxNextAgentPane

struct AgentPaneUploadTests {
@Test func acceptsMoreThanTenFilesWithinByteAndNameLimits() {
let files: [[String: Any]] = (0..<11).map { index in
["name": "part-\(index).zip", "data": Data([UInt8(index)]).base64EncodedString()]
}
let parsed = AgentPaneUpload.files(["files": files])
#expect(parsed?.count == 11)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -62,16 +62,8 @@ import Testing
#expect(Self.code(failed) == "upload_failed")
}

@Test func aFilePastThePeerLimitIsRefusedBeforeAnyIsSent() async {
let files = [AgentPaneUpload(name: "a.txt", data: Data("x".utf8)),
AgentPaneUpload(name: "big.bin", data: Data(count: AgentPaneUpload.maximumPeerFileBytes + 1))]
var sent = 0
let reply = await AgentPeerUploads.upload(files, machine: "huge-blue") { _, _ in
sent += 1
return "/x"
}
#expect(sent == 0)
#expect(Self.code(reply) == "too_large")
@Test func peerTransportAllowsFilesLargerThanTheFormerFrameCap() {
#expect(AgentPaneUpload.maximumPeerFileBytes > 64 * 1024 * 1024)
}

private static func code(_ reply: [String: Any]) -> String? {
Expand Down
Loading
Loading