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 } } 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..63cb030908f6 --- /dev/null +++ b/Modules/Sources/WordPressMediaLibrary/Upload/RemoteDownloader.swift @@ -0,0 +1,101 @@ +import Foundation + +/// 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 +/// 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: 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 + /// `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 session.download(from: url, delegate: delegate) + } catch { + if error is CancellationError || (error as? URLError)?.code == .cancelled { + throw CancellationError() + } + 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 + // 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..b752133c5965 --- /dev/null +++ b/Modules/Sources/WordPressMediaLibrary/Upload/UploadFilenameAllocator.swift @@ -0,0 +1,105 @@ +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: []) + + /// 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 { + if let cleaned = preferred.map(sanitize), !cleaned.isEmpty { + return cleaned + } + return "\(fallbackPrefix)-\(timestampedName(date: date))" + } + + /// 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 { + 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 = "\(safeStem) (\(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) + } + + /// 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: "") + } + + /// 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/Sources/WordPressMediaLibrary/Upload/UploadSourceMaterializer.swift b/Modules/Sources/WordPressMediaLibrary/Upload/UploadSourceMaterializer.swift new file mode 100644 index 000000000000..f31a010de115 --- /dev/null +++ b/Modules/Sources/WordPressMediaLibrary/Upload/UploadSourceMaterializer.swift @@ -0,0 +1,929 @@ +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() + 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 + /// 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) + }() + + /// 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 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() { + sweepOrphanedStagingFiles(in: defaultStagingDirectory, createdBefore: sweepReferenceDate) + } + + /// 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 localFile = try await remoteDownloader.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..7d9fa0d190fa --- /dev/null +++ b/Modules/Tests/WordPressMediaLibraryTests/UploadFilenameAllocatorTests.swift @@ -0,0 +1,125 @@ +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 + ] + ) + 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: - 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") + 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..f10fa91ab063 --- /dev/null +++ b/Modules/Tests/WordPressMediaLibraryTests/UploadSourceMaterializerTests.swift @@ -0,0 +1,887 @@ +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 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() + 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 +}