diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6de30d6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,119 @@ +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +# There is no FileMaker Server service container, so CI runs the tests that don't need a +# server (query building, and request/response handling against a mocked URLSession). The +# live-server tests skip unless FILEMAKER_TESTS=1. + +jobs: + linux: + name: Linux (Swift ${{ matrix.swift }}, ${{ matrix.configuration }}) + runs-on: ubuntu-24.04 + container: swift:${{ matrix.swift }}-noble + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + # The oldest supported Swift (swift-tools-version 6.2) and the newest. + swift: ["6.2", "6.4"] + configuration: [debug, release] + steps: + - uses: actions/checkout@v7 + + # Perfect-XML's libxml2 system-library target finds libxml2 through pkg-config on Linux. + - name: Install libxml2 + run: | + apt-get update + apt-get install -y --no-install-recommends pkg-config libxml2-dev + + - name: Toolchain + run: swift --version + + - name: Build (warnings are errors) + run: | + flags="-c ${{ matrix.configuration }} -Xswiftc -warnings-as-errors" + # @testable imports need testing enabled in an optimized build. + [ "${{ matrix.configuration }}" = release ] && flags="$flags -Xswiftc -enable-testing" + echo "FLAGS=$flags" >> "$GITHUB_ENV" + swift build --build-tests $flags + + - name: Test + run: swift test $FLAGS + + macos: + # libxml2 comes from the macOS SDK; no Homebrew packages are needed. + name: macOS (Xcode ${{ matrix.xcode }}, ${{ matrix.configuration }}) + runs-on: macos-26 + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + # The oldest Xcode with Swift 6.2, and the runner's default. + xcode: ["26.0.1", "26.6"] + configuration: [debug, release] + steps: + - uses: actions/checkout@v7 + + - name: Select Xcode + run: | + sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app + swift --version + + - name: Build (warnings are errors) + run: | + flags="-c ${{ matrix.configuration }} -Xswiftc -warnings-as-errors" + # @testable imports need testing enabled in an optimized build. + [ "${{ matrix.configuration }}" = release ] && flags="$flags -Xswiftc -enable-testing" + echo "FLAGS=$flags" >> "$GITHUB_ENV" + swift build --build-tests $flags + + - name: Test + run: swift test $FLAGS + + ios: + name: iOS Simulator + runs-on: macos-26 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + + - name: Select Xcode + run: | + sudo xcode-select -s /Applications/Xcode_26.6.app + xcodebuild -version + + # On a fresh runner the simulator service can start late, and xcodebuild then lists no + # simulators at all ("Unable to find a device"). Wait until it offers the one we test on. + # Listing devices with simctl makes the service refresh its runtimes + # (actions/runner-images#12948). + - name: Wait for the simulator + run: | + deadline=$((SECONDS + 300)) + while [ $SECONDS -lt $deadline ]; do + xcrun simctl list devices available > /dev/null 2>&1 || true + if destinations=$(xcodebuild -showdestinations -scheme PerfectFileMaker) \ + && grep -q "name:iPhone 17 }" <<< "$destinations"; then + exit 0 + fi + echo "No iPhone 17 simulator yet; retrying in 5 seconds" + sleep 5 + done + echo "No iPhone 17 simulator after 5 minutes" + xcrun simctl list devices available + exit 1 + + # The library builds at the package's iOS 15 deployment target, so this also catches + # library APIs that need a newer iOS. No SWIFT_TREAT_WARNINGS_AS_ERRORS here: it + # conflicts with Xcode's warning suppression for remote package dependencies + # (Perfect-XML). The SwiftPM jobs above check warnings. + - name: Test + run: | + xcodebuild test -scheme PerfectFileMaker \ + -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' \ + -destination-timeout 120 diff --git a/Package.swift b/Package.swift index 0a5d842..291a582 100644 --- a/Package.swift +++ b/Package.swift @@ -3,12 +3,12 @@ import PackageDescription let package = Package( name: "PerfectFileMaker", - platforms: [.macOS(.v12)], + platforms: [.macOS(.v12), .iOS(.v15)], products: [ .library(name: "PerfectFileMaker", targets: ["PerfectFileMaker"]), ], dependencies: [ - .package(url: "https://github.com/PerfectlySoft/Perfect-XML.git", branch: "main"), + .package(url: "https://github.com/PerfectlySoft/Perfect-XML.git", from: "4.0.0"), ], targets: [ .target( diff --git a/README.md b/README.md index ee7cd6b..b8cdaa1 100644 --- a/README.md +++ b/README.md @@ -2,40 +2,41 @@
-This project provides access to FileMaker Server databases using the classic XML Custom Web Publishing (CWP) interface (the `fmresultset` grammar) — listing databases, layouts, and fields, and running `find`/`findAll` queries. It was written to be stand-alone and does not need to be run as part of a Perfect server application. +This project provides access to FileMaker Server databases using the classic XML Custom Web Publishing (CWP) interface (the `fmresultset` grammar) — listing databases, layouts, and fields; running `find`, `findAll` and `findAny` queries; and creating, editing, deleting and duplicating records (`.new`, `.edit`, `.delete`, `.duplicate`). It was written to be stand-alone and does not need to be run as part of a Perfect server application. **Modernized for Swift 6.** A real query-injection gap in the original percent-encoder was found -and fixed during modernization — see the security note in the changelog/commit history if you're -evaluating this as a trust boundary. +and fixed during modernization: search values and names are now percent-encoded with a strict +allow-list, so a value containing `&` or `=` can't add query directives. See the +[4.0.0 release notes](../../releases/tag/4.0.0) if you're evaluating this as a trust boundary. The pre-Swift-6 version of this package is preserved on the [`legacy`](../../tree/legacy) branch. ## Requirements -- Swift tools version **6.2** (see `Package.swift`'s `swift-tools-version`) -- **macOS 12** or later — this is the only platform formally declared in `Package.swift`'s `platforms` array +- Swift **6.2** or newer (`swift-tools-version: 6.2`) +- **macOS 12** or later, **iOS 15** or later, or **Linux**. On Linux, install `libxml2-dev` and `pkg-config` for Perfect-XML (`sudo apt-get install libxml2-dev pkg-config`); on macOS and iOS the SDK's libxml2 is used and nothing extra is needed. -The source still guards its networking import with `#if canImport(FoundationNetworking)` for portability, but Linux is not currently a declared/supported SPM platform for this package — treat Linux support as unverified rather than assume the old Linux build notes below still apply. +CI builds and tests on Linux (Swift 6.2 and 6.4) and macOS (Xcode 26.0.1 and 26.6), in debug and release with warnings treated as errors, and on the iOS Simulator. There is no FileMaker Server in CI, so it runs the tests that don't need one; see [Testing](#testing). ## Building ```swift -.package(url: "https://github.com/PerfectlySoft/Perfect-FileMaker.git", branch: "main") +.package(url: "https://github.com/PerfectlySoft/Perfect-FileMaker.git", from: "4.0.0") ``` -This package's own `Package.swift` resolves its [Perfect-XML](https://github.com/PerfectlySoft/Perfect-XML) dependency the same way (`.package(url:, branch: "main")`) — no monorepo layout is required to build either repo. +and add `.product(name: "PerfectFileMaker", package: "Perfect-FileMaker")` to your target's dependencies. ## Dependencies -- [Perfect-XML](https://github.com/PerfectlySoft/Perfect-XML) — used for parsing the `fmresultset` and `FMPXMLLAYOUT` XML responses. +- [Perfect-XML](https://github.com/PerfectlySoft/Perfect-XML) 4.0.0 or later — used to parse the `fmresultset` XML responses. Every request, including `layoutInfo` (sent as `-view`), asks for the `fmresultset` grammar. -Networking is done directly via Foundation's `URLSession`/`URLRequest` — there is **no Perfect-CURL dependency** and no libcurl requirement. Query requests are deliberately sent as `POST` rather than `GET`, to avoid credential-adjacent query values leaking into URL logs, with a default 5-second request timeout and forced connection closure — added specifically to prevent FileMaker Web Publishing Engine session buildup under crawl-style load. +Networking is done directly via Foundation's `URLSession`/`URLRequest` — there is **no Perfect-CURL dependency** and no libcurl requirement of its own (on Linux, FoundationNetworking itself links libcurl, which the official Swift images already include). Query requests are deliberately sent as `POST` rather than `GET`, to avoid credential-adjacent query values leaking into URL logs, with a default 5-second inactivity timeout (`URLRequest.timeoutInterval`: it fires when no data arrives for 5 seconds, in practice while waiting for the first byte, not after 5 seconds in total) and a `Connection: close` header asking the server to close each connection after its response. Both were added to stop FileMaker Web Publishing Engine sessions building up under crawl-style load. -Note: the classic `FMPXMLLAYOUT` grammar (full layout/value-list introspection beyond field names) is deliberately unimplemented, matching the original PerfectlySoft library's behavior — this is a known, intentional gap rather than an oversight. +Note: only the `fmresultset` grammar is implemented. `layoutInfo` reads the field names, field result types and portal (related-set) fields from the `fmresultset` metadata. The `FMPXMLLAYOUT` grammar (value lists and other layout details) and the `FMPXMLRESULT` grammar are deliberately unimplemented, matching the original PerfectlySoft library. ## Examples @@ -191,3 +192,23 @@ do { print("Got an unexpected error \(e)") } ``` + +## Migrating from 3.x + +4.0.0 is a major release; the [release notes](../../releases/tag/4.0.0) list every change. The ones most likely to need attention: + +* **Toolchain and platforms.** Swift tools 6.2 is required, and the package declares macOS 12+ and iOS 15+ (3.x declared no platforms). Linux builds need `libxml2-dev` and `pkg-config`. Perfect-CURL is no longer used (on Linux, FoundationNetworking still links libcurl itself). +* **`async`/`await` instead of callbacks.** `databaseNames()`, `layoutNames(database:)`, `layoutInfo(database:layout:)` and `query(_:)` are `async throws` and return their result directly. Replace `fms.query(q) { response in let rs = try response() ... }` with `let rs = try await fms.query(q)`. +* **Perfect-CURL is gone** (and with it PerfectLib, which came in through Perfect-CURL). Requests use Foundation's `URLSession`. `FileMakerServer.init` gains optional `useTLS:`, `urlSession:` and `defaultTimeout:` parameters; existing four-argument calls still compile. +* **5-second inactivity timeout.** A request now fails if no data arrives for 5 seconds (in practice, if the first byte takes longer); a response that keeps arriving can take longer in total. 3.x set no timeout of its own. Pass a longer `defaultTimeout:` for servers, large finds or scripted queries that are slow to start responding. +* **Stricter escaping.** Names and values are percent-encoded except for `A-Z a-z 0-9 - . _ ~`, which closes a query-injection hole. 3.x left `= * & ! ( ) ; ' , / ? @` unescaped, so a value containing `&` could add query directives. Operators are unchanged in effect: 4.0 sends `<` and `>` literally where 3.x encoded them, which decodes to the same thing. +* **Plain-HTTP credentials are logged.** Sending a user name and password without TLS (`useTLS` unset and port not 443) still works but writes a warning to standard error. +* **`Sendable`.** The public types are `Sendable`. `FMPQueryField` is `@unchecked Sendable` because its `value` is `Any`: pass only strings and numbers. + +## Testing + +``` +swift test +``` + +The query-building and request/response tests run anywhere; the request tests use a mocked `URLSession`. The live tests run against the `FMServer_Sample` database on a real FileMaker Server and are skipped unless `FILEMAKER_TESTS=1` is set, with `FILEMAKER_HOST`, `FILEMAKER_PORT`, `FILEMAKER_USER` and `FILEMAKER_PASSWORD` describing the server. diff --git a/Sources/PerfectFileMaker/FMPQuery.swift b/Sources/PerfectFileMaker/FMPQuery.swift index 9bc3653..eb50950 100644 --- a/Sources/PerfectFileMaker/FMPQuery.swift +++ b/Sources/PerfectFileMaker/FMPQuery.swift @@ -23,7 +23,7 @@ import Foundation /// allow-list (`A-Za-z0-9-._~`) — deliberately narrower than /// `Foundation`'s `CharacterSet.urlQueryAllowed` (which lets `!$&'()*+,;=` /// through untouched) and than the original `PerfectLib.stringByEncodingURL` -/// this replaces (which left `& = ! ( ) * ;` unescaped). FileMaker's +/// this replaces (which left `= * & ! ( ) ; ' , / ? @` unescaped). FileMaker's /// classic XML Web Publishing query grammar treats exactly those /// characters as structurally significant (`-q1=Name&-q1.value=Value`, /// compound queries like `-query=(q1,q2);(q3)`) — an unescaped `&` in a @@ -145,16 +145,16 @@ public struct FMPQueryField: @unchecked Sendable { self.op = op } - /// The operator token is always-ASCII, fixed FileMaker query syntax — - /// never user data — and stays unencoded. Only the actual comparison - /// value is percent-encoded, and only once: encoding the operator - /// characters themselves (`> < = *`) here and relying on the call - /// site to encode this whole string a second time was the original - /// bug (see `FMPQuery.swift`'s `compoundFieldsString`) — under - /// `PerfectLib`'s old encoder, `.greaterThan`/`.lessThanEqual`/etc. - /// (which embed `<`/`>`/`=`, characters that encoder did escape) - /// produced mangled, broken queries. Never feed operator syntax into - /// the encoder. + /// The operator token is fixed ASCII FileMaker query syntax — never + /// user data — and stays unencoded; only the comparison value is + /// percent-encoded. 3.0.1 instead ran PerfectLib's `stringByEncodingURL` + /// once over operator and value together. That left `=` and `*` + /// literal and encoded only `<`/`>` (equivalent once the server + /// form-decodes the body), but it also left `&` in the value literal, + /// which let a value add query directives. Encoding the value on its + /// own with `fmpEscaped` closes that. The operator is left out of the + /// encoder only to keep the query string readable; encoding it would + /// decode to the same thing. var valueWithOp: String { let encodedValue = "\(value)".fmpEscaped switch op { diff --git a/Sources/PerfectFileMaker/FileMakerServer.swift b/Sources/PerfectFileMaker/FileMakerServer.swift index aeb83d3..79825c3 100644 --- a/Sources/PerfectFileMaker/FileMakerServer.swift +++ b/Sources/PerfectFileMaker/FileMakerServer.swift @@ -60,11 +60,14 @@ public struct FileMakerServer: Sendable { /// relying on a magic port number; see `effectiveUseTLS`. let useTLS: Bool? let urlSession: URLSession - /// Applied to every request whose call site doesn't pass its own - /// `timeout:` override (see each public method below). No prior - /// version of this library set an explicit request timeout at all — - /// every outbound request silently fell back to `URLSession`'s own - /// default (`timeoutIntervalForRequest`, 60 seconds). Live-confirmed + /// Applied to every request as its `URLRequest.timeoutInterval`. That is + /// an inactivity timeout, not a limit on the whole request: it fires + /// when no data arrives for this long (in practice, while waiting for + /// the first byte of the response), and a response that keeps + /// trickling in can take longer in total. Earlier versions set no + /// timeout of their own: 3.0.1 (Perfect-CURL) set none at all, and + /// pre-release URLSession builds fell back to `URLSession`'s default + /// (`timeoutIntervalForRequest`, 60 seconds). Live-confirmed /// (2026-07-17) as a real contributor to FileMaker Server Web /// Publishing Engine session buildup under crawl-style load: a /// calling application can reasonably give up on a slow response @@ -73,10 +76,12 @@ public struct FileMakerServer: Sendable { /// request kept running in the background regardless, for up to the /// rest of that 60 seconds, holding its WPE session open the whole /// time. 5 seconds is a deliberately short default that fails fast - /// for ordinary lookups; call sites with a real reason to expect a - /// slower response (a large-dataset query, or one that triggers a - /// FileMaker script) should pass an explicit longer `timeout:` to - /// the specific call that needs it, not raise this default globally. + /// for ordinary lookups. The public methods take no per-call timeout: + /// for calls with a real reason to expect a slow first byte (a + /// large-dataset query, or one that triggers a FileMaker script), make + /// a second `FileMakerServer` value with a longer `defaultTimeout` and + /// use it for just those calls, rather than raising this default for + /// everything. public let defaultTimeout: TimeInterval /// Initialize using a host, port, username and password. @@ -127,8 +132,9 @@ public struct FileMakerServer: Sendable { func makeRequest(url: URL) -> URLRequest { var request = URLRequest(url: url) request.httpMethod = "POST" - // No prior version of this library set an explicit timeout — every - // request silently used URLSession's own 60-second default. See + // Earlier versions set no timeout of their own (3.0.1 via Perfect-CURL + // set none; pre-release URLSession builds used URLSession's 60-second + // default). See // `defaultTimeout`'s own doc comment for why that's a real problem // under crawl-style load, not just theoretical. request.timeoutInterval = defaultTimeout @@ -143,8 +149,11 @@ public struct FileMakerServer: Sendable { // (not a tight request loop that benefits from reuse), those // accumulate as connections FileMaker Server's own admin console // shows as still "open" long after their one request finished. - // Forcing closure here trades a per-request TCP handshake for - // guaranteed cleanup — the right tradeoff for this workload. + // `Connection: close` asks the server to close the connection + // after its response (URLSession doesn't close the socket itself), + // trading a per-request TCP handshake for cleanup that doesn't + // depend on keep-alive timers — the right tradeoff for this + // workload. request.setValue("close", forHTTPHeaderField: "Connection") if !userName.isEmpty { if !effectiveUseTLS { diff --git a/Tests/PerfectFileMakerTests/PerfectFileMakerTests.swift b/Tests/PerfectFileMakerTests/PerfectFileMakerTests.swift index 7ddd9de..420f8e1 100644 --- a/Tests/PerfectFileMakerTests/PerfectFileMakerTests.swift +++ b/Tests/PerfectFileMakerTests/PerfectFileMakerTests.swift @@ -34,7 +34,7 @@ private func liveServer() -> FileMakerServer { // Literal expected strings reflect this modernization's stricter // `String.fmpEscaped` encoder (alnum + `-._~` only) — deliberately // narrower than the original `PerfectLib.stringByEncodingURL`, which left -// `& = ! ( ) * ; @` unescaped. `(`/`)` (in `-query=(q1)`) and `@` (in a +// `= * & ! ( ) ; ' , / ? @` unescaped. `(`/`)` (in `-query=(q1)`) and `@` (in a // literal task name below) now percent-encode to `%28`/`%29`/`%40` where // the old fixtures showed them literal — see FMPQuery.swift's doc // comments for why over-encoding here is safe, not a regression. @@ -60,16 +60,16 @@ private func liveServer() -> FileMakerServer { #expect("\(query)" == "-db=FMServer_Sample&-lay=Task%20Details&Task=Add%20a%20new%20record%20%40%20123%20%E2%98%83%EF%B8%8F&Status=In%20Progress&-new") } -// MARK: - Operator encoding (regression for the valueWithOp bug) +// MARK: - Operator encoding // -// Under the original PerfectLib encoder, `<`/`>` (used by -// .greaterThan/.lessThan/.greaterThanEqual/.lessThanEqual) WERE escaped, -// but only .equal/.beginsWith had test coverage — these operators were -// silently broken. Confirms the fixed `valueWithOp` (operator token -// unencoded, value encoded standalone) now produces well-formed, -// unmangled query strings for every operator. - -@Test func comparisonOperatorsProduceUnmangledQueryStrings() { +// `valueWithOp` keeps the operator token literal and encodes only the +// value. (3.0.1 encoded operator and value together with PerfectLib's +// encoder, which encoded `<`/`>` and left `=`/`*` literal; after the +// server form-decodes the body that is equivalent, so the old operators +// worked. What it got wrong was leaving `&` in values literal.) These pin +// the query string for every operator. + +@Test func comparisonOperatorsAreSentLiterallyWithEncodedValues() { let cases: [(FMPFieldOp, String, String)] = [ (.equal, "5", "==5"), (.contains, "abc", "==*abc*"), @@ -172,7 +172,7 @@ private let sampleResultSetXML = """