From fbd1fe2e491f95a6970b824f77d083a41721d979 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Tue, 7 Jul 2026 18:30:34 +1200 Subject: [PATCH 1/6] Rename MediaUploadPolicy.stripImageLocation to stripGPSLocation The flag is the single Remove Location setting: it strips GPS EXIF from images and selects the identifying-metadata filter for re-exported video, so the name should not imply it is image-only. --- .../Models/MediaUploadPolicy.swift | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Modules/Sources/WordPressMediaLibrary/Models/MediaUploadPolicy.swift b/Modules/Sources/WordPressMediaLibrary/Models/MediaUploadPolicy.swift index ee56b2dd24d0..140e6db5587f 100644 --- a/Modules/Sources/WordPressMediaLibrary/Models/MediaUploadPolicy.swift +++ b/Modules/Sources/WordPressMediaLibrary/Models/MediaUploadPolicy.swift @@ -42,8 +42,10 @@ public struct MediaUploadPolicy: Sendable { /// file and the effective MIME type the validator checks against. let videoOutputContentType: UTType - /// If true, strip GPS EXIF before upload. - let stripImageLocation: Bool + /// The "Remove Location" setting. If true, GPS EXIF is stripped from + /// images and identifying metadata (via `AVMetadataItemFilter.forSharing()`) + /// is filtered from re-exported videos before upload. + let stripGPSLocation: Bool public init( filePickerContentTypes: [UTType], @@ -54,7 +56,7 @@ public struct MediaUploadPolicy: Sendable { videoMaxDurationSeconds: TimeInterval?, videoExportPreset: String, videoOutputContentType: UTType, - stripImageLocation: Bool + stripGPSLocation: Bool ) { self.filePickerContentTypes = filePickerContentTypes self.isAllowedForUpload = isAllowedForUpload @@ -64,6 +66,6 @@ public struct MediaUploadPolicy: Sendable { self.videoMaxDurationSeconds = videoMaxDurationSeconds self.videoExportPreset = videoExportPreset self.videoOutputContentType = videoOutputContentType - self.stripImageLocation = stripImageLocation + self.stripGPSLocation = stripGPSLocation } } From f548d7a6134f94b2218568c98c54668fe3a8e2da Mon Sep 17 00:00:00 2001 From: Tony Li Date: Tue, 7 Jul 2026 18:30:34 +1200 Subject: [PATCH 2/6] Add UploadSourceMaterializer with policy-driven transforms Turns an upload source into an uploadable file: image normalization, video re-export, geolocation strip, and remote-URL download, all gated by the injected MediaUploadPolicy. Images are validated, type-sniffed, converted, resized, and GPS-stripped in a single ImageIO pass that preserves the EXIF orientation tag whenever pixels are not resampled. GIF and SVG never re-encode: one shared raw-passthrough rule covers the photo library, file importer, and remote sources. Includes the filename allocator, remote downloader, materializer error type, and their unit tests. --- .../Models/UploadSource.swift | 18 +- .../Strings/Strings.swift | 17 +- .../Upload/MaterializerError.swift | 39 + .../Upload/RemoteDownloader.swift | 82 ++ .../Upload/UploadFilenameAllocator.swift | 72 ++ .../Upload/UploadSourceMaterializer.swift | 924 ++++++++++++++++++ .../UploadFilenameAllocatorTests.swift | 82 ++ .../UploadSourceMaterializerTests.swift | 850 ++++++++++++++++ 8 files changed, 2077 insertions(+), 7 deletions(-) create mode 100644 Modules/Sources/WordPressMediaLibrary/Upload/MaterializerError.swift create mode 100644 Modules/Sources/WordPressMediaLibrary/Upload/RemoteDownloader.swift create mode 100644 Modules/Sources/WordPressMediaLibrary/Upload/UploadFilenameAllocator.swift create mode 100644 Modules/Sources/WordPressMediaLibrary/Upload/UploadSourceMaterializer.swift create mode 100644 Modules/Tests/WordPressMediaLibraryTests/UploadFilenameAllocatorTests.swift create mode 100644 Modules/Tests/WordPressMediaLibraryTests/UploadSourceMaterializerTests.swift diff --git a/Modules/Sources/WordPressMediaLibrary/Models/UploadSource.swift b/Modules/Sources/WordPressMediaLibrary/Models/UploadSource.swift index cf51be032915..3466d7c119a7 100644 --- a/Modules/Sources/WordPressMediaLibrary/Models/UploadSource.swift +++ b/Modules/Sources/WordPressMediaLibrary/Models/UploadSource.swift @@ -75,10 +75,7 @@ extension UploadSource { case .cameraVideo: return .video case .file(let url): - let contentType = - (try? url.resourceValues(forKeys: [.contentTypeKey]))?.contentType - ?? UTType(filenameExtension: url.pathExtension) - return contentType.map { MediaKind(estimating: $0) } ?? .document + return url.resolvedContentType.map { MediaKind(estimating: $0) } ?? .document case .remoteURL(let remote): return MediaKind(estimating: remote.contentType) case .imagePlayground: @@ -86,3 +83,16 @@ extension UploadSource { } } } + +extension URL { + /// The URL's content type from the file system's `.contentTypeKey` + /// resource, falling back to the path extension. `nil` when neither + /// resolves. The single URL→UTType resolution rule shared by + /// `estimatedKind` and the materializer. + var resolvedContentType: UTType? { + if let type = try? resourceValues(forKeys: [.contentTypeKey]).contentType { + return type + } + return UTType(filenameExtension: pathExtension) + } +} diff --git a/Modules/Sources/WordPressMediaLibrary/Strings/Strings.swift b/Modules/Sources/WordPressMediaLibrary/Strings/Strings.swift index 394781fe5bae..34bb82385f0d 100644 --- a/Modules/Sources/WordPressMediaLibrary/Strings/Strings.swift +++ b/Modules/Sources/WordPressMediaLibrary/Strings/Strings.swift @@ -140,10 +140,21 @@ enum Strings { value: "This file type isn't allowed for upload on your site.", comment: "Error shown when a picked file's type is not in the blog's allowed list." ) - static let uploadErrorHEICConversion = NSLocalizedString( - "mediaLibrary.upload.error.heicConversion", + static let uploadErrorInvalidImage = NSLocalizedString( + "mediaLibrary.upload.error.invalidImage", + value: "The selected file isn't a valid image.", + comment: "Error shown when picked or downloaded bytes do not decode as an image." + ) + static let uploadErrorImageEncode = NSLocalizedString( + "mediaLibrary.upload.error.imageEncode", value: "Couldn't convert the photo for upload.", - comment: "Error shown when HEIC-to-JPEG conversion fails before upload." + comment: "Error shown when re-encoding an image (e.g. HEIC to JPEG) fails before upload." + ) + static let uploadErrorLocationStripFailed = NSLocalizedString( + "mediaLibrary.upload.error.locationStrip", + value: "Couldn't remove the location from the photo for upload.", + comment: + "Error shown when stripping GPS/location metadata from an image fails and the Remove Location setting is on." ) static let uploadErrorVideoExport = NSLocalizedString( "mediaLibrary.upload.error.videoExport", diff --git a/Modules/Sources/WordPressMediaLibrary/Upload/MaterializerError.swift b/Modules/Sources/WordPressMediaLibrary/Upload/MaterializerError.swift new file mode 100644 index 000000000000..42ea9fa29b1f --- /dev/null +++ b/Modules/Sources/WordPressMediaLibrary/Upload/MaterializerError.swift @@ -0,0 +1,39 @@ +import Foundation + +enum MaterializerError: LocalizedError { + case securityScopedAccessDenied + case fileNotFound + case durationCapExceeded + case disallowedContentType + case invalidImageData + case imageEncodeFailed + case locationStripFailed + case videoExportFailed(underlyingError: Error) + case videoExportSessionUnavailable + case unknownContentType + case remoteDownloadFailed(underlyingError: Error) + + var errorDescription: String? { + switch self { + case .securityScopedAccessDenied: return Strings.uploadErrorSecurityScopedAccess + case .fileNotFound: return Strings.uploadErrorFileNotFound + case .durationCapExceeded: return Strings.uploadErrorDurationCap + case .disallowedContentType: return Strings.uploadErrorDisallowedType + case .invalidImageData: return Strings.uploadErrorInvalidImage + case .imageEncodeFailed: return Strings.uploadErrorImageEncode + case .locationStripFailed: return Strings.uploadErrorLocationStripFailed + case .videoExportFailed(let underlyingError): + return String.localizedStringWithFormat( + Strings.uploadErrorVideoExport, + underlyingError.localizedDescription + ) + case .videoExportSessionUnavailable: return Strings.uploadErrorVideoExportNoExporter + case .unknownContentType: return Strings.uploadErrorUnknownContentType + case .remoteDownloadFailed(let underlyingError): + return String.localizedStringWithFormat( + Strings.materializerErrorRemoteDownloadFailed, + underlyingError.localizedDescription + ) + } + } +} diff --git a/Modules/Sources/WordPressMediaLibrary/Upload/RemoteDownloader.swift b/Modules/Sources/WordPressMediaLibrary/Upload/RemoteDownloader.swift new file mode 100644 index 000000000000..512cbf0b8536 --- /dev/null +++ b/Modules/Sources/WordPressMediaLibrary/Upload/RemoteDownloader.swift @@ -0,0 +1,82 @@ +import Foundation + +/// Single-use downloader for `.remoteURL` materialization. The materializer +/// constructs one per download and discards it after. +/// +/// Built on the async `URLSession.download(from:delegate:)`, so the runtime +/// owns the continuation, cancellation, temp-file delivery, and error +/// propagation. The only thing it can't give us is byte-level progress +/// (the async convenience methods suppress `didWriteData`), so we observe +/// the task's own `Progress` via KVO from `didCreateTask`, the workaround +/// Apple's URL Loading System team recommends for this case: +/// https://developer.apple.com/forums/thread/723015 +final class RemoteDownloader { + + /// Downloads `url` into `parentDir` and returns the local file URL. Reports + /// byte-level progress on `progress` (scaled to its existing + /// `totalUnitCount`). Cooperative task cancellation cancels the request and + /// surfaces as `CancellationError`. + func download(from url: URL, into parentDir: URL, progress: Progress) async throws -> URL { + let delegate = ProgressForwardingDelegate(progress: progress) + let location: URL + let response: URLResponse + do { + (location, response) = try await URLSession.shared.download(from: url, delegate: delegate) + } catch { + if error is CancellationError || (error as? URLError)?.code == .cancelled { + throw CancellationError() + } + throw MaterializerError.remoteDownloadFailed(underlyingError: error) + } + + // HTTP status check: a 404/500 response body looks like a successful + // download to URLSession (it still hands back a valid temp file). For + // Stock Photos the image-byte validator catches HTML error bodies + // later, but remote GIFs are a raw passthrough, so without this we'd + // happily upload an HTML 404 response as 'image/gif'. + if let httpResponse = response as? HTTPURLResponse, + !(200..<300).contains(httpResponse.statusCode) + { + let statusError = NSError( + domain: "RemoteDownloader", + code: httpResponse.statusCode, + userInfo: [NSLocalizedDescriptionKey: "HTTP \(httpResponse.statusCode) response"] + ) + throw MaterializerError.remoteDownloadFailed(underlyingError: statusError) + } + + // Move out of the system temp location (reaped after this call) into our + // owned parentDir, reusing the system-generated unique name. + let dest = parentDir.appendingPathComponent(location.lastPathComponent) + do { + try FileManager.default.moveItem(at: location, to: dest) + } catch { + throw MaterializerError.remoteDownloadFailed(underlyingError: error) + } + return dest + } +} + +/// Forwards the download task's `Progress` to the caller-supplied `progress`. +/// `didCreateTask` fires for the async download API (unlike `didWriteData`), +/// so it's the hook for installing the KVO observation. +private final class ProgressForwardingDelegate: NSObject, URLSessionTaskDelegate { + private let progress: Progress + private var observation: NSKeyValueObservation? + + init(progress: Progress) { + self.progress = progress + super.init() + } + + func urlSession(_ session: URLSession, didCreateTask task: URLSessionTask) { + observation = task.progress.observe(\.fractionCompleted) { [progress] taskProgress, _ in + // `fractionCompleted` stays 0 when the server sends no Content-Length + // (totalUnitCount is -1), leaving the row indeterminate until the + // upload phase drives it to completion. + progress.completedUnitCount = Int64( + (taskProgress.fractionCompleted * Double(progress.totalUnitCount)).rounded() + ) + } + } +} diff --git a/Modules/Sources/WordPressMediaLibrary/Upload/UploadFilenameAllocator.swift b/Modules/Sources/WordPressMediaLibrary/Upload/UploadFilenameAllocator.swift new file mode 100644 index 000000000000..b30117a13a55 --- /dev/null +++ b/Modules/Sources/WordPressMediaLibrary/Upload/UploadFilenameAllocator.swift @@ -0,0 +1,72 @@ +import Foundation +import os + +/// Generates the temp-file basenames used for media uploads, and with them the +/// filenames WordPress ends up storing. +/// +/// The uploaded media's filename is the basename of the file sent to the +/// server: `MediaCreateParams` carries no explicit filename, so the upload +/// layer (wordpress-rs) falls back to the last path component of the file it +/// uploads. Naming each temp file `.` from the source's own name is +/// therefore the whole of filename preservation (and, since the title is left +/// unset, drives the server-derived attachment title too). +/// +/// Holds the basenames already handed out so repeated names within one uploader +/// session get ` (2)`, ` (3)` suffixes instead of overwriting each other. +final class UploadFilenameAllocator: Sendable { + private let usedBasenames = OSAllocatedUnfairLock>(initialState: []) + + /// The sanitized `preferred` name, or `-` when + /// the source has no usable name. Carries no extension and is not yet + /// deduplicated; pass the result to `basename`. + func stem(preferred: String?, fallbackPrefix: String, date: Date) -> String { + preferred.flatMap(sanitize) ?? "\(fallbackPrefix)-\(timestampedName(date: date))" + } + + /// A unique `.` basename. Names already handed out for the + /// lifetime of this allocator are tracked, so a batch with two same-named + /// files becomes `name.jpg`, `name (2).jpg`, and so on. + func basename(stem: String, ext: String) -> String { + usedBasenames.withLock { used in + let first = "\(stem).\(ext)" + if used.insert(first).inserted { + return first + } + var n = 2 + while true { + let candidate = "\(stem) (\(n)).\(ext)" + if used.insert(candidate).inserted { + return candidate + } + n += 1 + } + } + } + + /// Formatter for `timestampedName`. `DateFormatter` construction is + /// expensive, and the class is documented thread-safe, so one shared + /// instance serves every allocation. + private static let timestampFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd HH-mm-ss" + return formatter + }() + + /// Filesystem-safe `yyyy-MM-dd HH-mm-ss` timestamp (fixed POSIX locale) + /// used to build fallback stems when a source has no name of its own. + private func timestampedName(date: Date) -> String { + Self.timestampFormatter.string(from: date) + } + + /// Strips path separators and NUL, caps length, and treats an empty result + /// as "no usable name" (nil) so the caller falls back to a generated stem. + private func sanitize(_ name: String) -> String? { + let cleaned = + name + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "\u{0}", with: "") + let trimmed = String(cleaned.prefix(256)) + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/Modules/Sources/WordPressMediaLibrary/Upload/UploadSourceMaterializer.swift b/Modules/Sources/WordPressMediaLibrary/Upload/UploadSourceMaterializer.swift new file mode 100644 index 000000000000..d30fd5af84cb --- /dev/null +++ b/Modules/Sources/WordPressMediaLibrary/Upload/UploadSourceMaterializer.swift @@ -0,0 +1,924 @@ +import AVFoundation +import Foundation +import ImageIO +import UIKit +import UniformTypeIdentifiers +import WordPressAPI +import os + +struct MaterializedUpload: Sendable { + let tempFileURL: URL + let params: MediaCreateParams + let kind: MediaKind + + /// The uploaded file's basename, shown in the Uploads UI. + var displayName: String { + tempFileURL.lastPathComponent + } + + /// The per-upload directory that owns `tempFileURL`. The materializer + /// stages every upload in its own dedicated directory so consumers can + /// dispose of an upload's on-disk footprint by deleting this directory, + /// without knowing the layout inside it. Delete this (never a parent of + /// it) when the upload is finished, cancelled, or dismissed. + var stagingDirectory: URL { + tempFileURL.deletingLastPathComponent() + } +} + +final class UploadSourceMaterializer: Sendable { + private let policy: MediaUploadPolicy + private let temporaryRoot: URL + private let filenames = UploadFilenameAllocator() + + /// Default staging root for materialized uploads. Rooted in Application + /// Support (not the system temp dir) so files survive app suspension and a + /// queued Retry can reuse them; iOS purges tmp under storage pressure while + /// the app is suspended. Excluded from backup, and swept of crash-orphaned + /// entries at launch via `sweepOrphanedStagingFiles()`. + static let defaultStagingDirectory: URL = { + let base = + (try? FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: false + )) ?? FileManager.default.temporaryDirectory + return base.appendingPathComponent("WordPressMediaLibrary-Uploads", isDirectory: true) + }() + + /// Deletes staged uploads under the default staging root that were created + /// before this process started. In-memory uploader state never survives + /// process termination, so any entry from a previous run was orphaned by a + /// crash or force-quit. Entries created by the current process are never + /// touched, which makes the sweep safe to run at any point after launch, + /// even concurrently with in-flight staging (the app schedules it on a + /// deferred background queue) or with parallel test materializations that + /// share the default root. + public static func sweepOrphanedStagingFiles() { + let processStart = Date(timeIntervalSinceNow: -ProcessInfo.processInfo.systemUptime) + sweepOrphanedStagingFiles(in: defaultStagingDirectory, createdBefore: processStart) + } + + /// Testable core of `sweepOrphanedStagingFiles()`. Entries with an + /// unreadable creation date are skipped, erring on the side of not + /// deleting. + static func sweepOrphanedStagingFiles(in root: URL, createdBefore cutoff: Date) { + guard + let contents = try? FileManager.default.contentsOfDirectory( + at: root, + includingPropertiesForKeys: [.creationDateKey] + ) + else { return } + for url in contents { + guard + let creationDate = try? url.resourceValues(forKeys: [.creationDateKey]).creationDate, + creationDate < cutoff + else { continue } + try? FileManager.default.removeItem(at: url) + } + } + + init( + policy: MediaUploadPolicy, + temporaryRoot: URL = UploadSourceMaterializer.defaultStagingDirectory + ) { + self.policy = policy + self.temporaryRoot = temporaryRoot + } + + func materialize( + source: UploadSource, + into stageProgress: Progress + ) async throws -> MaterializedUpload { + try ensureStagingRoot() + let parentDir = temporaryRoot.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: parentDir, withIntermediateDirectories: true) + + do { + let result: MaterializedUpload + switch source { + case .photoLibrary(let itemProvider, let suggestedName, let hint): + result = try await materializePhotoLibrary( + parentDir: parentDir, + itemProvider: itemProvider, + suggestedName: suggestedName, + hint: hint, + stageProgress: stageProgress + ) + case .cameraImage(let image, let capturedAt): + result = try materializeCameraImage( + parentDir: parentDir, + image: image, + capturedAt: capturedAt + ) + case .cameraVideo(let url, let capturedAt): + result = try await materializeCameraVideo( + parentDir: parentDir, + sourceURL: url, + capturedAt: capturedAt, + stageProgress: stageProgress + ) + case .file(let url): + result = try await materializeFile( + parentDir: parentDir, + sourceURL: url, + stageProgress: stageProgress + ) + case .remoteURL(let remote): + result = try await materializeRemoteURL( + parentDir: parentDir, + remote: remote, + stageProgress: stageProgress + ) + case .imagePlayground(let url, let suggestedName): + result = try materializeImagePlayground( + parentDir: parentDir, + sourceURL: url, + suggestedName: suggestedName + ) + } + // Ensure the stage child reaches its total even when sub-methods + // didn't report fine-grained progress (image sub-methods snap + // 0→100 here; video sub-methods set it themselves after polling). + stageProgress.completedUnitCount = stageProgress.totalUnitCount + return result + } catch { + try? FileManager.default.removeItem(at: parentDir) + throw error + } + } + + // MARK: - Photo Library + + private func materializePhotoLibrary( + parentDir: URL, + itemProvider: NSItemProvider, + suggestedName: String?, + hint: UTType, + stageProgress: Progress + ) async throws -> MaterializedUpload { + if hint.conforms(to: .movie) { + return try await materializePhotoLibraryVideo( + parentDir: parentDir, + itemProvider: itemProvider, + suggestedName: suggestedName, + hint: hint, + stageProgress: stageProgress + ) + } + + if Self.rawPassthroughImageTypes.contains(hint) { + return try await materializePhotoLibraryPassthrough( + parentDir: parentDir, + itemProvider: itemProvider, + suggestedName: suggestedName, + type: hint + ) + } + + let data = try await loadDataRepresentation(itemProvider: itemProvider, hint: hint) + try Task.checkCancellation() + return try finalizeImage( + data: data, + declaredType: hint, + stem: filenames.stem(preferred: suggestedName, fallbackPrefix: "Photo", date: Date()), + parentDir: parentDir + ) + } + + /// PHPicker video path. `loadFileRepresentation` hands us a + /// provider-owned URL valid ONLY during the callback. Copy bytes into + /// our parentDir inside the callback, then run the export from the + /// owned copy. + private func materializePhotoLibraryVideo( + parentDir: URL, + itemProvider: NSItemProvider, + suggestedName: String?, + hint: UTType, + stageProgress: Progress + ) async throws -> MaterializedUpload { + let copiedSource = try await loadFileRepresentation( + from: itemProvider, + typeIdentifier: hint.identifier + ) { providerURL in + let sourceExt = + providerURL.pathExtension.isEmpty + ? (hint.preferredFilenameExtension ?? "mov") + : providerURL.pathExtension + let dest = parentDir.appendingPathComponent("source.\(sourceExt)") + try FileManager.default.copyItem(at: providerURL, to: dest) + return dest + } + try Task.checkCancellation() + + defer { try? FileManager.default.removeItem(at: copiedSource) } + + return try await finalizeVideo( + asset: AVURLAsset(url: copiedSource), + stem: filenames.stem(preferred: suggestedName, fallbackPrefix: "Video", date: Date()), + parentDir: parentDir, + stageProgress: stageProgress + ) + } + + /// PHPicker raw-passthrough path for `rawPassthroughImageTypes`: bytes + /// copy through the provider untouched, matching V1's + /// `ItemProviderMediaExporter.processGIF` and V2's `.file` branch for the + /// same types. + private func materializePhotoLibraryPassthrough( + parentDir: URL, + itemProvider: NSItemProvider, + suggestedName: String?, + type: UTType + ) async throws -> MaterializedUpload { + let ext = type.preferredFilenameExtension ?? "bin" + guard policy.isAllowedForUpload(type, ext) else { + throw MaterializerError.disallowedContentType + } + + let stem = filenames.stem(preferred: suggestedName, fallbackPrefix: "Photo", date: Date()) + let basename = filenames.basename(stem: stem, ext: ext) + let destURL = parentDir.appendingPathComponent(basename) + + _ = try await loadFileRepresentation( + from: itemProvider, + typeIdentifier: type.identifier + ) { providerURL in + try FileManager.default.copyItem(at: providerURL, to: destURL) + return destURL + } + try Task.checkCancellation() + + return MaterializedUpload( + tempFileURL: destURL, + params: MediaCreateParams(filePath: destURL.path), + kind: .image + ) + } + + // MARK: - Camera + + private func materializeCameraImage( + parentDir: URL, + image: UIImage, + capturedAt: Date + ) throws -> MaterializedUpload { + guard + let jpegData = downscaledForPolicy(image) + .jpegData(compressionQuality: CGFloat(policy.imageJpegQuality)) + else { + throw MaterializerError.imageEncodeFailed + } + return try finalizeImage( + data: jpegData, + declaredType: .jpeg, + stem: filenames.stem(preferred: nil, fallbackPrefix: "IMG", date: capturedAt), + parentDir: parentDir + ) + } + + /// Downscales the in-memory capture to the policy's max dimension BEFORE + /// the JPEG encode, so a capped capture is encoded exactly once (the + /// shared image tail then sees an in-cap image and passes the bytes + /// through). Purely an optimization: if the downscale fails or lands + /// over the cap, `finalizeImage` resizes as usual. + private func downscaledForPolicy(_ image: UIImage) -> UIImage { + guard let cap = policy.imageMaxDimension, cap > 0 else { return image } + let pixelLongEdge = max(image.size.width, image.size.height) * image.scale + guard pixelLongEdge > CGFloat(cap) else { return image } + let ratio = CGFloat(cap) / pixelLongEdge + let targetPixels = CGSize( + width: (image.size.width * image.scale * ratio).rounded(), + height: (image.size.height * image.scale * ratio).rounded() + ) + // preparingThumbnail takes a pixel size and returns an upright + // (.up-oriented) image, so the later jpegData carries no stale + // orientation tag. + return image.preparingThumbnail(of: targetPixels) ?? image + } + + private func materializeCameraVideo( + parentDir: URL, + sourceURL: URL, + capturedAt: Date, + stageProgress: Progress + ) async throws -> MaterializedUpload { + try await finalizeVideo( + asset: AVURLAsset(url: sourceURL), + stem: filenames.stem(preferred: nil, fallbackPrefix: "IMG", date: capturedAt), + parentDir: parentDir, + stageProgress: stageProgress + ) + } + + /// Re-exports `asset` to `destURL`, driving `stageProgress` from a sibling + /// poll of `session.progress`. + /// + /// `export(to:as:isolation:)` is `@backDeployed` to iOS 13, so the export + /// runs structured even on the iOS 17 floor: it sets the output URL / file + /// type itself, observes `Task` cancellation natively, and throws on + /// failure instead of reporting through a callback. Progress still uses the + /// legacy `session.progress` property because the modern + /// `states(updateInterval:)` AsyncSequence is iOS 18+ and not back-deployed. + private func exportVideo( + asset: AVURLAsset, + to destURL: URL, + outputType: AVFileType, + stageProgress: Progress + ) async throws { + guard + let exportSession = AVAssetExportSession( + asset: asset, + presetName: policy.videoExportPreset + ) + else { + throw MaterializerError.videoExportSessionUnavailable + } + exportSession.shouldOptimizeForNetworkUse = true + if policy.stripGPSLocation { + // `stripGPSLocation` is the single "Remove Location" setting and + // governs video too. `forSharing()` drops the QuickTime location + // atom (and other identifying metadata), matching V1 + // MediaVideoExporter. + exportSession.metadataItemFilter = AVMetadataItemFilter.forSharing() + } + + // `AVAssetExportSession` isn't `Sendable`, but the poll task only reads + // `.progress`, which is safe to sample off the originating actor. + nonisolated(unsafe) let session = exportSession + let pollTask = Task { [stageProgress] in + while !Task.isCancelled { + stageProgress.completedUnitCount = Int64( + (Double(stageProgress.totalUnitCount) * Double(session.progress)).rounded() + ) + try? await Task.sleep(for: .milliseconds(100)) + } + } + defer { pollTask.cancel() } + + do { + try await exportSession.export(to: destURL, as: outputType) + } catch { + // Let cancellation propagate untouched so the uploader treats it as + // a cancel rather than a failure; wrap everything else. + if error is CancellationError { throw error } + throw MaterializerError.videoExportFailed(underlyingError: error) + } + + // Snap stageProgress to full — the final poll may have been just shy + // of 1.0 when export returned. + stageProgress.completedUnitCount = stageProgress.totalUnitCount + } + + // MARK: - File + + private func materializeFile( + parentDir: URL, + sourceURL: URL, + stageProgress: Progress + ) async throws -> MaterializedUpload { + guard sourceURL.startAccessingSecurityScopedResource() else { + throw MaterializerError.securityScopedAccessDenied + } + defer { sourceURL.stopAccessingSecurityScopedResource() } + + let contentType = try resolveContentType(of: sourceURL) + let stem = filenames.stem( + preferred: sourceURL.deletingPathExtension().lastPathComponent, + fallbackPrefix: "File", + date: Date() + ) + + // Raw-passthrough types (GIF, SVG) must not be re-encoded, so they + // raw-copy like any other passthrough file. Every other image goes + // through the shared image tail. + if contentType.conforms(to: .image), !Self.rawPassthroughImageTypes.contains(contentType) { + return try materializeFileImage( + parentDir: parentDir, + sourceURL: sourceURL, + contentType: contentType, + stem: stem + ) + } + if contentType.conforms(to: .movie) { + return try await materializeFileVideo( + parentDir: parentDir, + sourceURL: sourceURL, + stem: stem, + stageProgress: stageProgress + ) + } + return try materializeFileRawCopy( + parentDir: parentDir, + sourceURL: sourceURL, + contentType: contentType, + stem: stem + ) + } + + private func materializeFileImage( + parentDir: URL, + sourceURL: URL, + contentType: UTType, + stem: String + ) throws -> MaterializedUpload { + let data = try Data(contentsOf: sourceURL) + return try finalizeImage( + data: data, + declaredType: contentType, + stem: stem, + parentDir: parentDir + ) + } + + /// Image Playground returns a local file URL inside our own sandbox — no + /// security-scoped access required. Resolves the URL's UTType with a + /// defensive `.heic` fallback (matches V1 `MediaPickerMenu+ImagePlayground` + /// behavior) and dispatches to the existing image policy path. + private func materializeImagePlayground( + parentDir: URL, + sourceURL: URL, + suggestedName: String + ) throws -> MaterializedUpload { + // V1 fallback (MediaPickerMenu+ImagePlayground.swift:46-55). + let resolvedType = sourceURL.resolvedContentType ?? .heic + + return try materializeFileImage( + parentDir: parentDir, + sourceURL: sourceURL, + contentType: resolvedType, + stem: suggestedName + ) + } + + private func materializeFileVideo( + parentDir: URL, + sourceURL: URL, + stem: String, + stageProgress: Progress + ) async throws -> MaterializedUpload { + try await finalizeVideo( + asset: AVURLAsset(url: sourceURL), + stem: stem, + parentDir: parentDir, + stageProgress: stageProgress + ) + } + + private func materializeFileRawCopy( + parentDir: URL, + sourceURL: URL, + contentType: UTType, + stem: String + ) throws -> MaterializedUpload { + let ext = sourceURL.pathExtension.isEmpty ? "bin" : sourceURL.pathExtension + guard policy.isAllowedForUpload(contentType, ext) else { + throw MaterializerError.disallowedContentType + } + let basename = filenames.basename(stem: stem, ext: ext) + let destURL = parentDir.appendingPathComponent(basename) + try FileManager.default.copyItem(at: sourceURL, to: destURL) + return MaterializedUpload( + tempFileURL: destURL, + params: MediaCreateParams(filePath: destURL.path), + kind: MediaKind(estimating: contentType) + ) + } + + // MARK: - Remote URL (post-download dispatch) + + /// `.remoteURL` = download phase (RemoteDownloader writes bytes into + /// `parentDir`) then dispatch phase (`dispatchRemoteDownload`). `parentDir` + /// is created and cleaned up by `materialize`, so neither phase manages it. + private func materializeRemoteURL( + parentDir: URL, + remote: UploadSource.RemoteURL, + stageProgress: Progress + ) async throws -> MaterializedUpload { + // TODO: failed `.remoteURL` materialization is currently + // Dismiss-only (FailedUpload.isRetryable: materialized != nil). + // Retaining the original UploadSource on failed entries would + // let Retry re-run materialization for this case (and every + // other materialization-failing source). + let downloader = RemoteDownloader() + let localFile = try await downloader.download( + from: remote.url, + into: parentDir, + progress: stageProgress + ) + return try await dispatchRemoteDownload( + localFile: localFile, + contentType: remote.contentType, + suggestedName: remote.suggestedName, + caption: remote.caption, + parentDir: parentDir + ) + } + + /// Post-download dispatch for `.remoteURL`. Takes a local file (already + /// downloaded into `parentDir`), the declared content type, the sanitized + /// suggested-name stem, and an optional caption. Assumes `parentDir` + /// exists — `materialize` owns its creation and cleanup. Branches: + /// raw-passthrough types (GIF, SVG) → move bytes to `.` + /// untouched; any other image → the shared image tail (which validates + /// the bytes, so an HTML error body served as image/jpeg is rejected); + /// anything else → MaterializerError.disallowedContentType. + /// + /// Kept module-internal as a test seam so the post-download dispatch can be + /// exercised without a live network download. + func dispatchRemoteDownload( + localFile: URL, + contentType: UTType, + suggestedName: String, + caption: String?, + parentDir: URL + ) async throws -> MaterializedUpload { + if Self.rawPassthroughImageTypes.contains(contentType) { + return try materializeRemotePassthrough( + localFile: localFile, + type: contentType, + stem: suggestedName, + caption: caption, + parentDir: parentDir + ) + } else if contentType.conforms(to: .image) { + let data = try Data(contentsOf: localFile) + return try finalizeImage( + data: data, + declaredType: contentType, + stem: suggestedName, + caption: caption, + parentDir: parentDir + ) + } else { + throw MaterializerError.disallowedContentType + } + } + + /// Raw passthrough for remote GIF/SVG: moves the downloaded bytes to + /// `.` in our owned parentDir, ignoring whatever extension + /// URLSession's temp file had. GIF mirrors V1's + /// MediaExternalExporter.swift:63-74; SVG is new in V2 (V1 rasterized + /// it). The bytes are not validated — these types can't round-trip + /// through ImageIO, so the server allow-list (via the policy check) is + /// the gate, same as the local `.file` path. + private func materializeRemotePassthrough( + localFile: URL, + type: UTType, + stem: String, + caption: String?, + parentDir: URL + ) throws -> MaterializedUpload { + let ext = type.preferredFilenameExtension ?? "bin" + guard policy.isAllowedForUpload(type, ext) else { + throw MaterializerError.disallowedContentType + } + let basename = filenames.basename(stem: stem, ext: ext) + let destURL = parentDir.appendingPathComponent(basename) + try FileManager.default.moveItem(at: localFile, to: destURL) + return MaterializedUpload( + tempFileURL: destURL, + params: MediaCreateParams(caption: caption, filePath: destURL.path), + kind: .image + ) + } + + // MARK: - Shared finalize + + /// The set of transforms the single-pass image write must apply, computed + /// once from the image header and the policy. + private struct ImageTransforms: OptionSet { + let rawValue: Int + + static let convert = ImageTransforms(rawValue: 1 << 0) + static let resize = ImageTransforms(rawValue: 1 << 1) + static let stripGPS = ImageTransforms(rawValue: 1 << 2) + } + + /// Shared image tail: header-validate, sniff the real content type, + /// allow-check, then apply every needed transform (JPEG conversion, + /// resize, GPS strip) in a single ImageIO pass and write into + /// `parentDir`. Callers supply only the declared type, name stem, and + /// optional caption. `parentDir` is created and cleaned up by the + /// `materialize` entry point. + /// + /// The bytes are decoded at most once and encoded at most once. When no + /// transform is needed the input bytes are written unchanged, so web-safe + /// in-cap images survive byte-for-byte. Any ImageIO failure throws — the + /// transform never falls back to the original bytes, so a required GPS + /// strip can never silently ship the location. + /// + /// Validation is header-level: a non-image body (e.g. an HTML error page + /// served as image/jpeg) is rejected, but a truncated image with an + /// intact header still passes, matching V1. + private func finalizeImage( + data: Data, + declaredType: UTType, + stem: String, + caption: String? = nil, + parentDir: URL + ) throws -> MaterializedUpload { + guard + let source = CGImageSourceCreateWithData(data as CFData, nil), + CGImageSourceGetCount(source) >= 1, + let props = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], + let width = props[kCGImagePropertyPixelWidth] as? Int, + let height = props[kCGImagePropertyPixelHeight] as? Int + else { + throw MaterializerError.invalidImageData + } + + // The sniffed container type is the truth; the declared type (picker + // hint, Content-Type header, file extension) can lie — a HEIC served + // as image/jpeg must still be converted. + let actualType = CGImageSourceGetType(source).flatMap { UTType($0 as String) } ?? declaredType + + var transforms: ImageTransforms = [] + var effectiveType = actualType + if !Self.webSafeImageTypes.contains(actualType), policy.convertHEICToJPEG { + effectiveType = .jpeg + transforms.insert(.convert) + } + if let cap = policy.imageMaxDimension, cap > 0, max(width, height) > cap { + transforms.insert(.resize) + } + if policy.stripGPSLocation, props[kCGImagePropertyGPSDictionary] != nil { + transforms.insert(.stripGPS) + } + // A transform re-encodes, and the re-encode target must be + // web-renderable and ImageIO-writable (e.g. an oversized DNG with + // HEIC conversion disabled still can't be written back as DNG). + if !transforms.isEmpty, !Self.webSafeImageTypes.contains(effectiveType) { + effectiveType = .jpeg + } + + let ext = + effectiveType.preferredFilenameExtension + ?? declaredType.preferredFilenameExtension ?? "bin" + guard policy.isAllowedForUpload(effectiveType, ext) else { + throw MaterializerError.disallowedContentType + } + + let output = + transforms.isEmpty + ? data + : try transformImage( + source: source, + sourceProperties: props, + to: effectiveType, + applying: transforms + ) + + let basename = filenames.basename(stem: stem, ext: ext) + let destURL = parentDir.appendingPathComponent(basename) + try output.write(to: destURL) + return MaterializedUpload( + tempFileURL: destURL, + params: MediaCreateParams(caption: caption, filePath: destURL.path), + kind: .image + ) + } + + /// Single ImageIO write applying `transforms`: exactly one decode and one + /// encode. Three strategies, chosen to preserve the EXIF orientation tag + /// whenever pixels are not resampled (a dropped tag once shipped rotated + /// HEIC uploads): + /// - resize: thumbnail with the rotation baked into the pixels, the tag + /// stamped upright, and the remaining metadata carried over — the same + /// treatment as V1 `MediaImageExporter.ImageSourceWriter`. + /// - GPS strip without resize: unrotated pixel decode with the source + /// properties (minus GPS) re-attached; the tag rides along in the + /// properties, staying paired with the unrotated pixels. + /// - conversion only: `CGImageDestinationAddImageFromSource`, which + /// carries pixels and metadata across the container change untouched. + private func transformImage( + source: CGImageSource, + sourceProperties: [CFString: Any], + to effectiveType: UTType, + applying transforms: ImageTransforms + ) throws -> Data { + let out = NSMutableData() + guard + let dst = CGImageDestinationCreateWithData( + out, + effectiveType.identifier as CFString, + 1, + nil + ) + else { + throw MaterializerError.imageEncodeFailed + } + + if transforms.contains(.resize) { + let thumbnailOptions: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceShouldCacheImmediately: true, + kCGImageSourceThumbnailMaxPixelSize: policy.imageMaxDimension ?? 0 + ] + guard + let thumb = CGImageSourceCreateThumbnailAtIndex( + source, + 0, + thumbnailOptions as CFDictionary + ) + else { + throw MaterializerError.imageEncodeFailed + } + var props = sourceProperties + if transforms.contains(.stripGPS) { + props.removeValue(forKey: kCGImagePropertyGPSDictionary) + } + // The thumbnail baked the EXIF rotation into the pixels, so stamp + // the orientation upright everywhere it lives (V1 parity) and drop + // the stale pixel-dimension records; the destination writes the + // real dimensions itself. + props[kCGImagePropertyOrientation] = CGImagePropertyOrientation.up.rawValue + if var tiff = props[kCGImagePropertyTIFFDictionary] as? [CFString: Any] { + tiff.removeValue(forKey: kCGImagePropertyTIFFOrientation) + props[kCGImagePropertyTIFFDictionary] = tiff + } + if var iptc = props[kCGImagePropertyIPTCDictionary] as? [CFString: Any] { + iptc.removeValue(forKey: kCGImagePropertyIPTCImageOrientation) + props[kCGImagePropertyIPTCDictionary] = iptc + } + if var exif = props[kCGImagePropertyExifDictionary] as? [CFString: Any] { + exif.removeValue(forKey: kCGImagePropertyExifPixelXDimension) + exif.removeValue(forKey: kCGImagePropertyExifPixelYDimension) + props[kCGImagePropertyExifDictionary] = exif + } + props.removeValue(forKey: kCGImagePropertyPixelWidth) + props.removeValue(forKey: kCGImagePropertyPixelHeight) + props[kCGImageDestinationLossyCompressionQuality] = policy.imageJpegQuality + CGImageDestinationAddImage(dst, thumb, props as CFDictionary) + } else if transforms.contains(.stripGPS) { + guard let image = CGImageSourceCreateImageAtIndex(source, 0, nil) else { + throw MaterializerError.locationStripFailed + } + var props = sourceProperties + props.removeValue(forKey: kCGImagePropertyGPSDictionary) + props[kCGImageDestinationLossyCompressionQuality] = policy.imageJpegQuality + CGImageDestinationAddImage(dst, image, props as CFDictionary) + } else { + // Copy the image straight from source to destination. ImageIO + // carries the orientation tag (and other metadata) across the + // container change, so the stored pixels stay paired with their + // orientation. Decoding to a bare CGImage and re-adding it would + // drop the tag and render a non-upright source (e.g. a + // 180°-oriented HEIC) upside down. + CGImageDestinationAddImageFromSource( + dst, + source, + 0, + [kCGImageDestinationLossyCompressionQuality: policy.imageJpegQuality] + as CFDictionary + ) + } + + guard CGImageDestinationFinalize(dst) else { + throw transforms.contains(.stripGPS) + ? MaterializerError.locationStripFailed + : MaterializerError.imageEncodeFailed + } + return out as Data + } + + /// Shared video tail: duration cap, allow-check, then re-export `asset` + /// into `parentDir`. The output container and extension come from the + /// policy; callers supply only the asset and the name stem. + private func finalizeVideo( + asset: AVURLAsset, + stem: String, + parentDir: URL, + stageProgress: Progress + ) async throws -> MaterializedUpload { + let duration = try await asset.load(.duration).seconds + if let cap = policy.videoMaxDurationSeconds, duration > cap { + throw MaterializerError.durationCapExceeded + } + let outputType = AVFileType(rawValue: policy.videoOutputContentType.identifier) + let ext = policy.videoOutputContentType.preferredFilenameExtension ?? "mp4" + guard policy.isAllowedForUpload(policy.videoOutputContentType, ext) else { + throw MaterializerError.disallowedContentType + } + let basename = filenames.basename(stem: stem, ext: ext) + let destURL = parentDir.appendingPathComponent(basename) + try await exportVideo( + asset: asset, + to: destURL, + outputType: outputType, + stageProgress: stageProgress + ) + return MaterializedUpload( + tempFileURL: destURL, + params: MediaCreateParams(filePath: destURL.path), + kind: .video + ) + } + + // MARK: - Helpers + + /// Creates the staging root (once) and excludes it from backup, so + /// materialized media never lands in the user's iCloud/device backup. + private func ensureStagingRoot() throws { + guard !FileManager.default.fileExists(atPath: temporaryRoot.path) else { return } + try FileManager.default.createDirectory(at: temporaryRoot, withIntermediateDirectories: true) + var root = temporaryRoot + var values = URLResourceValues() + values.isExcludedFromBackup = true + try? root.setResourceValues(values) + } + + private func loadDataRepresentation( + itemProvider: NSItemProvider, + hint: UTType + ) async throws -> Data { + try await providerLoad { completion in + itemProvider.loadDataRepresentation( + forTypeIdentifier: hint.identifier, + completionHandler: completion + ) + } + } + + /// Loads a provider file representation, invoking `body` with the + /// provider-owned URL *inside* the completion (that URL is valid only + /// there). + private func loadFileRepresentation( + from itemProvider: NSItemProvider, + typeIdentifier: String, + _ body: @escaping @Sendable (URL) throws -> T + ) async throws -> T { + try await providerLoad { completion in + itemProvider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { url, err in + guard let url, err == nil else { + completion(nil, err) + return + } + do { + completion(try body(url), nil) + } catch { + completion(nil, error) + } + } + } + } + + /// Bridges an `NSItemProvider` load API to async. `start` receives a + /// completion to hand to the provider and returns the load's `Progress`. + /// A `nil` value with a `nil` error surfaces as `fileNotFound`. Wrapped in + /// a cancellation handler that cancels the provider load, so tapping + /// Cancel during a large iCloud fetch stops the download instead of + /// running it to completion and discarding the result. + private func providerLoad( + _ start: (@escaping @Sendable (T?, Error?) -> Void) -> Progress + ) async throws -> T { + let box = ProgressBox() + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + let progress = start { value, err in + if let err { + cont.resume(throwing: err) + } else if let value { + cont.resume(returning: value) + } else { + cont.resume(throwing: MaterializerError.fileNotFound) + } + } + box.set(progress) + } + } onCancel: { + box.cancel() + } + } + + /// Image types that must never round-trip through ImageIO: re-encoding + /// would flatten GIF animation and rasterize SVG vectors. Every source + /// branch routes these to a raw byte copy instead of the image tail. + /// (Animated WebP and APNG are not exempt: WebP converts to a JPEG still + /// and an oversized APNG resizes to a single frame, matching V1.) + private static let rawPassthroughImageTypes: Set = [.gif, .svg] + + /// Raster types that upload without format conversion. Everything else + /// (HEIC, HEIF, TIFF, WebP, BMP, DNG, ...) is converted to JPEG, matching + /// V1 `ItemProviderMediaExporter.supportedImageTypes`. GIF and SVG never + /// reach the image tail — they are `rawPassthroughImageTypes`. + private static let webSafeImageTypes: Set = [.png, .jpeg] + + private func resolveContentType(of url: URL) throws -> UTType { + guard let type = url.resolvedContentType else { + throw MaterializerError.unknownContentType + } + return type + } +} + +/// Thread-safe holder so a task-cancellation handler can cancel a provider +/// load's `Progress`, which is produced synchronously right after the +/// continuation body starts running. +private final class ProgressBox: @unchecked Sendable { + private let stored = OSAllocatedUnfairLock(initialState: nil) + func set(_ value: Progress) { stored.withLock { $0 = value } } + func cancel() { stored.withLock { $0?.cancel() } } +} diff --git a/Modules/Tests/WordPressMediaLibraryTests/UploadFilenameAllocatorTests.swift b/Modules/Tests/WordPressMediaLibraryTests/UploadFilenameAllocatorTests.swift new file mode 100644 index 000000000000..0e0103a73cbc --- /dev/null +++ b/Modules/Tests/WordPressMediaLibraryTests/UploadFilenameAllocatorTests.swift @@ -0,0 +1,82 @@ +import Foundation +import Testing + +@testable import WordPressMediaLibrary + +@Suite("UploadFilenameAllocator") +struct UploadFilenameAllocatorTests { + // Suites are instantiated per test, so each test gets a fresh allocator. + private let allocator = UploadFilenameAllocator() + + // MARK: - basename + + @Test("first use of a name returns . unchanged") + func freshNameIsVerbatim() { + #expect(allocator.basename(stem: "photo", ext: "jpg") == "photo.jpg") + } + + @Test("repeated names get sequential numeric suffixes") + func repeatsAreSuffixed() { + #expect(allocator.basename(stem: "photo", ext: "jpg") == "photo.jpg") + #expect(allocator.basename(stem: "photo", ext: "jpg") == "photo (2).jpg") + #expect(allocator.basename(stem: "photo", ext: "jpg") == "photo (3).jpg") + } + + @Test("the same stem with a different extension does not collide") + func differentExtensionDoesNotCollide() { + #expect(allocator.basename(stem: "photo", ext: "jpg") == "photo.jpg") + #expect(allocator.basename(stem: "photo", ext: "png") == "photo.png") + } + + @Test("different stems do not collide") + func differentStemsDoNotCollide() { + #expect(allocator.basename(stem: "a", ext: "jpg") == "a.jpg") + #expect(allocator.basename(stem: "b", ext: "jpg") == "b.jpg") + } + + @Test("deduplication state is scoped to a single allocator instance") + func dedupIsPerInstance() { + #expect(UploadFilenameAllocator().basename(stem: "photo", ext: "jpg") == "photo.jpg") + // A separate allocator starts with a clean slate, so no suffix. + #expect(UploadFilenameAllocator().basename(stem: "photo", ext: "jpg") == "photo.jpg") + } + + // MARK: - stem derivation + + @Test( + "preferred names sanitize to filesystem-safe stems", + arguments: [ + ("My Vacation", "My Vacation"), // usable names are kept verbatim + ("a/b/c", "a_b_c"), // path separators become underscores + ("///", "___"), // separator-only names sanitize, not fall back + ("a\u{0}b", "ab"), // NUL characters are stripped + (String(repeating: "a", count: 300), String(repeating: "a", count: 256)) // capped at 256 + ] + ) + func preferredNameSanitization(preferred: String, expected: String) { + #expect(allocator.stem(preferred: preferred, fallbackPrefix: "Photo", date: Date()) == expected) + } + + @Test("a nil preferred name falls back to a filesystem-safe -") + func nilFallsBackToPrefixTimestamp() { + let stem = allocator.stem(preferred: nil, fallbackPrefix: "Photo", date: Date()) + // Digits, dashes, and a space only, so the generated timestamp is + // filesystem-safe (no ":" or "/") by construction. + let pattern = #"^Photo-\d{4}-\d{2}-\d{2} \d{2}-\d{2}-\d{2}$"# + #expect(stem.range(of: pattern, options: .regularExpression) != nil) + } + + @Test("an empty preferred name falls back to the prefix") + func emptyFallsBackToPrefix() { + let stem = allocator.stem(preferred: "", fallbackPrefix: "Video", date: Date()) + #expect(stem.hasPrefix("Video-")) + } + + // MARK: - end-to-end + + @Test("a source name flows through to a complete upload basename") + func sourceNameBecomesBasename() { + let stem = allocator.stem(preferred: "Quarterly Report", fallbackPrefix: "File", date: Date()) + #expect(allocator.basename(stem: stem, ext: "pdf") == "Quarterly Report.pdf") + } +} diff --git a/Modules/Tests/WordPressMediaLibraryTests/UploadSourceMaterializerTests.swift b/Modules/Tests/WordPressMediaLibraryTests/UploadSourceMaterializerTests.swift new file mode 100644 index 000000000000..43add0cd4abc --- /dev/null +++ b/Modules/Tests/WordPressMediaLibraryTests/UploadSourceMaterializerTests.swift @@ -0,0 +1,850 @@ +import AVFoundation +import Foundation +import Testing +import UIKit +import UniformTypeIdentifiers +@testable import WordPressMediaLibrary + +/// Fresh stage progress for materializer tests that don't care about +/// the value — matches what the actor would allocate in production. +private func stage() -> Progress { Progress(totalUnitCount: 100) } + +/// Expects `body` to throw the given `MaterializerError` case. Compares by +/// case name, so only use it with payload-free cases. +private func expectThrowsCase( + _ expected: MaterializerError, + sourceLocation: SourceLocation = #_sourceLocation, + performing body: () async throws -> R +) async { + let error = await #expect(throws: MaterializerError.self, sourceLocation: sourceLocation) { + _ = try await body() + } + guard let error else { return } // the #expect above already recorded the failure + #expect( + String(describing: error) == String(describing: expected), + sourceLocation: sourceLocation + ) +} + +@Suite("UploadSourceMaterializer") +final class UploadSourceMaterializerTests { + /// Per-test root for fixtures and staging output, so tests never write + /// into the production staging directory and need no per-result cleanup. + private let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + + deinit { + try? FileManager.default.removeItem(at: root) + } + + private func policy( + allow: @escaping @Sendable (UTType, String) -> Bool = { _, _ in true }, + imageMaxDimension: Int? = nil, + videoMaxDurationSeconds: TimeInterval? = nil, + stripGPSLocation: Bool = false + ) -> MediaUploadPolicy { + MediaUploadPolicy( + filePickerContentTypes: [.content], + isAllowedForUpload: allow, + imageMaxDimension: imageMaxDimension, + imageJpegQuality: 0.9, + convertHEICToJPEG: true, + videoMaxDurationSeconds: videoMaxDurationSeconds, + videoExportPreset: AVAssetExportPresetMediumQuality, + videoOutputContentType: .mpeg4Movie, + stripGPSLocation: stripGPSLocation + ) + } + + @Test("file source: validator rejection surfaces disallowedContentType") + func validatorRejectsDocument() async throws { + let tempURL = try createTempPDF() + let m = makeMaterializer(policy(allow: { _, _ in false })) + await expectThrowsCase(.disallowedContentType) { + try await m.materialize(source: .file(tempURL), into: stage()) + } + } + + @Test("file source preserves original basename verbatim") + func fileSourcePreservesBasename() async throws { + let tempURL = try createTempPDF(name: "Quarterly Report.pdf") + let m = makeMaterializer(policy()) + let result = try await m.materialize(source: .file(tempURL), into: stage()) + #expect(result.displayName == "Quarterly Report.pdf") + } + + @Test("materialization failures remove their parent temp directory") + func failureRemovesTempDir() async throws { + let tempURL = try createTempPDF() + let inspectableRoot = try makeTempDir() + + let m = makeMaterializer(policy(allow: { _, _ in false }), temporaryRoot: inspectableRoot) + await expectThrowsCase(.disallowedContentType) { + try await m.materialize(source: .file(tempURL), into: stage()) + } + + let contents = try FileManager.default.contentsOfDirectory( + at: inspectableRoot, + includingPropertiesForKeys: nil + ) + #expect(contents.isEmpty) + } + + @Test("camera image yields a timestamped IMG JPEG") + func cameraImageBasename() async throws { + let image = makeSolidColorImage(size: CGSize(width: 10, height: 10), color: .red) + + let m = makeMaterializer(policy()) + let result = try await m.materialize(source: .cameraImage(image, capturedAt: Date()), into: stage()) + + #expect(result.displayName.hasPrefix("IMG")) + #expect(result.displayName.hasSuffix(".jpeg")) + #expect(result.kind == .image) + #expect(FileManager.default.fileExists(atPath: result.tempFileURL.path)) + } + + @Test(".file JPEG with GPS: stripGPSLocation removes GPS") + func fileJPEGStripsGPS() async throws { + // Build a JPEG with a synthetic GPS dict embedded and run it through + // the file-source path, which exercises stripGPS on raw JPEG bytes. + let sourceURL = try writeTempFixture(makeJPEGWithGPSAndDate(), ext: "jpg") + + let m = makeMaterializer(policy(stripGPSLocation: true)) + let result = try await m.materialize(source: .file(sourceURL), into: stage()) + + #expect(!imageHasGPS(result.tempFileURL)) + } + + @Test("camera video over duration is rejected") + func cameraVideoOverDuration() async throws { + let videoURL = try await sharedBlankVideoTask.value + let m = makeMaterializer(policy(videoMaxDurationSeconds: 0.5)) + await expectThrowsCase(.durationCapExceeded) { + try await m.materialize(source: .cameraVideo(videoURL, capturedAt: Date()), into: stage()) + } + } + + @Test("camera video exports to .mp4 by default") + func cameraVideoExportsToMP4() async throws { + let videoURL = try await sharedBlankVideoTask.value + let m = makeMaterializer(policy()) + let result = try await m.materialize(source: .cameraVideo(videoURL, capturedAt: Date()), into: stage()) + + #expect(result.displayName.hasSuffix(".mp4")) + #expect(result.kind == .video) + #expect(FileManager.default.fileExists(atPath: result.tempFileURL.path)) + } + + @Test(".file HEIC under policy that rejects HEIC but allows JPEG succeeds") + func fileHEICConvertedToJPEG() async throws { + let sourceURL = try writeTempFixture(makeSyntheticHEIC(), ext: "heic") + + let m = makeMaterializer( + policy(allow: { type, ext in + // Reject HEIC, allow JPEG + !(type == .heic || ext == "heic") + }) + ) + let result = try await m.materialize(source: .file(sourceURL), into: stage()) + + #expect(result.displayName.hasSuffix(".jpeg")) + #expect(imageType(of: result.tempFileURL) == .jpeg) + #expect(result.kind == .image) + } + + /// Regression: HEIC→JPEG conversion decodes the raw stored pixels without + /// applying the EXIF transform, so the orientation tag must survive the + /// conversion. Dropping it left a 180°-oriented HEIC rendering upside down. + @Test("HEIC→JPEG conversion preserves EXIF orientation") + func heicToJPEGPreservesOrientation() async throws { + let heicData = try makeSyntheticHEIC(orientation: .down) // 3 == 180° + let sourceURL = try writeTempFixture(heicData, ext: "heic") + + let m = makeMaterializer(policy()) + let result = try await m.materialize(source: .file(sourceURL), into: stage()) + + let props = try imageProperties(of: result.tempFileURL) + let orientation = props[kCGImagePropertyOrientation] as? UInt32 + #expect(orientation == CGImagePropertyOrientation.down.rawValue) + } + + @Test(".file video with duration cap exceeded is rejected") + func fileVideoOverDuration() async throws { + let videoURL = try await sharedBlankVideoTask.value + let m = makeMaterializer(policy(videoMaxDurationSeconds: 0.01)) + await expectThrowsCase(.durationCapExceeded) { + try await m.materialize(source: .file(videoURL), into: stage()) + } + } + + @Test(".file video re-exports to .mp4") + func fileVideoReexportsToMP4() async throws { + let videoURL = try await sharedBlankVideoTask.value + let m = makeMaterializer(policy()) + let result = try await m.materialize(source: .file(videoURL), into: stage()) + + #expect(result.displayName.hasSuffix(".mp4")) + #expect(result.kind == .video) + } + + @Test(".file GIF passes through raw-copied") + func fileGIFRawCopy() async throws { + let gifURL = try createTempGIF() + + let m = makeMaterializer(policy()) + let result = try await m.materialize(source: .file(gifURL), into: stage()) + + #expect(result.displayName.hasSuffix(".gif")) + #expect(result.kind == .image) + // Byte-for-byte copy: any ImageIO round-trip would alter the bytes. + #expect(try Data(contentsOf: result.tempFileURL) == gifFixture) + } + + @Test(".file PDF passes through raw-copied") + func filePDFRawCopy() async throws { + let pdfURL = try createTempPDF() + + let m = makeMaterializer(policy()) + let result = try await m.materialize(source: .file(pdfURL), into: stage()) + + #expect(result.displayName.hasSuffix(".pdf")) + #expect(result.kind == .document) + } + + @Test(".photoLibrary GIF passes through raw-copied") + func photoLibraryGIFRawCopy() async throws { + let gifURL = try createTempGIF() + + // Resize + GPS-strip both enabled — the bug class this guards + // against is ImageIO flattening animated GIFs when either is on. + let m = makeMaterializer(policy(imageMaxDimension: 1024, stripGPSLocation: true)) + let result = try await m.materialize( + source: .photoLibrary( + itemProvider: makeGIFItemProvider(vendingFile: gifURL), + suggestedName: "Animation", + hint: .gif + ), + into: stage() + ) + + #expect(result.displayName.hasSuffix(".gif")) + #expect(result.kind == .image) + // Byte-for-byte copy: any ImageIO round-trip would alter the + // bytes even if the file size happened to match. + #expect(try Data(contentsOf: result.tempFileURL) == gifFixture) + } + + @Test(".photoLibrary GIF: validator rejection surfaces disallowedContentType") + func photoLibraryGIFRejected() async throws { + let gifURL = try createTempGIF() + + let m = makeMaterializer(policy(allow: { _, ext in ext != "gif" })) + await expectThrowsCase(.disallowedContentType) { + try await m.materialize( + source: .photoLibrary( + itemProvider: makeGIFItemProvider(vendingFile: gifURL), + suggestedName: "Animation", + hint: .gif + ), + into: stage() + ) + } + } + + @Test("camera image: imageMaxDimension resizes before GPS strip") + func cameraImageResized() async throws { + // 2000×2000 image, cap at 1024 — output pixel long edge must be <= 1024. + let image = makeSolidColorImage(size: CGSize(width: 2000, height: 2000), color: .blue) + + let m = makeMaterializer(policy(imageMaxDimension: 1024, stripGPSLocation: true)) + let result = try await m.materialize(source: .cameraImage(image, capturedAt: Date()), into: stage()) + + let props = try imageProperties(of: result.tempFileURL) + let width = try #require(props[kCGImagePropertyPixelWidth] as? Int) + let height = try #require(props[kCGImagePropertyPixelHeight] as? Int) + #expect(max(width, height) <= 1024) + } + + @Test("image already within imageMaxDimension is not re-encoded") + func imageWithinMaxDimensionPassesThrough() async throws { + // 50×50 JPEG under a 1024 cap: resizing would only re-encode it lossily + // at the same size, so the materialized bytes must equal the input. + let smallJPEG = try encodeImage( + makeSolidColorImage(size: CGSize(width: 50, height: 50), color: .red), + as: .jpeg + ) + let sourceURL = try writeTempFixture(smallJPEG, ext: "jpg") + + let m = makeMaterializer(policy(imageMaxDimension: 1024)) + let result = try await m.materialize(source: .file(sourceURL), into: stage()) + + // Byte-for-byte equality: any ImageIO re-encode would alter the bytes. + #expect(try Data(contentsOf: result.tempFileURL) == smallJPEG) + } + + @Test(".imagePlayground applies the image policy without security scoping") + func imagePlaygroundAppliesImagePolicy() async throws { + // Plant a HEIC where Image Playground would have written its output; + // the image policy must convert it like any other picked image. + let imageURL = try makeTempDir().appendingPathComponent("Generated.heic") + try makeSyntheticHEIC().write(to: imageURL) + + let result = try await makeMaterializer(policy()) + .materialize( + source: .imagePlayground(imageURL, suggestedName: "Generated"), + into: stage() + ) + + #expect(result.kind == .image) + #expect(result.displayName == "Generated.jpeg") + #expect(imageType(of: result.tempFileURL) == .jpeg) + } + + // MARK: - .remoteURL post-download dispatch + + @Test("remote dispatch: GIF passthrough preserves bytes") + func remoteDispatchGIFPassthroughPreservesBytes() async throws { + let parentDir = try makeTempDir() + let sourceGIF = parentDir.appendingPathComponent("download.tmp") + try gifFixture.write(to: sourceGIF) + + let result = try await makeMaterializer(policy()) + .dispatchRemoteDownload( + localFile: sourceGIF, + contentType: .gif, + suggestedName: "happy-cat", + caption: nil, + parentDir: parentDir + ) + + #expect(try Data(contentsOf: result.tempFileURL) == gifFixture) // byte-equal: animation preserved + // Display name includes the .gif extension, matching the contract + // applied uniformly across materializer branches. + #expect(result.displayName == "happy-cat.gif") + #expect(result.params.filePath == result.tempFileURL.path) + } + + @Test("remote dispatch passes the caption through", arguments: [UTType.gif, .jpeg]) + func remoteDispatchPassesCaptionThrough(contentType: UTType) async throws { + let parentDir = try makeTempDir() + let sourceFile = parentDir.appendingPathComponent("download.tmp") + let bytes = + contentType == .gif + ? gifFixture + : try encodeImage(makeSolidColorImage(size: CGSize(width: 10, height: 10), color: .red), as: .jpeg) + try bytes.write(to: sourceFile) + + let result = try await makeMaterializer(policy()) + .dispatchRemoteDownload( + localFile: sourceFile, + contentType: contentType, + suggestedName: "g", + caption: "Photo by Foo", + parentDir: parentDir + ) + #expect(result.params.caption == "Photo by Foo") + } + + @Test("remote dispatch rejects non-image, non-GIF content types") + func remoteDispatchRejectsNonImageNonGifContentType() async throws { + let parentDir = try makeTempDir() + let sourceFile = parentDir.appendingPathComponent("vid.tmp") + try Data([0x00]).write(to: sourceFile) + + let m = makeMaterializer(policy()) + await expectThrowsCase(.disallowedContentType) { + try await m.dispatchRemoteDownload( + localFile: sourceFile, + contentType: .movie, + suggestedName: "x", + caption: nil, + parentDir: parentDir + ) + } + } + + @Test("remote dispatch image branch rejects non-image bytes") + func remoteDispatchImageBranchRejectsNonImageBytes() async throws { + let parentDir = try makeTempDir() + let sourceFile = parentDir.appendingPathComponent("a.tmp") + try Data("404 Not Found".utf8).write(to: sourceFile) + + let m = makeMaterializer(policy()) + await expectThrowsCase(.invalidImageData) { + try await m.dispatchRemoteDownload( + localFile: sourceFile, + contentType: .jpeg, + suggestedName: "x", + caption: nil, + parentDir: parentDir + ) + } + } + + // MARK: - Raw passthrough (GIF/SVG) + + @Test(".file SVG passes through raw-copied") + func fileSVGRawCopy() async throws { + let url = try writeTempFixture(svgFixture, name: "art.svg") + + let m = makeMaterializer(policy()) + let result = try await m.materialize(source: .file(url), into: stage()) + + #expect(result.displayName == "art.svg") + #expect(result.kind == .image) + #expect(try Data(contentsOf: result.tempFileURL) == svgFixture) + } + + @Test("remote dispatch: SVG passthrough preserves bytes") + func remoteDispatchSVGPassthroughPreservesBytes() async throws { + let parentDir = try makeTempDir() + let sourceSVG = parentDir.appendingPathComponent("download.tmp") + try svgFixture.write(to: sourceSVG) + + let result = try await makeMaterializer(policy()) + .dispatchRemoteDownload( + localFile: sourceSVG, + contentType: .svg, + suggestedName: "vector-art", + caption: nil, + parentDir: parentDir + ) + + #expect(result.displayName == "vector-art.svg") + #expect(result.kind == .image) + #expect(try Data(contentsOf: result.tempFileURL) == svgFixture) + } + + @Test("remote dispatch: SVG policy rejection surfaces disallowedContentType") + func remoteDispatchSVGPolicyRejectionSurfacesDisallowed() async throws { + let parentDir = try makeTempDir() + let sourceSVG = parentDir.appendingPathComponent("download.tmp") + try svgFixture.write(to: sourceSVG) + + let m = makeMaterializer(policy(allow: { _, ext in ext != "svg" })) + await expectThrowsCase(.disallowedContentType) { + try await m.dispatchRemoteDownload( + localFile: sourceSVG, + contentType: .svg, + suggestedName: "vector-art", + caption: nil, + parentDir: parentDir + ) + } + } + + // MARK: - Single-pass transform + + @Test(".file with non-image bytes in a .jpg is rejected") + func fileCorruptImageRejected() async throws { + let url = try writeTempFixture(Data("definitely not an image".utf8), ext: "jpg") + + let m = makeMaterializer(policy()) + await expectThrowsCase(.invalidImageData) { + try await m.materialize(source: .file(url), into: stage()) + } + } + + @Test("resize strips GPS but retains other EXIF metadata") + func resizeRetainsEXIFStripsGPS() async throws { + let url = try writeTempFixture(makeJPEGWithGPSAndDate(), ext: "jpg") + + let m = makeMaterializer(policy(imageMaxDimension: 32, stripGPSLocation: true)) + let result = try await m.materialize(source: .file(url), into: stage()) + + let props = try imageProperties(of: result.tempFileURL) + #expect(props[kCGImagePropertyGPSDictionary] == nil) + let exif = props[kCGImagePropertyExifDictionary] as? [CFString: Any] + #expect(exif?[kCGImagePropertyExifDateTimeOriginal] as? String == fixtureDateTimeOriginal) + let width = try #require(props[kCGImagePropertyPixelWidth] as? Int) + let height = try #require(props[kCGImagePropertyPixelHeight] as? Int) + #expect(max(width, height) <= 32) + } + + @Test("resize with stripGPSLocation off keeps GPS") + func resizeWithoutStripKeepsGPS() async throws { + let url = try writeTempFixture(makeJPEGWithGPSAndDate(), ext: "jpg") + + let m = makeMaterializer(policy(imageMaxDimension: 32)) + let result = try await m.materialize(source: .file(url), into: stage()) + + #expect(imageHasGPS(result.tempFileURL)) + } + + @Test("resize bakes EXIF orientation into the pixels") + func resizeBakesOrientation() async throws { + // Landscape pixels with a 90° tag: a baked resize must produce + // portrait output (height > width) with an upright tag. + let heic = try encodeImage( + makeSolidColorImage(size: CGSize(width: 80, height: 60), color: .blue), + as: .heic, + properties: [kCGImagePropertyOrientation: CGImagePropertyOrientation.right.rawValue] + ) + let url = try writeTempFixture(heic, ext: "heic") + + let m = makeMaterializer(policy(imageMaxDimension: 40)) + let result = try await m.materialize(source: .file(url), into: stage()) + + let props = try imageProperties(of: result.tempFileURL) + let width = try #require(props[kCGImagePropertyPixelWidth] as? Int) + let height = try #require(props[kCGImagePropertyPixelHeight] as? Int) + #expect(height > width) + let orientation = props[kCGImagePropertyOrientation] as? UInt32 + #expect(orientation == nil || orientation == CGImagePropertyOrientation.up.rawValue) + } + + @Test("sniffed container type wins over a lying declared type") + func sniffedTypeWinsOverDeclared() async throws { + // HEIC bytes in a file named .jpg: the extension-derived declared + // type says JPEG, but the bytes must still be converted. + let url = try writeTempFixture(makeSyntheticHEIC(), ext: "jpg") + + let m = makeMaterializer(policy()) + let result = try await m.materialize(source: .file(url), into: stage()) + + #expect(imageType(of: result.tempFileURL) == .jpeg) + } + + @Test("PNG stays PNG through resize") + func pngResizeKeepsType() async throws { + let png = try encodeImage( + makeSolidColorImage(size: CGSize(width: 128, height: 128), color: .red), + as: .png + ) + let url = try writeTempFixture(png, ext: "png") + + let m = makeMaterializer(policy(imageMaxDimension: 32)) + let result = try await m.materialize(source: .file(url), into: stage()) + + #expect(imageType(of: result.tempFileURL) == .png) + let props = try imageProperties(of: result.tempFileURL) + let width = try #require(props[kCGImagePropertyPixelWidth] as? Int) + #expect(width <= 32) + } + + // MARK: - Format normalization + location stripping + + @Test(".file non-web-safe image (TIFF) is normalized to JPEG") + func fileTIFFConvertedToJPEG() async throws { + let tiff = try encodeImage( + makeSolidColorImage(size: CGSize(width: 64, height: 64), color: .red), + as: .tiff + ) + let url = try writeTempFixture(tiff, ext: "tiff") + + let result = try await makeMaterializer(policy()) + .materialize(source: .file(url), into: stage()) + + #expect(result.displayName.hasSuffix(".jpeg")) + #expect(imageType(of: result.tempFileURL) == .jpeg) + } + + @Test("image with no GPS strips cleanly (no-op, no throw)") + func fileImageWithoutGPSStripSucceeds() async throws { + let jpeg = try encodeImage( + makeSolidColorImage(size: CGSize(width: 48, height: 48), color: .green), + as: .jpeg + ) + let url = try writeTempFixture(jpeg, ext: "jpg") + + let result = try await makeMaterializer(policy(stripGPSLocation: true)) + .materialize(source: .file(url), into: stage()) + #expect(!imageHasGPS(result.tempFileURL)) + } + + @Test("video with stripGPSLocation removes location metadata") + func videoStripsLocation() async throws { + let src = try await makeVideoWithLocation() + let sourceHasLocation = try await videoHasLocation(src) + #expect(sourceHasLocation) // sanity: source is tagged + + let result = try await makeMaterializer(policy(stripGPSLocation: true)) + .materialize(source: .file(src), into: stage()) + let outputHasLocation = try await videoHasLocation(result.tempFileURL) + #expect(!outputHasLocation) + } + + @Test("orphan sweep deletes only entries created before the cutoff") + func sweepSkipsEntriesCreatedAfterCutoff() throws { + let sweepRoot = try makeTempDir() + let orphaned = sweepRoot.appendingPathComponent(UUID().uuidString, isDirectory: true) + let fresh = sweepRoot.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: orphaned, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: fresh, withIntermediateDirectories: true) + try FileManager.default.setAttributes( + [.creationDate: Date(timeIntervalSinceNow: -3600)], + ofItemAtPath: orphaned.path + ) + + UploadSourceMaterializer.sweepOrphanedStagingFiles( + in: sweepRoot, + createdBefore: Date(timeIntervalSinceNow: -60) + ) + + #expect(!FileManager.default.fileExists(atPath: orphaned.path)) + #expect(FileManager.default.fileExists(atPath: fresh.path)) + } +} + +// MARK: - Suite fixtures + +extension UploadSourceMaterializerTests { + /// Materializer staging into the per-test root (or an explicit override + /// when the test needs to inspect the root itself). + private func makeMaterializer( + _ policy: MediaUploadPolicy, + temporaryRoot: URL? = nil + ) -> UploadSourceMaterializer { + UploadSourceMaterializer(policy: policy, temporaryRoot: temporaryRoot ?? root) + } + + /// Fresh directory under the per-test root; removed with it in deinit. + private func makeTempDir() throws -> URL { + let dir = root.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + /// Writes fixture bytes into a fresh directory under the per-test root. + private func writeTempFixture(_ data: Data, name: String) throws -> URL { + let url = try makeTempDir().appendingPathComponent(name) + try data.write(to: url) + return url + } + + private func writeTempFixture(_ data: Data, ext: String) throws -> URL { + try writeTempFixture(data, name: "fixture.\(ext)") + } + + private func createTempPDF(name: String = "test.pdf") throws -> URL { + try writeTempFixture(Data("%PDF-1.4\n%EOF\n".utf8), name: name) + } + + private func createTempGIF() throws -> URL { + try writeTempFixture(gifFixture, name: "test.gif") + } + + /// A short video carrying a QuickTime ISO-6709 location, used to prove the + /// materializer strips it. + private func makeVideoWithLocation() async throws -> URL { + let location = AVMutableMetadataItem() + location.identifier = .quickTimeMetadataLocationISO6709 + location.value = "+37.3349-122.0090+030.000/" as NSString + location.dataType = "com.apple.metadata.datatype.quicktime-metadata-location-ISO6709" + return try await createBlankVideo( + durationSeconds: 1, + metadata: [location], + in: makeTempDir() + ) + } +} + +// MARK: - Test fixtures + +private func makeGIFItemProvider(vendingFile gifURL: URL) -> NSItemProvider { + let provider = NSItemProvider() + provider.registerFileRepresentation( + forTypeIdentifier: UTType.gif.identifier, + fileOptions: [], + visibility: .all + ) { completion in + completion(gifURL, false, nil) + return nil + } + return provider +} + +/// Minimal valid GIF89a: header + logical screen descriptor + trailer. +private let gifFixture = Data([ + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, // "GIF89a" + 0x01, 0x00, 0x01, 0x00, // width=1, height=1 + 0x00, // GCT flag off + 0x00, // background color + 0x00, // aspect ratio + 0x3B // trailer +]) + +private enum FixtureError: Error { case encodingFailed, imageUnavailable } + +/// Minimal valid SVG document for passthrough tests. +private let svgFixture = Data( + #""# + .utf8 +) + +private func makeSolidColorImage(size: CGSize, color: UIColor) -> UIImage { + // Opaque format: a stray alpha channel only triggers ImageIO's "opaque + // image with AlphaLast" warnings when the image is later re-encoded. + // Scale 1 keeps the pixel dimensions equal to `size` on any simulator + // (and avoids rendering fixture bitmaps at 3x screen scale). + let format = UIGraphicsImageRendererFormat.preferred() + format.opaque = true + format.scale = 1 + return UIGraphicsImageRenderer(size: size, format: format) + .image { ctx in + color.setFill() + ctx.fill(CGRect(origin: .zero, size: size)) + } +} + +/// Encodes an image as `type`, attaching any extra image properties such as +/// orientation or GPS. Centralizes the `CGImageDestination` boilerplate the +/// image fixtures would otherwise each repeat. +private func encodeImage( + _ image: UIImage, + as type: UTType, + properties: [CFString: Any] = [:] +) throws -> Data { + guard let cgImage = image.cgImage else { + throw FixtureError.imageUnavailable + } + let out = NSMutableData() + guard + let dst = CGImageDestinationCreateWithData(out, type.identifier as CFString, 1, nil) + else { + throw FixtureError.encodingFailed + } + CGImageDestinationAddImage(dst, cgImage, properties as CFDictionary) + guard CGImageDestinationFinalize(dst) else { + throw FixtureError.encodingFailed + } + return out as Data +} + +private let fixtureDateTimeOriginal = "2026:01:01 12:00:00" + +/// Synthetic JPEG carrying both a GPS dictionary and an EXIF capture date, +/// for GPS-stripping tests and tests that pin the resize path's metadata +/// handling. +private func makeJPEGWithGPSAndDate() throws -> Data { + let image = makeSolidColorImage(size: CGSize(width: 128, height: 128), color: .green) + let gps: [CFString: Any] = [ + kCGImagePropertyGPSLatitude: 37.33, + kCGImagePropertyGPSLongitude: -122.03 + ] + let exif: [CFString: Any] = [ + kCGImagePropertyExifDateTimeOriginal: fixtureDateTimeOriginal + ] + return try encodeImage( + image, + as: .jpeg, + properties: [ + kCGImagePropertyGPSDictionary: gps, + kCGImagePropertyExifDictionary: exif + ] + ) +} + +private func imageProperties(of url: URL) throws -> [CFString: Any] { + let src = try #require(CGImageSourceCreateWithURL(url as CFURL, nil)) + return try #require(CGImageSourceCopyPropertiesAtIndex(src, 0, nil) as? [CFString: Any]) +} + +/// Synthetic HEIC, optionally carrying a specific EXIF orientation tag. +/// Works on iOS 17+ simulator. +private func makeSyntheticHEIC(orientation: CGImagePropertyOrientation? = nil) throws -> Data { + let image = makeSolidColorImage(size: CGSize(width: 64, height: 64), color: .blue) + var properties: [CFString: Any] = [:] + if let orientation { + properties[kCGImagePropertyOrientation] = orientation.rawValue + } + return try encodeImage(image, as: .heic, properties: properties) +} + +/// Encoding a video is the most expensive fixture in this suite, and every +/// consumer only reads the source file, so all tests share a single clip. +private let sharedBlankVideoTask = Task { try await createBlankVideo(durationSeconds: 1.0) } + +/// Creates a 1-second blank H.264 video using AVAssetWriter, optionally +/// tagged with container-level metadata. +private func createBlankVideo( + durationSeconds: Double, + metadata: [AVMetadataItem] = [], + in directory: URL = FileManager.default.temporaryDirectory +) async throws -> URL { + let url = directory.appendingPathComponent("blank_\(UUID().uuidString).mp4") + + let writer = try AVAssetWriter(outputURL: url, fileType: .mp4) + writer.metadata = metadata + let settings: [String: Any] = [ + AVVideoCodecKey: AVVideoCodecType.h264, + AVVideoWidthKey: 320, + AVVideoHeightKey: 240 + ] + let input = AVAssetWriterInput(mediaType: .video, outputSettings: settings) + input.expectsMediaDataInRealTime = false + writer.add(input) + + let adaptor = AVAssetWriterInputPixelBufferAdaptor( + assetWriterInput: input, + sourcePixelBufferAttributes: [ + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA, + kCVPixelBufferWidthKey as String: 320, + kCVPixelBufferHeightKey as String: 240 + ] + ) + + writer.startWriting() + writer.startSession(atSourceTime: .zero) + + // Write a single black frame at t=0. + var pixelBuffer: CVPixelBuffer? + CVPixelBufferCreate( + kCFAllocatorDefault, + 320, + 240, + kCVPixelFormatType_32BGRA, + [ + kCVPixelBufferCGImageCompatibilityKey: true, + kCVPixelBufferCGBitmapContextCompatibilityKey: true + ] as CFDictionary, + &pixelBuffer + ) + if let pb = pixelBuffer { + CVPixelBufferLockBaseAddress(pb, []) + let ptr = CVPixelBufferGetBaseAddress(pb) + memset(ptr, 0, CVPixelBufferGetDataSize(pb)) + CVPixelBufferUnlockBaseAddress(pb, []) + adaptor.append(pb, withPresentationTime: .zero) + } + + input.markAsFinished() + + return try await withCheckedThrowingContinuation { cont in + writer.endSession(atSourceTime: CMTime(seconds: durationSeconds, preferredTimescale: 600)) + writer.finishWriting { + if writer.status == .completed { + cont.resume(returning: url) + } else { + cont.resume(throwing: writer.error ?? NSError(domain: "Test", code: 20)) + } + } + } +} + +private func imageType(of url: URL) -> UTType? { + guard + let src = CGImageSourceCreateWithURL(url as CFURL, nil), + let type = CGImageSourceGetType(src) + else { return nil } + return UTType(type as String) +} + +private func imageHasGPS(_ url: URL) -> Bool { + guard + let src = CGImageSourceCreateWithURL(url as CFURL, nil), + let props = CGImageSourceCopyPropertiesAtIndex(src, 0, nil) as? [CFString: Any] + else { return false } + return props[kCGImagePropertyGPSDictionary] != nil +} + +private func videoHasLocation(_ url: URL) async throws -> Bool { + let asset = AVURLAsset(url: url) + for format in try await asset.load(.availableMetadataFormats) { + for item in try await asset.loadMetadata(for: format) { + let identifier = (item.identifier?.rawValue ?? "").lowercased() + if identifier.contains("loci") || identifier.contains("location") { return true } + } + } + return false +} From 1dd6715cc97a4ad243a6736ab2e101a4a8db2263 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Wed, 8 Jul 2026 11:20:59 +1200 Subject: [PATCH 3/6] Sweep orphaned staging files using this run's sweep reference date ProcessInfo.systemUptime measures time since device boot, so the sweep cutoff spared staging directories orphaned by an earlier run in the same boot session. Capture a per-run reference date on the first sweep instead, which the app schedules shortly after launch, before any upload is staged. --- .../Upload/UploadSourceMaterializer.swift | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/Modules/Sources/WordPressMediaLibrary/Upload/UploadSourceMaterializer.swift b/Modules/Sources/WordPressMediaLibrary/Upload/UploadSourceMaterializer.swift index d30fd5af84cb..efc45071222f 100644 --- a/Modules/Sources/WordPressMediaLibrary/Upload/UploadSourceMaterializer.swift +++ b/Modules/Sources/WordPressMediaLibrary/Upload/UploadSourceMaterializer.swift @@ -47,17 +47,22 @@ final class UploadSourceMaterializer: Sendable { return base.appendingPathComponent("WordPressMediaLibrary-Uploads", isDirectory: true) }() + /// Reference instant for the sweep, captured the first time it runs. + /// Deliberately not a true process-start time (it is only as early as that + /// first call), so it must not be reused as a general launch timestamp; it + /// exists solely to tell this run's staging dirs from an earlier run's. + private static let sweepReferenceDate = Date() + /// Deletes staged uploads under the default staging root that were created - /// before this process started. In-memory uploader state never survives - /// process termination, so any entry from a previous run was orphaned by a - /// crash or force-quit. Entries created by the current process are never - /// touched, which makes the sweep safe to run at any point after launch, - /// even concurrently with in-flight staging (the app schedules it on a - /// deferred background queue) or with parallel test materializations that - /// share the default root. + /// before this process began sweeping. In-memory uploader state never + /// survives process termination, so any earlier entry was orphaned by a + /// crash or force-quit in a previous run. Call this once shortly after app + /// launch, before the first upload is staged, so the reference it captures + /// approximates this process's start: staging dirs this run creates + /// afterward are always newer and never swept, so a later concurrent + /// materialization (or a parallel test sharing the default root) is safe. public static func sweepOrphanedStagingFiles() { - let processStart = Date(timeIntervalSinceNow: -ProcessInfo.processInfo.systemUptime) - sweepOrphanedStagingFiles(in: defaultStagingDirectory, createdBefore: processStart) + sweepOrphanedStagingFiles(in: defaultStagingDirectory, createdBefore: sweepReferenceDate) } /// Testable core of `sweepOrphanedStagingFiles()`. Entries with an From 82bc166b1571eb99d6cf2bf8dc44909b3252a973 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Wed, 8 Jul 2026 11:21:16 +1200 Subject: [PATCH 4/6] Release the remote download temp file on failure paths URLSession's async download hands back a temp file the caller owns and must delete. A non-2xx status or a failed move threw without removing it, leaking the file in the system temp directory on every failed remote pick. --- .../WordPressMediaLibrary/Upload/RemoteDownloader.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Modules/Sources/WordPressMediaLibrary/Upload/RemoteDownloader.swift b/Modules/Sources/WordPressMediaLibrary/Upload/RemoteDownloader.swift index 512cbf0b8536..38bb2caec5a2 100644 --- a/Modules/Sources/WordPressMediaLibrary/Upload/RemoteDownloader.swift +++ b/Modules/Sources/WordPressMediaLibrary/Upload/RemoteDownloader.swift @@ -29,6 +29,13 @@ final class RemoteDownloader { throw MaterializerError.remoteDownloadFailed(underlyingError: error) } + // The async `download` hands back a temp file it does not reap for us, + // so we own it now. Delete it on every path that doesn't move it into + // parentDir (the status check below throws before the move), otherwise a + // failed download strands bytes in the system temp directory. On success + // the move renames it away first, leaving this a no-op. + defer { try? FileManager.default.removeItem(at: location) } + // HTTP status check: a 404/500 response body looks like a successful // download to URLSession (it still hands back a valid temp file). For // Stock Photos the image-byte validator catches HTML error bodies From a25f4136b35259184710d80548ef161f36024646 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 9 Jul 2026 12:52:26 +1200 Subject: [PATCH 5/6] Reuse an ephemeral URLSession for remote downloads --- .../Upload/RemoteDownloader.swift | 20 +++++++++++++++---- .../Upload/UploadSourceMaterializer.swift | 4 ++-- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/Modules/Sources/WordPressMediaLibrary/Upload/RemoteDownloader.swift b/Modules/Sources/WordPressMediaLibrary/Upload/RemoteDownloader.swift index 38bb2caec5a2..63cb030908f6 100644 --- a/Modules/Sources/WordPressMediaLibrary/Upload/RemoteDownloader.swift +++ b/Modules/Sources/WordPressMediaLibrary/Upload/RemoteDownloader.swift @@ -1,7 +1,9 @@ import Foundation -/// Single-use downloader for `.remoteURL` materialization. The materializer -/// constructs one per download and discards it after. +/// Downloader for `.remoteURL` materialization. Owns a private ephemeral +/// `URLSession` and is reused across downloads (the materializer holds one +/// instance), so downloaded media bytes, cookies, and credentials never +/// touch the app-wide `URLSession.shared` cache and jar. /// /// Built on the async `URLSession.download(from:delegate:)`, so the runtime /// owns the continuation, cancellation, temp-file delivery, and error @@ -10,7 +12,17 @@ import Foundation /// the task's own `Progress` via KVO from `didCreateTask`, the workaround /// Apple's URL Loading System team recommends for this case: /// https://developer.apple.com/forums/thread/723015 -final class RemoteDownloader { +final class RemoteDownloader: Sendable { + + private let session: URLSession + + init() { + let configuration = URLSessionConfiguration.ephemeral + // Media downloads are large and single-use, so keep them out of the + // session's in-memory URL cache. + configuration.urlCache = nil + self.session = URLSession(configuration: configuration) + } /// Downloads `url` into `parentDir` and returns the local file URL. Reports /// byte-level progress on `progress` (scaled to its existing @@ -21,7 +33,7 @@ final class RemoteDownloader { let location: URL let response: URLResponse do { - (location, response) = try await URLSession.shared.download(from: url, delegate: delegate) + (location, response) = try await session.download(from: url, delegate: delegate) } catch { if error is CancellationError || (error as? URLError)?.code == .cancelled { throw CancellationError() diff --git a/Modules/Sources/WordPressMediaLibrary/Upload/UploadSourceMaterializer.swift b/Modules/Sources/WordPressMediaLibrary/Upload/UploadSourceMaterializer.swift index efc45071222f..f31a010de115 100644 --- a/Modules/Sources/WordPressMediaLibrary/Upload/UploadSourceMaterializer.swift +++ b/Modules/Sources/WordPressMediaLibrary/Upload/UploadSourceMaterializer.swift @@ -30,6 +30,7 @@ final class UploadSourceMaterializer: Sendable { private let policy: MediaUploadPolicy private let temporaryRoot: URL private let filenames = UploadFilenameAllocator() + private let remoteDownloader = RemoteDownloader() /// Default staging root for materialized uploads. Rooted in Application /// Support (not the system temp dir) so files survive app suspension and a @@ -506,8 +507,7 @@ final class UploadSourceMaterializer: Sendable { // Retaining the original UploadSource on failed entries would // let Retry re-run materialization for this case (and every // other materialization-failing source). - let downloader = RemoteDownloader() - let localFile = try await downloader.download( + let localFile = try await remoteDownloader.download( from: remote.url, into: parentDir, progress: stageProgress From 18466f5d3ff3d8157b03654e6bafd6f4dc89cead Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 9 Jul 2026 13:26:55 +1200 Subject: [PATCH 6/6] Bound and sanitize upload basenames in the allocator The materialized temp-file name drives the uploaded filename, so an over-long source name could overflow NAME_MAX (ENAMETOOLONG) and a name carrying path separators could escape the staging directory via appendingPathComponent. Move both guards into basename, the single point every source path funnels through, so the .remoteURL and .imagePlayground paths that skip stem are covered too: sanitize separators and NUL, then cap the stem to a byte budget on a grapheme boundary. --- .../Upload/UploadFilenameAllocator.swift | 61 ++++++++++++++----- .../UploadFilenameAllocatorTests.swift | 47 +++++++++++++- .../UploadSourceMaterializerTests.swift | 37 +++++++++++ 3 files changed, 129 insertions(+), 16 deletions(-) diff --git a/Modules/Sources/WordPressMediaLibrary/Upload/UploadFilenameAllocator.swift b/Modules/Sources/WordPressMediaLibrary/Upload/UploadFilenameAllocator.swift index b30117a13a55..b752133c5965 100644 --- a/Modules/Sources/WordPressMediaLibrary/Upload/UploadFilenameAllocator.swift +++ b/Modules/Sources/WordPressMediaLibrary/Upload/UploadFilenameAllocator.swift @@ -16,25 +16,40 @@ import os final class UploadFilenameAllocator: Sendable { private let usedBasenames = OSAllocatedUnfairLock>(initialState: []) + /// Upper bound on the name stem, in UTF-8 bytes. Kept well under APFS + /// `NAME_MAX` (255 bytes) so the `.` and any ` (n)` dedup suffix still + /// fit without per-candidate length arithmetic. A source name longer than + /// this would otherwise make the temp-file write fail with `ENAMETOOLONG`. + private static let maxStemBytes = 200 + /// The sanitized `preferred` name, or `-` when /// the source has no usable name. Carries no extension and is not yet /// deduplicated; pass the result to `basename`. func stem(preferred: String?, fallbackPrefix: String, date: Date) -> String { - preferred.flatMap(sanitize) ?? "\(fallbackPrefix)-\(timestampedName(date: date))" + if let cleaned = preferred.map(sanitize), !cleaned.isEmpty { + return cleaned + } + return "\(fallbackPrefix)-\(timestampedName(date: date))" } - /// A unique `.` basename. Names already handed out for the - /// lifetime of this allocator are tracked, so a batch with two same-named - /// files becomes `name.jpg`, `name (2).jpg`, and so on. + /// A unique `.` basename, safe to hand to + /// `URL.appendingPathComponent`. The stem is sanitized and length-capped + /// here (not only in `stem`), so a caller that passes a raw source name + /// (`.remoteURL`, `.imagePlayground`) can't smuggle a path separator or an + /// over-long name onto disk. Names already handed out for the lifetime of + /// this allocator are tracked, so a batch with two same-named files becomes + /// `name.jpg`, `name (2).jpg`, and so on. func basename(stem: String, ext: String) -> String { - usedBasenames.withLock { used in - let first = "\(stem).\(ext)" + let fitted = truncated(sanitize(stem), toUTF8ByteCount: Self.maxStemBytes) + let safeStem = fitted.isEmpty ? "file" : fitted + return usedBasenames.withLock { used in + let first = "\(safeStem).\(ext)" if used.insert(first).inserted { return first } var n = 2 while true { - let candidate = "\(stem) (\(n)).\(ext)" + let candidate = "\(safeStem) (\(n)).\(ext)" if used.insert(candidate).inserted { return candidate } @@ -59,14 +74,32 @@ final class UploadFilenameAllocator: Sendable { Self.timestampFormatter.string(from: date) } - /// Strips path separators and NUL, caps length, and treats an empty result - /// as "no usable name" (nil) so the caller falls back to a generated stem. - private func sanitize(_ name: String) -> String? { - let cleaned = - name + /// Replaces path separators with `_` and strips NUL so the result is a + /// single path component that `appendingPathComponent` can't use to escape + /// its parent directory (e.g. `../escaped` becomes `.._escaped`). May be + /// empty; `stem` treats that as "no usable name" and `basename` substitutes + /// a stub. + private func sanitize(_ name: String) -> String { + name .replacingOccurrences(of: "/", with: "_") .replacingOccurrences(of: "\u{0}", with: "") - let trimmed = String(cleaned.prefix(256)) - return trimmed.isEmpty ? nil : trimmed + } + + /// Truncates `name` to at most `maxBytes` UTF-8 bytes without splitting a + /// grapheme, so the temp-file write can't fail with `ENAMETOOLONG` and the + /// result is never cut mid-character. + private func truncated(_ name: String, toUTF8ByteCount maxBytes: Int) -> String { + guard name.utf8.count > maxBytes else { return name } + var result = "" + var count = 0 + for character in name { + let size = String(character).utf8.count + if count + size > maxBytes { + break + } + result.append(character) + count += size + } + return result } } diff --git a/Modules/Tests/WordPressMediaLibraryTests/UploadFilenameAllocatorTests.swift b/Modules/Tests/WordPressMediaLibraryTests/UploadFilenameAllocatorTests.swift index 0e0103a73cbc..7d9fa0d190fa 100644 --- a/Modules/Tests/WordPressMediaLibraryTests/UploadFilenameAllocatorTests.swift +++ b/Modules/Tests/WordPressMediaLibraryTests/UploadFilenameAllocatorTests.swift @@ -49,8 +49,7 @@ struct UploadFilenameAllocatorTests { ("My Vacation", "My Vacation"), // usable names are kept verbatim ("a/b/c", "a_b_c"), // path separators become underscores ("///", "___"), // separator-only names sanitize, not fall back - ("a\u{0}b", "ab"), // NUL characters are stripped - (String(repeating: "a", count: 300), String(repeating: "a", count: 256)) // capped at 256 + ("a\u{0}b", "ab") // NUL characters are stripped ] ) func preferredNameSanitization(preferred: String, expected: String) { @@ -72,6 +71,50 @@ struct UploadFilenameAllocatorTests { #expect(stem.hasPrefix("Video-")) } + // MARK: - basename safety + + @Test("basename neutralizes a traversing stem into a single path component") + func basenameNeutralizesTraversal() { + // The `/` is replaced, so the result can't climb out of its parent dir + // via `appendingPathComponent`. `basename` sanitizes even when the + // caller (`.remoteURL`, `.imagePlayground`) skipped `stem`. + #expect(allocator.basename(stem: "../escaped", ext: "jpg") == ".._escaped.jpg") + } + + @Test("basename caps an over-long ASCII stem within NAME_MAX") + func basenameCapsLongASCIIStem() { + let basename = allocator.basename(stem: String(repeating: "a", count: 400), ext: "jpg") + #expect(basename.utf8.count <= 255) + #expect(basename.hasSuffix(".jpg")) + } + + @Test("basename caps a multibyte stem on a grapheme boundary") + func basenameCapsMultibyteStem() { + // "あ" is 3 UTF-8 bytes, so a naive byte-slice could split it. The cap + // must stay within NAME_MAX and never leave a broken scalar behind. + let basename = allocator.basename(stem: String(repeating: "あ", count: 300), ext: "jpg") + #expect(basename.utf8.count <= 255) + #expect(basename.hasSuffix(".jpg")) + // No replacement character: every retained scalar is intact. + #expect(!basename.contains("\u{FFFD}")) + } + + @Test("a stem that sanitizes to empty falls back to a stub basename") + func emptyStemFallsBackToStub() { + #expect(allocator.basename(stem: "", ext: "jpg") == "file.jpg") + #expect(allocator.basename(stem: "\u{0}", ext: "png") == "file.png") + } + + @Test("truncated stems still deduplicate and stay within NAME_MAX") + func truncatedStemsDeduplicate() { + let stem = String(repeating: "a", count: 400) + let first = allocator.basename(stem: stem, ext: "jpg") + let second = allocator.basename(stem: stem, ext: "jpg") + #expect(first != second) + #expect(second.contains(" (2)")) + #expect(second.utf8.count <= 255) + } + // MARK: - end-to-end @Test("a source name flows through to a complete upload basename") diff --git a/Modules/Tests/WordPressMediaLibraryTests/UploadSourceMaterializerTests.swift b/Modules/Tests/WordPressMediaLibraryTests/UploadSourceMaterializerTests.swift index 43add0cd4abc..f10fa91ab063 100644 --- a/Modules/Tests/WordPressMediaLibraryTests/UploadSourceMaterializerTests.swift +++ b/Modules/Tests/WordPressMediaLibraryTests/UploadSourceMaterializerTests.swift @@ -363,6 +363,43 @@ final class UploadSourceMaterializerTests { } } + @Test( + "remote dispatch keeps output inside parentDir for a traversing suggestedName", + arguments: [UTType.jpeg, .gif] + ) + func remoteDispatchContainsTraversingName(contentType: UTType) async throws { + let parentDir = try makeTempDir() + let sourceFile = parentDir.appendingPathComponent("download.tmp") + let bytes = + contentType == .gif + ? gifFixture + : try encodeImage(makeSolidColorImage(size: CGSize(width: 10, height: 10), color: .red), as: .jpeg) + try bytes.write(to: sourceFile) + + // `.gif` exercises the passthrough `moveItem` branch; `.jpeg` the + // `finalizeImage` write branch. Both build the destination from the + // untrusted suggested name via `appendingPathComponent`. + let result = try await makeMaterializer(policy()) + .dispatchRemoteDownload( + localFile: sourceFile, + contentType: contentType, + suggestedName: "../escaped", + caption: nil, + parentDir: parentDir + ) + + let parent = parentDir.standardizedFileURL.path + // The finalized file must stay INSIDE parentDir: sanitizing the name + // neutralizes the `..` before it reaches `appendingPathComponent`. + #expect( + result.tempFileURL.standardizedFileURL.path.hasPrefix(parent + "/"), + "materialized file escaped parentDir: \(result.tempFileURL.standardizedFileURL.path)" + ) + // The documented cleanup target must be parentDir itself, never its + // parent, so deleting it can't wipe sibling uploads in the staging root. + #expect(result.stagingDirectory.standardizedFileURL.path == parent) + } + @Test("remote dispatch image branch rejects non-image bytes") func remoteDispatchImageBranchRejectsNonImageBytes() async throws { let parentDir = try makeTempDir()