From b1f50018b9204537c8ce2744c5aa0279374a3120 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:17:19 -0600 Subject: [PATCH] fix: decode the local file path before handing it to the media upload API `URL.path()` percent-encodes by default, unlike the legacy `url.path` property. `MediaCreateParams` passed the encoded form as `filePath`, so any media file whose name needs encoding could not be found on disk: WpApiError.MediaFileNotFound(filePath: ".../screenrecording_08-27-2024%2012-12-08_1.mp4") while the file on disk was `screenrecording_08-27-2024 12-12-08_1.mp4`. A space is enough to trigger it, which makes this reachable with ordinary content: screen recordings and files imported from macOS routinely have spaces in their names. --- .../MediaCreateParamsFilePathTests.swift | 41 +++++++++++++++++++ .../Services/MediaServiceRemoteCoreREST.swift | 8 +++- 2 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 Tests/KeystoneTests/Tests/Services/MediaCreateParamsFilePathTests.swift diff --git a/Tests/KeystoneTests/Tests/Services/MediaCreateParamsFilePathTests.swift b/Tests/KeystoneTests/Tests/Services/MediaCreateParamsFilePathTests.swift new file mode 100644 index 000000000000..e1050287d328 --- /dev/null +++ b/Tests/KeystoneTests/Tests/Services/MediaCreateParamsFilePathTests.swift @@ -0,0 +1,41 @@ +import Foundation +import Testing +import WordPressAPI +import WordPressKit + +@testable import WordPress + +/// `URL.path()` percent-encodes by default, unlike the legacy `url.path` property, so the +/// upload API was handed a path that doesn't exist on disk whenever the filename needed +/// encoding. A space is enough, which makes it reachable with ordinary content. +struct MediaCreateParamsFilePathTests { + + @Test func decodesPercentEncodingInTheFilePath() throws { + let media = RemoteMedia() + media.localURL = URL(fileURLWithPath: "/tmp/media/screen recording 1.mp4") + + let params = try #require(MediaCreateParams(media: media)) + + #expect(params.filePath == "/tmp/media/screen recording 1.mp4") + } + + /// Characters beyond the space get encoded too, so check one of those as well. + @Test func decodesPercentEncodingBeyondSpaces() throws { + let media = RemoteMedia() + media.localURL = URL(fileURLWithPath: "/tmp/media/100% done.jpg") + + let params = try #require(MediaCreateParams(media: media)) + + #expect(params.filePath == "/tmp/media/100% done.jpg") + } + + /// A name needing no encoding has to survive untouched. + @Test func leavesAnOrdinaryPathAlone() throws { + let media = RemoteMedia() + media.localURL = URL(fileURLWithPath: "/tmp/media/IMG_0001.jpg") + + let params = try #require(MediaCreateParams(media: media)) + + #expect(params.filePath == "/tmp/media/IMG_0001.jpg") + } +} diff --git a/WordPress/Classes/Services/MediaServiceRemoteCoreREST.swift b/WordPress/Classes/Services/MediaServiceRemoteCoreREST.swift index aa1d060c0066..394caa07214a 100644 --- a/WordPress/Classes/Services/MediaServiceRemoteCoreREST.swift +++ b/WordPress/Classes/Services/MediaServiceRemoteCoreREST.swift @@ -176,7 +176,9 @@ private extension RemoteMedia { } } -private extension MediaCreateParams { +// Not `private`: the file-path handling below is covered by +// `MediaCreateParamsFilePathTests`. +extension MediaCreateParams { init?(media: RemoteMedia) { guard let localURL = media.localURL else { @@ -198,7 +200,9 @@ private extension MediaCreateParams { caption: media.caption, description: media.descriptionText, postId: media.postID?.int64Value, - filePath: localURL.path() + // `path()` percent-encodes by default, so a filename with a space (a screen + // recording, say) becomes a path that doesn't exist on disk. + filePath: localURL.path(percentEncoded: false) ) } }