Skip to content
Merged
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 @@ -22,7 +22,30 @@ let result = try await CURLTask("""

`CURLTask` parses the command straight into a ``RequestDL/RequestConfiguration`` — there's no `@PropertyBuilder` tree to declare from a string only known at runtime — but resolution and execution still go through the same pipeline ``RequestDL/DataTask`` uses, so caching, tracing, and executor requirements all apply exactly the same way.

> Important: Run only curl commands you wrote or trust. A command is a program for the request engine, not just data: `-F name=@path` uploads the local file it names, `--cert`, `--key` and `--cacert` read files from disk, `-k` / `--insecure` turns certificate verification off, and `-x` / `--proxy` and `--resolve` change where the request goes. A command assembled from user input, a pasted snippet or a network response can leak local files to a host of its choosing or remove TLS protection. Reject or inspect such a command first, or declare the request with ``RequestDL/DataTask`` instead.
> Important: Run only curl commands you wrote or trust. A command is a program for the request engine, not just data: `-F name=@path` uploads the local file it names, `--cert`, `--key` and `--cacert` read files from disk, `-k` / `--insecure` turns certificate verification off, and `-x` / `--proxy` and `--resolve` change where the request goes. A command assembled from user input, a pasted snippet or a network response can leak local files to a host of its choosing or remove TLS protection. Say what the command may do, as below, reject or inspect it first, or declare the request with ``RequestDL/DataTask`` instead.

### A command you do not control

``RequestDL/CURLTask/init(_:allowing:)`` takes the capabilities a command may use. A flag that needs one it was not given throws ``RequestDL/CURLParsingError`` with `unsupportedFlag` while the command is parsed, before any file is opened and before anything is sent:

```swift
// Nothing it could read, redirect or weaken.
try await CURLTask(pasted, allowing: [])
.result()

// The same, but it may name a proxy.
try await CURLTask(pasted, allowing: .proxy)
.result()
```

| Capability | Flags it covers |
|---|---|
| `.localFiles` | `-F name=@path` (relative paths too, which resolve against the working directory), `--cacert`, `--cert` / `-E`, `--key` |
| `.disabledTLSVerification` | `-k` / `--insecure` |
| `.proxy` | `-x` / `--proxy` |
| `.dnsOverrides` | `--resolve` |

Everything else in the subset (the method, headers, a body, a form of plain fields, `-u`, redirects, `--compressed`, and the flags that only shape curl's own output) needs none. `CURLTask(_:)` allows all of them, as it always did.

### From a request to curl

Expand Down
94 changes: 84 additions & 10 deletions Sources/RequestDL/Tasks/Sources/Raw Task/cURL/CURLTask.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,34 +43,108 @@ import struct Foundation.Data
/// .Configuration.dnsOverride`) is incompatible with the `.urlSession` executor, so a command
/// using it will only run under `.nio`/`.nioTransportServices`.
///
/// - Important: Pass only commands you wrote or trust. A command is a program for the request
/// engine, not just data. `-F name=@path` reads that local file and uploads it. `--cert`/`-E`/
/// `--key` and `--cacert` read the files they name. `-k`/`--insecure` turns certificate
/// verification off, and `-x`/`--proxy` and `--resolve` redirect where the request goes. A
/// command built from user input, a pasted snippet or a network response can therefore leak
/// local files to a host of its choosing or strip TLS protection. Reject or inspect such a
/// command before handing it to `CURLTask`, or build the request with ``DataTask`` instead.
/// - Important: Pass only commands you wrote or trust, or say what the command may do. A
/// command is a program for the request engine, not just data. `-F name=@path` reads that local
/// file and uploads it. `--cert`/`-E`/`--key` and `--cacert` read the files they name.
/// `-k`/`--insecure` turns certificate verification off, and `-x`/`--proxy` and `--resolve`
/// redirect where the request goes. A command built from user input, a pasted snippet or a
/// network response can therefore leak local files to a host of its choosing or strip TLS
/// protection. ``init(_:allowing:)`` refuses those flags unless the capability that covers them
/// is given, so a command from a source you do not control can run with none of them:
///
/// ```swift
/// try await CURLTask(pasted, allowing: [])
/// .result()
/// ```
///
/// `CURLTask(_:)` allows all of them, as it always did.
///
/// The command is parsed straight into a ``RequestConfiguration`` rather than through
/// `@PropertyBuilder`, since there is nothing to declare a `Property` tree from until the
/// string is parsed at runtime. Resolution and execution still go through the same pipeline
/// ``DataTask`` uses, so caching, tracing, and executor requirements all apply the same way.
public struct CURLTask: RequestTask {

// MARK: - Public types

/// What a curl command may do beyond describing a request: the flags that touch the machine
/// or change where, and how safely, the request goes. ``CURLTask/init(_:)`` allows all of
/// them; ``CURLTask/init(_:allowing:)`` refuses the flag of any that is not given, with
/// ``CURLParsingError/Context/unsupportedFlag``, before a file is opened or a request is
/// built.
public struct Capabilities: OptionSet, Sendable, Hashable {

/// Reading a file from this machine: `-F name=@path` (any path, relative ones included,
/// which resolve against the working directory), `--cacert`, `--cert`/`-E` and `--key`.
public static let localFiles = Capabilities(rawValue: 1 << 0)

/// Turning certificate verification off: `-k`/`--insecure`.
public static let disabledTLSVerification = Capabilities(rawValue: 1 << 1)

/// Sending the request through a proxy: `-x`/`--proxy`.
public static let proxy = Capabilities(rawValue: 1 << 2)

/// Sending the request to an address other than the one DNS gives for the host:
/// `--resolve`.
public static let dnsOverrides = Capabilities(rawValue: 1 << 3)

/// Every capability, which is what ``CURLTask/init(_:)`` allows.
public static let all: Capabilities = [
.localFiles, .disabledTLSVerification, .proxy, .dnsOverrides,
]

public let rawValue: Int

public init(rawValue: Int) {
self.rawValue = rawValue
}
}

// MARK: - Private properties

private let command: String
private let capabilities: Capabilities

// MARK: - Inits

///
/// Initializes with a curl command line.
///
/// The command may use every flag the subset covers, local files, a proxy and
/// `-k`/`--insecure` included. Pass only commands you wrote or trust, or use
/// ``init(_:allowing:)``.
///
/// - Parameter command: The curl command line to parse. A leading literal `curl` token, if
/// present, is ignored.
///
public init(_ command: String) {
self.init(command, allowing: .all)
}

///
/// Initializes with a curl command line that may only use the given capabilities.
///
/// A flag that needs a capability that is not in `capabilities` throws
/// ``CURLParsingError`` with ``CURLParsingError/Context/unsupportedFlag`` and the flag as
/// the token, while the command is parsed: before any file is opened and before anything is
/// sent.
///
/// ```swift
/// // A command from a source you do not control, with nothing it could leak or redirect.
/// CURLTask(pasted, allowing: [])
///
/// // The same, but it may name a proxy.
/// CURLTask(pasted, allowing: .proxy)
/// ```
///
/// - Parameters:
/// - command: The curl command line to parse. A leading literal `curl` token, if present,
/// is ignored.
/// - capabilities: What the command may do. An empty set refuses all of it.
///
public init(_ command: String, allowing capabilities: Capabilities) {
self.command = command
self.capabilities = capabilities
}

// MARK: - Public methods
Expand All @@ -83,11 +157,11 @@ public struct CURLTask: RequestTask {
/// `.description(_:onDescribe:)`, `.digestAuthentication()`, `.environment(...)`) was then a
/// silent no-op.
///
/// - Throws: ``CURLParsingError`` if the command doesn't parse, or an error from the request
/// itself.
/// - Throws: ``CURLParsingError`` if the command doesn't parse or uses a flag its capabilities
/// do not cover, or an error from the request itself.
@_spi(Private)
public func _result(environment: RequestEnvironmentValues) async throws -> TaskResult<Data> {
let parsed = try await CURLCommandParser.parseCommand(command)
let parsed = try await CURLCommandParser.parseCommand(command, allowing: capabilities)

return try await RawTask(
content: RawRequestConfigurationProperty(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,13 @@ enum CURLCommandParser {
try await parseCommand(command).requestConfiguration
}

static func parseCommand(_ command: String) async throws -> Command {
/// - Parameter capabilities: What the command may do. A flag that needs one it does not have
/// throws ``CURLParsingError/Context/unsupportedFlag`` as soon as it is read: this is the
/// loop over the tokens, so nothing was opened or built by then.
static func parseCommand(
_ command: String,
allowing capabilities: CURLTask.Capabilities = .all
) async throws -> Command {
var tokens = try CURLTokenizer.tokenize(command)

if tokens.first == "curl" {
Expand All @@ -69,6 +75,12 @@ enum CURLCommandParser {

var index = tokens.startIndex

func require(_ capability: CURLTask.Capabilities, for flag: String) throws {
guard capabilities.contains(capability) else {
throw CURLParsingError(.unsupportedFlag, token: flag)
}
}

func value(for flag: String) throws -> String {
index += 1

Expand Down Expand Up @@ -100,7 +112,13 @@ enum CURLCommandParser {
dataFragments.append(try value(for: token))

case "-F", "--form":
formItems.append(try makeFormItem(try value(for: token)))
let field = try value(for: token)

if isFile(field) {
try require(.localFiles, for: token)
}

formItems.append(try makeFormItem(field))

case "-u", "--user":
let (username, password) = try splitOnce(try value(for: token), separator: ":")
Expand Down Expand Up @@ -134,25 +152,31 @@ enum CURLCommandParser {
maxRedirects = count

case "-k", "--insecure":
try require(.disabledTLSVerification, for: token)
isInsecure = true

case "-x", "--proxy":
try require(.proxy, for: token)
proxy = try makeProxy(try value(for: token))

case "--resolve":
try require(.dnsOverrides, for: token)
let (host, address) = try makeDNSOverride(try value(for: token))
dnsOverrides[host] = address

case "--compressed":
isCompressed = true

case "--cacert":
try require(.localFiles, for: token)
caCertPath = try value(for: token)

case "--cert", "-E":
try require(.localFiles, for: token)
certPath = try value(for: token)

case "--key":
try require(.localFiles, for: token)
keyPath = try value(for: token)

// Ignored outright, not routed through `value(for:)`/`RequestConfiguration` at all:
Expand Down Expand Up @@ -451,6 +475,15 @@ enum CURLCommandParser {
configuration.body = body
}

/// Whether a `-F`/`--form` field is a file (`name=@path`), which is read from this machine.
private static func isFile(_ field: String) -> Bool {
guard let separator = field.firstIndex(of: "=") else {
return false
}

return field[field.index(after: separator)...].trimming(where: \.isWhitespace).hasPrefix("@")
}

/// Parses one `-F`/`--form` field: `name=value`, or `name=@path;type=mime` for a file.
private static func makeFormItem(_ field: String) throws -> FormItem {
let (name, rawValue) = try splitOnce(field, separator: "=", context: field)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ public struct CURLParsingError: Error, Sendable, Hashable {
public enum Context: Sendable, Hashable {

/// A flag outside the documented subset (`-X`/`-H`/`-d`/`-F`/`-u`/`--url`/`-G`, plus
/// their long forms).
/// their long forms), or one that ``CURLTask/Capabilities`` the task was given do not
/// cover (`-F name=@path`, `-k`, `-x`, `--resolve`, `--cacert`, `--cert`, `--key`).
case unsupportedFlag

/// A flag that takes a value was the last token, or its value didn't parse (a `-F`
Expand Down
Loading
Loading