From f94cc940ddd0aeb1fc8013cb388129e0568ac5af Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 6 Oct 2026 07:30:32 -0600 Subject: [PATCH 1/6] Pin Perfect-XML from 4.0.0, add CI, skip live tests visibly - Package.swift: Perfect-XML moves from branch "main" to from: "4.0.0". Perfect-XML 4.0.0 is not tagged yet, so this does not resolve until it is. It was verified against a local 4.0.0 tag in a scratch clone of the Perfect-XML release-prep branch. - CI modeled on Perfect-CRUD's: Linux Swift 6.2 and 6.4 (apt libxml2-dev and pkg-config for Perfect-XML) and macOS Xcode 26.0.1 and 26.6, debug and release, warnings as errors. No FileMaker Server service exists, so CI runs the server-independent tests. - The seven live-server tests returned early and were reported as passing without FILEMAKER_TESTS=1. They now use .enabled(if:), so they show as skipped. - defaultTimeout's doc comment described a per-call timeout: parameter that no public method has. Corrected. Verified (scratch copy resolving Perfect-XML at the local 4.0.0 tag): warning-free, 23 tests (16 run, 7 live tests skipped, 0 failures) in debug and release on Xcode 27 (Swift 6.4), Xcode 26.3 (Swift 6.2.4), and Linux swift:6.2-noble and swift:6.4-noble. Live tests not run: no FileMaker Server available. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 77 +++++++++++++++++++ Package.swift | 2 +- .../PerfectFileMaker/FileMakerServer.swift | 13 ++-- .../PerfectFileMakerTests.swift | 28 +++---- 4 files changed, 99 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..de99e50 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,77 @@ +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 diff --git a/Package.swift b/Package.swift index 0a5d842..04b6397 100644 --- a/Package.swift +++ b/Package.swift @@ -8,7 +8,7 @@ let package = Package( .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/Sources/PerfectFileMaker/FileMakerServer.swift b/Sources/PerfectFileMaker/FileMakerServer.swift index aeb83d3..4642321 100644 --- a/Sources/PerfectFileMaker/FileMakerServer.swift +++ b/Sources/PerfectFileMaker/FileMakerServer.swift @@ -60,8 +60,7 @@ 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 + /// Applied to every request as its `URLRequest.timeoutInterval`. 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 @@ -73,10 +72,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 slower response (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. diff --git a/Tests/PerfectFileMakerTests/PerfectFileMakerTests.swift b/Tests/PerfectFileMakerTests/PerfectFileMakerTests.swift index 7ddd9de..f9af62a 100644 --- a/Tests/PerfectFileMakerTests/PerfectFileMakerTests.swift +++ b/Tests/PerfectFileMakerTests/PerfectFileMakerTests.swift @@ -415,20 +415,20 @@ struct MockedFileMakerServerTests { // MARK: - Live FileMaker Server integration tests (FILEMAKER_TESTS=1) -@Test func liveDatabaseNamesIncludesSampleDatabase() async throws { - guard liveTestsEnabled() else { return } +@Test(.enabled(if: liveTestsEnabled(), "Set FILEMAKER_TESTS=1 to run against a FileMaker Server")) +func liveDatabaseNamesIncludesSampleDatabase() async throws { let names = try await liveServer().databaseNames() #expect(names.contains(sampleDB)) } -@Test func liveLayoutNamesIncludesSampleLayout() async throws { - guard liveTestsEnabled() else { return } +@Test(.enabled(if: liveTestsEnabled(), "Set FILEMAKER_TESTS=1 to run against a FileMaker Server")) +func liveLayoutNamesIncludesSampleLayout() async throws { let names = try await liveServer().layoutNames(database: sampleDB) #expect(names.contains(sampleLayout)) } -@Test func liveLayoutInfoIncludesExpectedFields() async throws { - guard liveTestsEnabled() else { return } +@Test(.enabled(if: liveTestsEnabled(), "Set FILEMAKER_TESTS=1 to run against a FileMaker Server")) +func liveLayoutInfoIncludesExpectedFields() async throws { let expectedNames = ["Status", "Category", "Description", "Task", "Related | Sort Selection", "Days Till Due", "Due Date", "Assignees::Name", "Assignees::Phone", "Assignees::Email", "Attachments::Attachment | Container", @@ -441,8 +441,8 @@ struct MockedFileMakerServerTests { } } -@Test func liveQuerySkipMaxReturnsExpectedRecordCounts() async throws { - guard liveTestsEnabled() else { return } +@Test(.enabled(if: liveTestsEnabled(), "Set FILEMAKER_TESTS=1 to run against a FileMaker Server")) +func liveQuerySkipMaxReturnsExpectedRecordCounts() async throws { let fms = liveServer() let zeroQuery = FMPQuery(database: sampleDB, layout: sampleLayout, action: .findAll).maxRecords(0) @@ -454,8 +454,8 @@ struct MockedFileMakerServerTests { #expect(twoResult.records.count == 2) } -@Test func liveQueryFindAllReturnsRecordsMatchingLayout() async throws { - guard liveTestsEnabled() else { return } +@Test(.enabled(if: liveTestsEnabled(), "Set FILEMAKER_TESTS=1 to run against a FileMaker Server")) +func liveQueryFindAllReturnsRecordsMatchingLayout() async throws { let fms = liveServer() let query = FMPQuery(database: sampleDB, layout: sampleLayout, action: .findAll) let resultSet = try await fms.query(query) @@ -495,8 +495,8 @@ struct MockedFileMakerServerTests { } } -@Test func liveQueryFindInProgressReturnsMatchingStatus() async throws { - guard liveTestsEnabled() else { return } +@Test(.enabled(if: liveTestsEnabled(), "Set FILEMAKER_TESTS=1 to run against a FileMaker Server")) +func liveQueryFindInProgressReturnsMatchingStatus() async throws { let qfields = [FMPQueryFieldGroup(fields: [FMPQueryField(name: "Status", value: "In Progress")])] let query = FMPQuery(database: sampleDB, layout: sampleLayout, action: .find).queryFields(qfields) let resultSet = try await liveServer().query(query) @@ -511,8 +511,8 @@ struct MockedFileMakerServerTests { } } -@Test func liveNewFindAndDeleteRoundTrip() async throws { - guard liveTestsEnabled() else { return } +@Test(.enabled(if: liveTestsEnabled(), "Set FILEMAKER_TESTS=1 to run against a FileMaker Server")) +func liveNewFindAndDeleteRoundTrip() async throws { let fms = liveServer() let unique = Int(Date().timeIntervalSince1970) let task = "Add a new record @ \(unique) \u{2603}\u{FE0F}" From 8b69e35a33027e69d2b8378f24abe7bf1e3f7dfc Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 6 Oct 2026 07:32:20 -0600 Subject: [PATCH 2/6] README for 4.0.0: install, platforms, what is parsed, migration - Install snippet uses from: "4.0.0"; drops the branch "main" wording. - Linux is supported (built and tested on swift:6.2-noble and swift:6.4-noble); the README called it unverified. - Fixes a contradiction: the Dependencies list said Perfect-XML parses "fmresultset and FMPXMLLAYOUT" responses, while a later note said FMPXMLLAYOUT is unimplemented. Every request uses the fmresultset grammar; layoutInfo reads field names, result types and portal fields from its metadata. FMPXMLLAYOUT and FMPXMLRESULT are not implemented. - Adds "Migrating from 3.x" and "Testing" sections. Co-Authored-By: Claude Opus 5.5 --- README.md | 43 ++++++++++++++++++++++++++++++++----------- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index ee7cd6b..4922fd8 100644 --- a/README.md +++ b/README.md @@ -2,40 +2,41 @@

Swift 6.2 - Platforms macOS 12+ + Platforms macOS 12+ | Linux License Apache 2.0

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. **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, or **Linux**. On Linux, install `libxml2-dev` and `pkg-config` for Perfect-XML (`sudo apt-get install libxml2-dev pkg-config`); on macOS 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. 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 request timeout and forced connection closure — added specifically to prevent FileMaker Web Publishing Engine session buildup 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+ (3.x declared no platforms). Linux builds need `libxml2-dev` and `pkg-config`; libcurl is no longer used. +* **`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 and PerfectLib are gone.** Requests use Foundation's `URLSession`. `FileMakerServer.init` gains optional `useTLS:`, `urlSession:` and `defaultTimeout:` parameters; existing four-argument calls still compile. +* **5-second request timeout.** Each request now times out after 5 seconds by default (3.x set no timeout of its own). Pass a longer `defaultTimeout:` for servers or queries that need it. +* **Stricter escaping.** Names and values are percent-encoded except for `A-Z a-z 0-9 - . _ ~`, which closes a query-injection hole. Comparison operators (`==`, `>`, `>=`, `<`, `<=`) are now sent literally and only the value is encoded; 3.x percent-encoded the operator along with the value. Query strings are covered by tests; there was no FileMaker Server to confirm them against. +* **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. From 3fe0b1175c3e78aff3ba52b742755f5263b34e66 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 6 Oct 2026 08:50:59 -0600 Subject: [PATCH 3/6] Declare iOS 15 and add an iOS Simulator CI job Perfect-XML 4.0 declares iOS 15, so FileMaker needs at least that to build for iOS. The iOS job mirrors Perfect-XML's, including the wait for the simulator, but without SWIFT_TREAT_WARNINGS_AS_ERRORS, which conflicts with Xcode's warning suppression for remote package dependencies; the SwiftPM jobs check warnings. Verified with a scratch copy resolving Perfect-XML at a local 4.0.0 tag of the Perfect-XML release-prep head (aa0cdb8): xcodebuild test on an iPhone 17 simulator passes with Xcode 26.3 (iOS 26.2 runtime) and Xcode 27 (27 tests: 20 run, 7 live tests skipped). The library builds at iOS 15. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 42 ++++++++++++++++++++++++++++++++++++++++ Package.swift | 2 +- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de99e50..6de30d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,3 +75,45 @@ jobs: - 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 04b6397..291a582 100644 --- a/Package.swift +++ b/Package.swift @@ -3,7 +3,7 @@ import PackageDescription let package = Package( name: "PerfectFileMaker", - platforms: [.macOS(.v12)], + platforms: [.macOS(.v12), .iOS(.v15)], products: [ .library(name: "PerfectFileMaker", targets: ["PerfectFileMaker"]), ], From 215cb9e69e217bd774b035da5bd8e5fdd635d376 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 6 Oct 2026 08:50:59 -0600 Subject: [PATCH 4/6] Tests: offline layoutInfo, layoutNames and error-401 coverage The mocked responses used type="text" on field definitions, but the code reads the data type from result= (FileMaker's type= is normal/calculation/ summary), so field typing was never exercised offline. Mocks now use result=, and new URLProtocol-stub tests cover: - layoutInfo: the -view request body, result= typing (number, date, text), and a portal's relatedset-definition; - query: typed values (number, date) and portal (relatedset) records; - layoutNames: LAYOUT_NAME parsing and the -layoutnames request body; - error 401 (no records match) thrown as FMPError.serverError(401, _). Also corrects the operator-encoding test comments: 3.0.1 encoded operator and value together once with PerfectLib's encoder, which left = and * literal and encoded < and >. Nothing was double-encoded or broken. Co-Authored-By: Claude Opus 5.5 --- .../PerfectFileMakerTests.swift | 160 ++++++++++++++++-- 1 file changed, 148 insertions(+), 12 deletions(-) diff --git a/Tests/PerfectFileMakerTests/PerfectFileMakerTests.swift b/Tests/PerfectFileMakerTests/PerfectFileMakerTests.swift index f9af62a..64cedca 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 = """ - + @@ -182,6 +182,38 @@ private let sampleResultSetXML = """ """ +/// A `-view`/find response shaped like FileMaker Server's: `result=` carries +/// the field's data type (`type=` is normal/calculation/summary), and a +/// portal appears as a `relatedset-definition` plus `relatedset` rows. +private let layoutResultSetXML = """ + + + + + + + + + + + + + + + + +12 +10/06/2026 +Open + +Bolt4 +Nut9 + + + + +""" + @Suite(.serialized) struct MockedFileMakerServerTests { @Test func queryParsesSuccessfulResultSet() async throws { @@ -228,7 +260,7 @@ struct MockedFileMakerServerTests { - + \(sampleDB) @@ -239,6 +271,110 @@ struct MockedFileMakerServerTests { #expect(names == [sampleDB]) } + @Test func layoutNamesParsesLayoutNameFieldsAndSendsLayoutnames() async throws { + let xml = """ + + + + + + + Task Details + Tasks & Notes + + + """ + let server = mockServer(handler: okResponse(xml)) + let names = try await server.layoutNames(database: "Test DB") + #expect(names == ["Task Details", "Tasks & Notes"]) + let body = String(data: MockURLProtocol.lastBody ?? Data(), encoding: .utf8) + #expect(body == "-db=Test%20DB&-layoutnames") + } + + @Test func layoutInfoReadsResultTypesAndPortalDefinitions() async throws { + let server = mockServer(handler: okResponse(layoutResultSetXML)) + let info = try await server.layoutInfo(database: "TestDB", layout: "Task Details") + let body = String(data: MockURLProtocol.lastBody ?? Data(), encoding: .utf8) + #expect(body == "-db=TestDB&-lay=Task%20Details&-view") + + #expect(info.fields.count == 4) + guard case .fieldDefinition(let count) = info.fields[0] else { + Issue.record("Expected a field definition first") + return + } + #expect(count.name == "Count") + #expect(count.type == .number) + guard case .relatedSetDefinition(let table, let portalFields) = info.fields[3] else { + Issue.record("Expected a related-set definition last") + return + } + #expect(table == "Items") + #expect(portalFields.map(\.name) == ["Items::Name", "Items::Qty"]) + #expect(portalFields.map(\.type) == [.text, .number]) + + #expect(info.fieldsByName["Count"] == .number) + #expect(info.fieldsByName["Due"] == .date) + #expect(info.fieldsByName["Status"] == .text) + #expect(info.fieldsByName["Items::Qty"] == .number) + } + + @Test func queryTypesFieldValuesAndReadsPortalRecords() async throws { + let server = mockServer(handler: okResponse(layoutResultSetXML)) + let resultSet = try await server.query(FMPQuery(database: "TestDB", layout: "Task Details", action: .findAll)) + #expect(resultSet.foundCount == 1) + let record = try #require(resultSet.records.first) + #expect(record.recordId == 7) + + guard case .field(_, .number(let count)) = record.elements["Count"] else { + Issue.record("Count should be a number: \(String(describing: record.elements["Count"]))") + return + } + #expect(count == 12) + guard case .field(_, .date(let due)) = record.elements["Due"] else { + Issue.record("Due should be a date") + return + } + #expect(due == "10/06/2026") + + guard case .relatedSet(let table, let portalRecords) = record.elements["Items"] else { + Issue.record("Expected the Items portal") + return + } + #expect(table == "Items") + #expect(portalRecords.map(\.recordId) == [100, 101]) + guard case .field(_, .text(let name)) = portalRecords[0].elements["Items::Name"], + case .field(_, .number(let qty)) = portalRecords[0].elements["Items::Qty"] else { + Issue.record("Unexpected portal row: \(portalRecords[0].elements)") + return + } + #expect(name == "Bolt") + #expect(qty == 4) + } + + @Test func noRecordsMatchError401Throws() async { + let xml = """ + + + + + + + + + """ + let server = mockServer(handler: okResponse(xml)) + let query = FMPQuery(database: "TestDB", layout: "Task Details", action: .find) + .queryFields([FMPQueryFieldGroup(fields: [FMPQueryField(name: "Status", value: "Nothing")])]) + do { + _ = try await server.query(query) + Issue.record("Expected FMPError.serverError(401, ...)") + } catch FMPError.serverError(let code, _) { + #expect(code == 401) + } catch { + Issue.record("Unexpected error \(error)") + } + } + @Test func requestSendsBasicAuthHeaderWhenCredentialsProvided() async throws { let captured = CapturedValueBox() MockURLProtocol.lastBody = nil From 60b788b1750b94351084c7a697b87a335583b7ae Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 6 Oct 2026 08:50:59 -0600 Subject: [PATCH 5/6] Docs: operator history, inactivity timeout, Connection: close - FMPQuery: 3.0.1's encoder left = * & ! ( ) ; ' , / ? @ unescaped; the operator comment no longer claims old queries were mangled. - defaultTimeout is URLRequest.timeoutInterval, an inactivity timeout (in practice time to first byte), not a limit on the whole request. Doc comment and README say so. - Connection: close asks the server to close the connection; URLSession doesn't close the socket itself. Reworded. - README: lists new/edit/delete/duplicate, iOS 15, PerfectLib as a transitive dependency of Perfect-CURL, and FoundationNetworking's own libcurl use on Linux. Co-Authored-By: Claude Opus 5.5 --- README.md | 18 +++++++-------- Sources/PerfectFileMaker/FMPQuery.swift | 22 +++++++++---------- .../PerfectFileMaker/FileMakerServer.swift | 15 +++++++++---- 3 files changed, 31 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 4922fd8..b8cdaa1 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@

Swift 6.2 - Platforms macOS 12+ | Linux + Platforms macOS 12+ | iOS 15+ | Linux License Apache 2.0

-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: search values and names are now percent-encoded with a strict @@ -18,9 +18,9 @@ The pre-Swift-6 version of this package is preserved on the [`legacy`](../../tre ## Requirements - Swift **6.2** or newer (`swift-tools-version: 6.2`) -- **macOS 12** 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 the SDK's libxml2 is used and nothing extra is needed. +- **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. -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. There is no FileMaker Server in CI, so it runs the tests that don't need one; see [Testing](#testing). +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 @@ -34,7 +34,7 @@ and add `.product(name: "PerfectFileMaker", package: "Perfect-FileMaker")` to yo - [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 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 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: 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. @@ -197,11 +197,11 @@ do { 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+ (3.x declared no platforms). Linux builds need `libxml2-dev` and `pkg-config`; libcurl is no longer used. +* **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 and PerfectLib are gone.** Requests use Foundation's `URLSession`. `FileMakerServer.init` gains optional `useTLS:`, `urlSession:` and `defaultTimeout:` parameters; existing four-argument calls still compile. -* **5-second request timeout.** Each request now times out after 5 seconds by default (3.x set no timeout of its own). Pass a longer `defaultTimeout:` for servers or queries that need it. -* **Stricter escaping.** Names and values are percent-encoded except for `A-Z a-z 0-9 - . _ ~`, which closes a query-injection hole. Comparison operators (`==`, `>`, `>=`, `<`, `<=`) are now sent literally and only the value is encoded; 3.x percent-encoded the operator along with the value. Query strings are covered by tests; there was no FileMaker Server to confirm them against. +* **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. 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 4642321..aeb6688 100644 --- a/Sources/PerfectFileMaker/FileMakerServer.swift +++ b/Sources/PerfectFileMaker/FileMakerServer.swift @@ -60,7 +60,11 @@ public struct FileMakerServer: Sendable { /// relying on a magic port number; see `effectiveUseTLS`. let useTLS: Bool? let urlSession: URLSession - /// Applied to every request as its `URLRequest.timeoutInterval`. No prior + /// 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. 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 @@ -73,7 +77,7 @@ public struct FileMakerServer: Sendable { /// 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. The public methods take no per-call timeout: - /// for calls with a real reason to expect a slower response (a + /// 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 @@ -144,8 +148,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 { From b8f0fc88f1f4a5aeb5c6e3c798671bdb16956843 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 13 Oct 2026 08:57:54 -0500 Subject: [PATCH 6/6] Docs: 3.0.1 set no timeout; only pre-release builds used URLSession's 60 s The defaultTimeout doc comment, the makeRequest comment and a test comment said every earlier version fell back to URLSession's 60-second default. 3.0.1 used Perfect-CURL and set no timeout; only the pre-release URLSession builds used 60 s. Co-Authored-By: Claude Opus 5.5 --- Sources/PerfectFileMaker/FileMakerServer.swift | 13 +++++++------ .../PerfectFileMakerTests.swift | 5 +++-- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/Sources/PerfectFileMaker/FileMakerServer.swift b/Sources/PerfectFileMaker/FileMakerServer.swift index aeb6688..79825c3 100644 --- a/Sources/PerfectFileMaker/FileMakerServer.swift +++ b/Sources/PerfectFileMaker/FileMakerServer.swift @@ -64,10 +64,10 @@ public struct FileMakerServer: Sendable { /// 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. 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 + /// 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 @@ -132,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 diff --git a/Tests/PerfectFileMakerTests/PerfectFileMakerTests.swift b/Tests/PerfectFileMakerTests/PerfectFileMakerTests.swift index 64cedca..420f8e1 100644 --- a/Tests/PerfectFileMakerTests/PerfectFileMakerTests.swift +++ b/Tests/PerfectFileMakerTests/PerfectFileMakerTests.swift @@ -475,8 +475,9 @@ struct MockedFileMakerServerTests { } @Test func requestDefaultsToAFiveSecondTimeoutNotURLSessionsSixty() async throws { - // No prior version of this library set an explicit timeout — every - // request silently used URLSession's own 60-second default. + // Earlier versions set no timeout of their own: 3.0.1 (Perfect-CURL) + // set none, and pre-release URLSession builds used URLSession's + // 60-second default. // Live-confirmed (2026-07-17) as a real contributor to FileMaker // Server Web Publishing Engine session buildup under crawl-style // load: a calling application reasonably gives up on a slow