mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(mobile): image upload fails due to unstripped metadata (#2185)
Signed-off-by: npub1ft62tztwwm2x9xamk25smmuaj4sfckdkldksruf2x2jwqalffkrq0g7arr <4af4a5896e76d4629bbbb2a90def9d95609c59b6fb6d01f12a32a4e077e94d86@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1ft62tztwwm2x9xamk25smmuaj4sfckdkldksruf2x2jwqalffkrq0g7arr <4af4a5896e76d4629bbbb2a90def9d95609c59b6fb6d01f12a32a4e077e94d86@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
co-authored by
npub1ft62tztwwm2x9xamk25smmuaj4sfckdkldksruf2x2jwqalffkrq0g7arr
parent
8c77a0cf8f
commit
37f15b2001
@@ -1087,6 +1087,51 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ios_uikit_sanitizer_outputs_match_relay_contract() {
|
||||
let config = test_config();
|
||||
for (name, bytes, expected_mime) in [
|
||||
(
|
||||
"PNG",
|
||||
include_bytes!("../tests/fixtures/ios/uikit-sanitized.png").as_slice(),
|
||||
"image/png",
|
||||
),
|
||||
(
|
||||
"JPEG",
|
||||
include_bytes!("../tests/fixtures/ios/uikit-sanitized.jpg").as_slice(),
|
||||
"image/jpeg",
|
||||
),
|
||||
] {
|
||||
let actual = validate_content(bytes, &config).unwrap_or_else(|error| {
|
||||
panic!("rejected iOS UIKit-sanitized {name} fixture: {error}")
|
||||
});
|
||||
assert_eq!(actual, expected_mime);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ios_uikit_encoder_outputs_require_sanitization() {
|
||||
let config = test_config();
|
||||
for (name, bytes) in [
|
||||
(
|
||||
"PNG",
|
||||
include_bytes!("../tests/fixtures/ios/uikit-encoded.png").as_slice(),
|
||||
),
|
||||
(
|
||||
"JPEG",
|
||||
include_bytes!("../tests/fixtures/ios/uikit-encoded.jpg").as_slice(),
|
||||
),
|
||||
] {
|
||||
assert!(
|
||||
matches!(
|
||||
validate_content(bytes, &config),
|
||||
Err(MediaError::MetadataForbidden)
|
||||
),
|
||||
"accepted unsanitized iOS UIKit {name} fixture"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rejects_png_metadata_and_trailing_payload() {
|
||||
let config = test_config();
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
# UIKit media fixtures
|
||||
|
||||
These 2 x 2 fixtures were produced on an iOS simulator with UIKit, not by a generic image encoder.
|
||||
|
||||
## Regeneration
|
||||
|
||||
1. Create a small source image and run this program against the simulator SDK:
|
||||
|
||||
```swift
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
let arguments = CommandLine.arguments
|
||||
let source = try Data(contentsOf: URL(fileURLWithPath: arguments[1]))
|
||||
guard
|
||||
let image = UIImage(data: source),
|
||||
let png = image.pngData(),
|
||||
let jpeg = image.jpegData(compressionQuality: 1.0)
|
||||
else {
|
||||
fatalError("UIKit could not encode the source image")
|
||||
}
|
||||
try png.write(to: URL(fileURLWithPath: arguments[2]))
|
||||
try jpeg.write(to: URL(fileURLWithPath: arguments[3]))
|
||||
```
|
||||
|
||||
Compile and run it with the active Xcode toolchain:
|
||||
|
||||
```sh
|
||||
SDK_PATH="$(xcrun --sdk iphonesimulator --show-sdk-path)"
|
||||
xcrun --sdk iphonesimulator swiftc \
|
||||
-sdk "$SDK_PATH" \
|
||||
-target arm64-apple-ios16.0-simulator \
|
||||
reencode.swift -o reencode
|
||||
xcrun simctl spawn booted ./reencode \
|
||||
source.png uikit-encoded.png uikit-encoded.jpg
|
||||
```
|
||||
|
||||
2. Copy the encoded files into both fixture directories:
|
||||
|
||||
```sh
|
||||
cp uikit-encoded.png mobile/ios/RunnerTests/Fixtures/UIKitEncoded.png
|
||||
cp uikit-encoded.jpg mobile/ios/RunnerTests/Fixtures/UIKitEncoded.jpg
|
||||
cp uikit-encoded.png crates/buzz-media/tests/fixtures/ios/
|
||||
cp uikit-encoded.jpg crates/buzz-media/tests/fixtures/ios/
|
||||
```
|
||||
|
||||
3. Add a temporary Runner test that loads `UIKitEncoded.png` and `UIKitEncoded.jpg`, calls `MediaSanitizer.scrubPng` and `MediaSanitizer.scrubJpeg`, and writes those outputs to `uikit-sanitized.png` and `uikit-sanitized.jpg`. Run it once, copy the files here, then remove the temporary test.
|
||||
4. Run `cmp` on each encoded copy to confirm that the Runner and Rust fixtures are byte-identical.
|
||||
5. Run `cargo test -p buzz-media test_ios_uikit` to verify that UIKit's encoded output is rejected and the matching sanitizer output is accepted by the relay contract.
|
||||
|
||||
Regenerate both encoded and sanitized pairs whenever UIKit encoding or `MediaSanitizer` changes. Do not update only the sanitized files, because the test is intended to cover the exact encoder-to-sanitizer boundary.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 896 B |
Binary file not shown.
|
After Width: | Height: | Size: 312 B |
Binary file not shown.
|
After Width: | Height: | Size: 696 B |
Binary file not shown.
|
After Width: | Height: | Size: 107 B |
@@ -1,6 +1,8 @@
|
||||
PODS:
|
||||
- app_badge_plus (1.2.10):
|
||||
- Flutter
|
||||
- app_links (6.4.1):
|
||||
- Flutter
|
||||
- connectivity_plus (0.0.1):
|
||||
- Flutter
|
||||
- Flutter (1.0.0)
|
||||
@@ -25,6 +27,7 @@ PODS:
|
||||
|
||||
DEPENDENCIES:
|
||||
- app_badge_plus (from `.symlinks/plugins/app_badge_plus/ios`)
|
||||
- app_links (from `.symlinks/plugins/app_links/ios`)
|
||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
|
||||
@@ -38,6 +41,8 @@ DEPENDENCIES:
|
||||
EXTERNAL SOURCES:
|
||||
app_badge_plus:
|
||||
:path: ".symlinks/plugins/app_badge_plus/ios"
|
||||
app_links:
|
||||
:path: ".symlinks/plugins/app_links/ios"
|
||||
connectivity_plus:
|
||||
:path: ".symlinks/plugins/connectivity_plus/ios"
|
||||
Flutter:
|
||||
@@ -59,6 +64,7 @@ EXTERNAL SOURCES:
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
app_badge_plus: 09939f19a075cc742cc155d8ed85e6d8601f0104
|
||||
app_links: 3dbc685f76b1693c66a6d9dd1e9ab6f73d97dc0a
|
||||
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
/* Begin PBXBuildFile section */
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
||||
331C809B294A63AB00263BE5 /* MediaSanitizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C809A294A618700263BE5 /* MediaSanitizer.swift */; };
|
||||
331C809D294A63AB00263BE5 /* UIKitEncoded.png in Resources */ = {isa = PBXBuildFile; fileRef = 331C809C294A618700263BE5 /* UIKitEncoded.png */; };
|
||||
331C809F294A63AB00263BE5 /* UIKitEncoded.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 331C809E294A618700263BE5 /* UIKitEncoded.jpg */; };
|
||||
33ADD70AB275E0EC81295559 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8906419FB4E98B4B12B7A56F /* Pods_Runner.framework */; };
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||
42C129326CE4E1B8E617B9CD /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CD6B899582D0416ADBD8A68F /* Pods_RunnerTests.framework */; };
|
||||
@@ -47,6 +50,9 @@
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||
30CE81D3D1E0B195EF2A6390 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||
331C809A294A618700263BE5 /* MediaSanitizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaSanitizer.swift; sourceTree = "<group>"; };
|
||||
331C809C294A618700263BE5 /* UIKitEncoded.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = UIKitEncoded.png; sourceTree = "<group>"; };
|
||||
331C809E294A618700263BE5 /* UIKitEncoded.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = UIKitEncoded.jpg; sourceTree = "<group>"; };
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||
42F36AA401015EC51F984FEA /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
@@ -93,10 +99,20 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
331C807B294A618700263BE5 /* RunnerTests.swift */,
|
||||
331C80A0294A618700263BE5 /* Fixtures */,
|
||||
);
|
||||
path = RunnerTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
331C80A0294A618700263BE5 /* Fixtures */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
331C809E294A618700263BE5 /* UIKitEncoded.jpg */,
|
||||
331C809C294A618700263BE5 /* UIKitEncoded.png */,
|
||||
);
|
||||
path = Fixtures;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
746EA2D40EB8F35E67C48311 /* Pods */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -153,6 +169,7 @@
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||
331C809A294A618700263BE5 /* MediaSanitizer.swift */,
|
||||
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||
);
|
||||
@@ -256,6 +273,8 @@
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
331C809F294A63AB00263BE5 /* UIKitEncoded.jpg in Resources */,
|
||||
331C809D294A63AB00263BE5 /* UIKitEncoded.png in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -381,6 +400,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||
331C809B294A63AB00263BE5 /* MediaSanitizer.swift in Sources */,
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
|
||||
);
|
||||
@@ -496,8 +516,10 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMPRESS_PNG_FILES = NO;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
STRIP_PNG_TEXT = NO;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER).RunnerTests";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -514,8 +536,10 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMPRESS_PNG_FILES = NO;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
STRIP_PNG_TEXT = NO;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER).RunnerTests";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -530,8 +554,10 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMPRESS_PNG_FILES = NO;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
STRIP_PNG_TEXT = NO;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER).RunnerTests";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
||||
@@ -58,19 +58,19 @@ import UserNotifications
|
||||
return
|
||||
}
|
||||
|
||||
let sanitizedData: Data?
|
||||
switch mimeType {
|
||||
case "image/png":
|
||||
sanitizedData = image.pngData()
|
||||
case "image/jpeg":
|
||||
sanitizedData = image.jpegData(compressionQuality: 1.0)
|
||||
case "image/webp":
|
||||
sanitizedData = image.pngData()
|
||||
default:
|
||||
sanitizedData = nil
|
||||
}
|
||||
|
||||
guard let sanitizedData else {
|
||||
do {
|
||||
guard let sanitizedData = try MediaSanitizer.sanitizeImage(image, mimeType: mimeType) else {
|
||||
result(
|
||||
FlutterError(
|
||||
code: "sanitize_failed",
|
||||
message: "Unable to sanitize picked image.",
|
||||
details: mimeType
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
result(FlutterStandardTypedData(bytes: sanitizedData))
|
||||
} catch {
|
||||
result(
|
||||
FlutterError(
|
||||
code: "sanitize_failed",
|
||||
@@ -78,10 +78,7 @@ import UserNotifications
|
||||
details: mimeType
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
result(FlutterStandardTypedData(bytes: sanitizedData))
|
||||
case "transcodeImageToJpeg":
|
||||
guard let typedData = call.arguments as? FlutterStandardTypedData else {
|
||||
result(
|
||||
@@ -94,9 +91,7 @@ import UserNotifications
|
||||
return
|
||||
}
|
||||
|
||||
guard let image = UIImage(data: typedData.data),
|
||||
let jpegData = image.jpegData(compressionQuality: 1.0)
|
||||
else {
|
||||
guard let image = UIImage(data: typedData.data) else {
|
||||
result(
|
||||
FlutterError(
|
||||
code: "transcode_failed",
|
||||
@@ -107,7 +102,27 @@ import UserNotifications
|
||||
return
|
||||
}
|
||||
|
||||
result(FlutterStandardTypedData(bytes: jpegData))
|
||||
do {
|
||||
guard let jpegData = try MediaSanitizer.encodeJpeg(image) else {
|
||||
result(
|
||||
FlutterError(
|
||||
code: "transcode_failed",
|
||||
message: "Unable to convert picked image to JPEG.",
|
||||
details: nil
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
result(FlutterStandardTypedData(bytes: jpegData))
|
||||
} catch {
|
||||
result(
|
||||
FlutterError(
|
||||
code: "transcode_failed",
|
||||
message: "Unable to convert picked image to JPEG.",
|
||||
details: nil
|
||||
)
|
||||
)
|
||||
}
|
||||
case "transcodeVideoToMp4":
|
||||
guard let sourcePath = call.arguments as? String else {
|
||||
result(
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
private enum MediaSanitizationError: Error {
|
||||
case invalidPng
|
||||
case invalidJpeg
|
||||
}
|
||||
|
||||
enum MediaSanitizer {
|
||||
private static let pngSignature = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
|
||||
private static let allowedPngAncillaryChunks: Set<String> = [
|
||||
"cHRM", "gAMA", "sBIT", "sRGB", "bKGD", "hIST", "tRNS", "sPLT", "acTL", "fcTL", "fdAT",
|
||||
]
|
||||
|
||||
static func sanitizeImage(_ image: UIImage, mimeType: String) throws -> Data? {
|
||||
switch mimeType {
|
||||
case "image/png":
|
||||
guard let image = renderInSRGB(image), let encoded = image.pngData() else { return nil }
|
||||
return try scrubPng(encoded)
|
||||
case "image/jpeg":
|
||||
return try encodeJpeg(image)
|
||||
case "image/webp":
|
||||
guard let image = renderInSRGB(image), let encoded = image.pngData() else { return nil }
|
||||
return try scrubPng(encoded)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
static func encodeJpeg(_ image: UIImage) throws -> Data? {
|
||||
guard
|
||||
let image = renderInSRGB(image),
|
||||
let encoded = image.jpegData(compressionQuality: 1.0)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return try scrubJpeg(encoded)
|
||||
}
|
||||
|
||||
private static func renderInSRGB(_ image: UIImage) -> UIImage? {
|
||||
guard image.size.width > 0, image.size.height > 0 else { return nil }
|
||||
let format = UIGraphicsImageRendererFormat()
|
||||
format.scale = image.scale
|
||||
format.opaque = false
|
||||
format.preferredRange = .standard
|
||||
return UIGraphicsImageRenderer(size: image.size, format: format).image { _ in
|
||||
image.draw(in: CGRect(origin: .zero, size: image.size))
|
||||
}
|
||||
}
|
||||
|
||||
static func scrubPng(_ data: Data) throws -> Data {
|
||||
let data = Data(data)
|
||||
guard data.count >= pngSignature.count, data.prefix(pngSignature.count) == pngSignature else {
|
||||
throw MediaSanitizationError.invalidPng
|
||||
}
|
||||
|
||||
var output = pngSignature
|
||||
var offset = pngSignature.count
|
||||
while offset < data.count {
|
||||
guard data.count - offset >= 12 else {
|
||||
throw MediaSanitizationError.invalidPng
|
||||
}
|
||||
|
||||
let payloadLengthValue = try readUInt32BigEndian(data, at: offset)
|
||||
guard
|
||||
let payloadLength = Int(exactly: payloadLengthValue),
|
||||
payloadLength <= data.count - offset - 12
|
||||
else {
|
||||
throw MediaSanitizationError.invalidPng
|
||||
}
|
||||
let chunkLength = payloadLength + 12
|
||||
let typeStart = offset + 4
|
||||
let typeEnd = typeStart + 4
|
||||
let typeBytes = data[typeStart..<typeEnd]
|
||||
guard let type = String(bytes: typeBytes, encoding: .ascii) else {
|
||||
throw MediaSanitizationError.invalidPng
|
||||
}
|
||||
|
||||
let isAncillary = typeBytes[typeBytes.startIndex] & 0x20 != 0
|
||||
if !isAncillary || allowedPngAncillaryChunks.contains(type) {
|
||||
output.append(data[offset..<(offset + chunkLength)])
|
||||
}
|
||||
|
||||
offset += chunkLength
|
||||
if type == "IEND" {
|
||||
return output
|
||||
}
|
||||
}
|
||||
|
||||
throw MediaSanitizationError.invalidPng
|
||||
}
|
||||
|
||||
static func scrubJpeg(_ data: Data) throws -> Data {
|
||||
let data = Data(data)
|
||||
guard data.count >= 2, data[0] == 0xFF, data[1] == 0xD8 else {
|
||||
throw MediaSanitizationError.invalidJpeg
|
||||
}
|
||||
|
||||
var output = Data([0xFF, 0xD8])
|
||||
var offset = 2
|
||||
var inScan = false
|
||||
while offset < data.count {
|
||||
if inScan, data[offset] != 0xFF {
|
||||
let nextMarker = data[offset...].firstIndex(of: 0xFF) ?? data.endIndex
|
||||
output.append(data[offset..<nextMarker])
|
||||
offset = nextMarker
|
||||
continue
|
||||
}
|
||||
guard data[offset] == 0xFF else {
|
||||
throw MediaSanitizationError.invalidJpeg
|
||||
}
|
||||
|
||||
let markerStart = offset
|
||||
while offset < data.count, data[offset] == 0xFF {
|
||||
offset += 1
|
||||
}
|
||||
guard offset < data.count else {
|
||||
throw MediaSanitizationError.invalidJpeg
|
||||
}
|
||||
|
||||
let marker = data[offset]
|
||||
offset += 1
|
||||
if inScan, marker == 0x00 {
|
||||
output.append(data[markerStart..<offset])
|
||||
continue
|
||||
}
|
||||
if (0xD0...0xD7).contains(marker) || marker == 0x01 {
|
||||
output.append(data[markerStart..<offset])
|
||||
continue
|
||||
}
|
||||
if marker == 0xD9 {
|
||||
output.append(data[markerStart..<offset])
|
||||
return output
|
||||
}
|
||||
guard marker != 0xD8, data.count - offset >= 2 else {
|
||||
throw MediaSanitizationError.invalidJpeg
|
||||
}
|
||||
|
||||
let segmentLength = try readUInt16BigEndian(data, at: offset)
|
||||
guard segmentLength >= 2, Int(segmentLength) <= data.count - offset else {
|
||||
throw MediaSanitizationError.invalidJpeg
|
||||
}
|
||||
let segmentEnd = offset + Int(segmentLength)
|
||||
if shouldKeepJpegSegment(marker, data: data, payload: (offset + 2)..<segmentEnd) {
|
||||
output.append(data[markerStart..<segmentEnd])
|
||||
}
|
||||
|
||||
offset = segmentEnd
|
||||
inScan = marker == 0xDA
|
||||
}
|
||||
|
||||
throw MediaSanitizationError.invalidJpeg
|
||||
}
|
||||
|
||||
private static func shouldKeepJpegSegment(
|
||||
_ marker: UInt8,
|
||||
data: Data,
|
||||
payload: Range<Int>
|
||||
) -> Bool {
|
||||
switch marker {
|
||||
case 0xE0:
|
||||
guard
|
||||
payload.count >= 14,
|
||||
data[payload.lowerBound..<(payload.lowerBound + 5)].elementsEqual([
|
||||
0x4A, 0x46, 0x49, 0x46, 0x00,
|
||||
])
|
||||
else {
|
||||
return false
|
||||
}
|
||||
let thumbnailWidth = Int(data[payload.lowerBound + 12])
|
||||
let thumbnailHeight = Int(data[payload.lowerBound + 13])
|
||||
return payload.count == 14 + 3 * thumbnailWidth * thumbnailHeight
|
||||
case 0xEE:
|
||||
return payload.count == 12
|
||||
&& data[payload.lowerBound..<(payload.lowerBound + 5)].elementsEqual([
|
||||
0x41, 0x64, 0x6F, 0x62, 0x65,
|
||||
])
|
||||
case 0xE1...0xED, 0xEF, 0xFE:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private static func readUInt16BigEndian(_ data: Data, at offset: Int) throws -> UInt16 {
|
||||
guard data.count - offset >= 2 else {
|
||||
throw MediaSanitizationError.invalidJpeg
|
||||
}
|
||||
return UInt16(data[offset]) << 8 | UInt16(data[offset + 1])
|
||||
}
|
||||
|
||||
private static func readUInt32BigEndian(_ data: Data, at offset: Int) throws -> UInt32 {
|
||||
guard data.count - offset >= 4 else {
|
||||
throw MediaSanitizationError.invalidPng
|
||||
}
|
||||
return UInt32(data[offset]) << 24 | UInt32(data[offset + 1]) << 16
|
||||
| UInt32(data[offset + 2]) << 8 | UInt32(data[offset + 3])
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 896 B |
Binary file not shown.
|
After Width: | Height: | Size: 312 B |
@@ -1,6 +1,7 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
import XCTest
|
||||
|
||||
@testable import Buzz
|
||||
|
||||
class RunnerTests: XCTestCase {
|
||||
@@ -51,4 +52,325 @@ class RunnerTests: XCTestCase {
|
||||
XCTAssertNil(AppDelegate.clipboardImageData(from: pasteboard))
|
||||
}
|
||||
|
||||
func testSanitizePngRemovesUIKitMetadataChunks() throws {
|
||||
let fixture = try fixtureData(named: "UIKitEncoded", extension: "png")
|
||||
XCTAssertEqual(
|
||||
try pngChunkTypes(fixture),
|
||||
[
|
||||
"IHDR", "sRGB", "eXIf", "pHYs", "iDOT", "IDAT", "IDAT", "IEND",
|
||||
])
|
||||
|
||||
let sanitized = try MediaSanitizer.scrubPng(fixture)
|
||||
|
||||
XCTAssertEqual(
|
||||
try pngChunkTypes(sanitized),
|
||||
[
|
||||
"IHDR", "sRGB", "IDAT", "IDAT", "IEND",
|
||||
])
|
||||
try assertMatchesRelayImageMetadataPolicy(sanitized, mimeType: "image/png")
|
||||
XCTAssertNotNil(UIImage(data: sanitized))
|
||||
|
||||
var withTrailingPayload = fixture
|
||||
withTrailingPayload.append(Data("hidden location".utf8))
|
||||
let scrubbedTrailingPayload = try MediaSanitizer.scrubPng(withTrailingPayload)
|
||||
XCTAssertEqual(scrubbedTrailingPayload, sanitized)
|
||||
}
|
||||
|
||||
func testSanitizePngSupportsDataSlices() throws {
|
||||
let fixture = try fixtureData(named: "UIKitEncoded", extension: "png")
|
||||
let padded = Data([0x00]) + fixture
|
||||
let slice = padded.dropFirst()
|
||||
XCTAssertNotEqual(slice.startIndex, 0)
|
||||
|
||||
let sanitized = try MediaSanitizer.scrubPng(slice)
|
||||
|
||||
try assertMatchesRelayImageMetadataPolicy(sanitized, mimeType: "image/png")
|
||||
XCTAssertNotNil(UIImage(data: sanitized))
|
||||
}
|
||||
|
||||
func testSanitizeJpegRemovesUIKitMetadataSegments() throws {
|
||||
let fixture = try fixtureData(named: "UIKitEncoded", extension: "jpg")
|
||||
XCTAssertEqual(try jpegMetadataMarkers(fixture), [0xE0, 0xE1, 0xED])
|
||||
|
||||
let sanitized = try MediaSanitizer.scrubJpeg(fixture)
|
||||
|
||||
XCTAssertEqual(try jpegMetadataMarkers(sanitized), [0xE0])
|
||||
try assertMatchesRelayImageMetadataPolicy(sanitized, mimeType: "image/jpeg")
|
||||
XCTAssertNotNil(UIImage(data: sanitized))
|
||||
|
||||
var withTrailingPayload = fixture
|
||||
withTrailingPayload.append(Data("hidden location".utf8))
|
||||
let scrubbedTrailingPayload = try MediaSanitizer.scrubJpeg(withTrailingPayload)
|
||||
XCTAssertEqual(scrubbedTrailingPayload, sanitized)
|
||||
}
|
||||
|
||||
func testSanitizeJpegSupportsDataSlices() throws {
|
||||
let fixture = try fixtureData(named: "UIKitEncoded", extension: "jpg")
|
||||
let padded = Data([0x00]) + fixture
|
||||
let slice = padded.dropFirst()
|
||||
XCTAssertNotEqual(slice.startIndex, 0)
|
||||
|
||||
let sanitized = try MediaSanitizer.scrubJpeg(slice)
|
||||
|
||||
try assertMatchesRelayImageMetadataPolicy(sanitized, mimeType: "image/jpeg")
|
||||
XCTAssertNotNil(UIImage(data: sanitized))
|
||||
}
|
||||
|
||||
func testEncodeJpegScrubsUIKitOutput() throws {
|
||||
let fixture = try fixtureData(named: "UIKitEncoded", extension: "jpg")
|
||||
let image = try XCTUnwrap(UIImage(data: fixture))
|
||||
|
||||
let encoded = try XCTUnwrap(MediaSanitizer.encodeJpeg(image))
|
||||
|
||||
try assertMatchesRelayImageMetadataPolicy(encoded, mimeType: "image/jpeg")
|
||||
XCTAssertNotNil(UIImage(data: encoded))
|
||||
}
|
||||
|
||||
func testSanitizeDisplayP3ImagePreservesRenderedColorInSRGB() throws {
|
||||
let image = try displayP3Image(red: 0.9, green: 0.2, blue: 0.1)
|
||||
let expectedColor = try sRGBPixel(from: image)
|
||||
let mimeTypesAndAccuracy: [(mimeType: String, accuracy: UInt8)] = [
|
||||
("image/png", 0), ("image/jpeg", 1),
|
||||
]
|
||||
|
||||
for (mimeType, accuracy) in mimeTypesAndAccuracy {
|
||||
let sanitized = try XCTUnwrap(
|
||||
MediaSanitizer.sanitizeImage(image, mimeType: mimeType),
|
||||
"Failed to sanitize Display-P3 image as \(mimeType)"
|
||||
)
|
||||
|
||||
try assertMatchesRelayImageMetadataPolicy(sanitized, mimeType: mimeType)
|
||||
let decoded = try XCTUnwrap(UIImage(data: sanitized))
|
||||
XCTAssertEqual(
|
||||
decoded.cgImage?.colorSpace?.name,
|
||||
CGColorSpace(name: CGColorSpace.sRGB)?.name
|
||||
)
|
||||
let actualColor = try sRGBPixel(from: decoded)
|
||||
XCTAssertEqual(actualColor.count, expectedColor.count)
|
||||
for (actual, expected) in zip(actualColor, expectedColor) {
|
||||
XCTAssertLessThanOrEqual(
|
||||
actual > expected ? actual - expected : expected - actual,
|
||||
accuracy
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func displayP3Image(red: CGFloat, green: CGFloat, blue: CGFloat) throws -> UIImage {
|
||||
let colorSpace = try XCTUnwrap(CGColorSpace(name: CGColorSpace.displayP3))
|
||||
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)
|
||||
let context = try XCTUnwrap(
|
||||
CGContext(
|
||||
data: nil,
|
||||
width: 1,
|
||||
height: 1,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: 4,
|
||||
space: colorSpace,
|
||||
bitmapInfo: bitmapInfo.rawValue
|
||||
)
|
||||
)
|
||||
context.setFillColor(
|
||||
try XCTUnwrap(CGColor(colorSpace: colorSpace, components: [red, green, blue, 1]))
|
||||
)
|
||||
context.fill(CGRect(x: 0, y: 0, width: 1, height: 1))
|
||||
return UIImage(cgImage: try XCTUnwrap(context.makeImage()))
|
||||
}
|
||||
|
||||
private func sRGBPixel(from image: UIImage) throws -> [UInt8] {
|
||||
let colorSpace = try XCTUnwrap(CGColorSpace(name: CGColorSpace.sRGB))
|
||||
var bytes = [UInt8](repeating: 0, count: 4)
|
||||
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)
|
||||
let context = try bytes.withUnsafeMutableBytes { bytes in
|
||||
try XCTUnwrap(
|
||||
CGContext(
|
||||
data: bytes.baseAddress,
|
||||
width: 1,
|
||||
height: 1,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: 4,
|
||||
space: colorSpace,
|
||||
bitmapInfo: bitmapInfo.rawValue
|
||||
)
|
||||
)
|
||||
}
|
||||
context.interpolationQuality = .none
|
||||
context.draw(try XCTUnwrap(image.cgImage), in: CGRect(x: 0, y: 0, width: 1, height: 1))
|
||||
return bytes
|
||||
}
|
||||
|
||||
private func fixtureData(named name: String, extension fileExtension: String) throws -> Data {
|
||||
let url = try XCTUnwrap(
|
||||
Bundle(for: RunnerTests.self).url(forResource: name, withExtension: fileExtension))
|
||||
return try Data(contentsOf: url)
|
||||
}
|
||||
}
|
||||
|
||||
private enum RelayImagePolicyError: Error {
|
||||
case invalidPng
|
||||
case invalidJpeg
|
||||
case metadataForbidden
|
||||
}
|
||||
|
||||
private let pngSignature = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
|
||||
private let allowedPngAncillaryChunks: Set<String> = [
|
||||
"cHRM", "gAMA", "sBIT", "sRGB", "bKGD", "hIST", "tRNS", "sPLT", "acTL", "fcTL", "fdAT",
|
||||
]
|
||||
|
||||
private func assertMatchesRelayImageMetadataPolicy(_ data: Data, mimeType: String) throws {
|
||||
switch mimeType {
|
||||
case "image/png":
|
||||
guard data.count >= pngSignature.count, data.prefix(pngSignature.count) == pngSignature else {
|
||||
throw RelayImagePolicyError.invalidPng
|
||||
}
|
||||
var offset = pngSignature.count
|
||||
while offset < data.count {
|
||||
guard data.count - offset >= 12 else { throw RelayImagePolicyError.invalidPng }
|
||||
let payloadLength = Int(try readUInt32BigEndian(data, at: offset))
|
||||
guard payloadLength <= data.count - offset - 12 else {
|
||||
throw RelayImagePolicyError.invalidPng
|
||||
}
|
||||
let typeBytes = data[(offset + 4)..<(offset + 8)]
|
||||
guard let type = String(bytes: typeBytes, encoding: .ascii) else {
|
||||
throw RelayImagePolicyError.invalidPng
|
||||
}
|
||||
let chunkEnd = offset + payloadLength + 12
|
||||
let isAncillary = typeBytes[typeBytes.startIndex] & 0x20 != 0
|
||||
if isAncillary, !allowedPngAncillaryChunks.contains(type) {
|
||||
throw RelayImagePolicyError.metadataForbidden
|
||||
}
|
||||
offset = chunkEnd
|
||||
if type == "IEND" {
|
||||
guard offset == data.count else { throw RelayImagePolicyError.metadataForbidden }
|
||||
return
|
||||
}
|
||||
}
|
||||
throw RelayImagePolicyError.invalidPng
|
||||
case "image/jpeg":
|
||||
guard data.count >= 2, data[0] == 0xFF, data[1] == 0xD8 else {
|
||||
throw RelayImagePolicyError.invalidJpeg
|
||||
}
|
||||
var offset = 2
|
||||
var inScan = false
|
||||
while offset < data.count {
|
||||
if inScan, data[offset] != 0xFF {
|
||||
offset += 1
|
||||
continue
|
||||
}
|
||||
guard data[offset] == 0xFF else { throw RelayImagePolicyError.invalidJpeg }
|
||||
while offset < data.count, data[offset] == 0xFF { offset += 1 }
|
||||
guard offset < data.count else { throw RelayImagePolicyError.invalidJpeg }
|
||||
let marker = data[offset]
|
||||
offset += 1
|
||||
if inScan, marker == 0x00 { continue }
|
||||
if (0xD0...0xD7).contains(marker) || marker == 0x01 { continue }
|
||||
if marker == 0xD9 {
|
||||
guard offset == data.count else { throw RelayImagePolicyError.metadataForbidden }
|
||||
return
|
||||
}
|
||||
guard marker != 0xD8, data.count - offset >= 2 else {
|
||||
throw RelayImagePolicyError.invalidJpeg
|
||||
}
|
||||
let length = Int(try readUInt16BigEndian(data, at: offset))
|
||||
guard length >= 2, length <= data.count - offset else {
|
||||
throw RelayImagePolicyError.invalidJpeg
|
||||
}
|
||||
let payload = (offset + 2)..<(offset + length)
|
||||
if marker == 0xE0 {
|
||||
guard
|
||||
payload.count >= 14,
|
||||
data[payload.lowerBound..<(payload.lowerBound + 5)].elementsEqual([
|
||||
0x4A, 0x46, 0x49, 0x46, 0x00,
|
||||
]),
|
||||
payload.count
|
||||
== 14 + 3 * Int(data[payload.lowerBound + 12]) * Int(data[payload.lowerBound + 13])
|
||||
else {
|
||||
throw RelayImagePolicyError.metadataForbidden
|
||||
}
|
||||
} else if marker == 0xEE {
|
||||
guard
|
||||
payload.count == 12,
|
||||
data[payload.lowerBound..<(payload.lowerBound + 5)].elementsEqual([
|
||||
0x41, 0x64, 0x6F, 0x62, 0x65,
|
||||
])
|
||||
else {
|
||||
throw RelayImagePolicyError.metadataForbidden
|
||||
}
|
||||
} else if (0xE1...0xED).contains(marker) || marker == 0xEF || marker == 0xFE {
|
||||
throw RelayImagePolicyError.metadataForbidden
|
||||
}
|
||||
offset += length
|
||||
inScan = marker == 0xDA
|
||||
}
|
||||
throw RelayImagePolicyError.invalidJpeg
|
||||
default:
|
||||
XCTFail("Unsupported test MIME type: \(mimeType)")
|
||||
}
|
||||
}
|
||||
|
||||
private func pngChunkTypes(_ data: Data) throws -> [String] {
|
||||
guard data.count >= pngSignature.count, data.prefix(pngSignature.count) == pngSignature else {
|
||||
throw RelayImagePolicyError.invalidPng
|
||||
}
|
||||
var result: [String] = []
|
||||
var offset = pngSignature.count
|
||||
while offset < data.count {
|
||||
guard data.count - offset >= 12 else { throw RelayImagePolicyError.invalidPng }
|
||||
let payloadLength = Int(try readUInt32BigEndian(data, at: offset))
|
||||
guard payloadLength <= data.count - offset - 12 else { throw RelayImagePolicyError.invalidPng }
|
||||
guard let type = String(bytes: data[(offset + 4)..<(offset + 8)], encoding: .ascii) else {
|
||||
throw RelayImagePolicyError.invalidPng
|
||||
}
|
||||
result.append(type)
|
||||
offset += payloadLength + 12
|
||||
if type == "IEND" { return result }
|
||||
}
|
||||
throw RelayImagePolicyError.invalidPng
|
||||
}
|
||||
|
||||
private func jpegMetadataMarkers(_ data: Data) throws -> [UInt8] {
|
||||
guard data.count >= 2, data[0] == 0xFF, data[1] == 0xD8 else {
|
||||
throw RelayImagePolicyError.invalidJpeg
|
||||
}
|
||||
var result: [UInt8] = []
|
||||
var offset = 2
|
||||
var inScan = false
|
||||
while offset < data.count {
|
||||
if inScan, data[offset] != 0xFF {
|
||||
offset += 1
|
||||
continue
|
||||
}
|
||||
guard data[offset] == 0xFF else { throw RelayImagePolicyError.invalidJpeg }
|
||||
while offset < data.count, data[offset] == 0xFF { offset += 1 }
|
||||
guard offset < data.count else { throw RelayImagePolicyError.invalidJpeg }
|
||||
let marker = data[offset]
|
||||
offset += 1
|
||||
if inScan, marker == 0x00 { continue }
|
||||
if (0xD0...0xD7).contains(marker) || marker == 0x01 { continue }
|
||||
if marker == 0xD9 { return result }
|
||||
guard marker != 0xD8, data.count - offset >= 2 else {
|
||||
throw RelayImagePolicyError.invalidJpeg
|
||||
}
|
||||
let length = Int(try readUInt16BigEndian(data, at: offset))
|
||||
guard length >= 2, length <= data.count - offset else {
|
||||
throw RelayImagePolicyError.invalidJpeg
|
||||
}
|
||||
if (0xE0...0xEF).contains(marker) || marker == 0xFE {
|
||||
result.append(marker)
|
||||
}
|
||||
offset += length
|
||||
inScan = marker == 0xDA
|
||||
}
|
||||
throw RelayImagePolicyError.invalidJpeg
|
||||
}
|
||||
|
||||
private func readUInt16BigEndian(_ data: Data, at offset: Int) throws -> UInt16 {
|
||||
guard data.count - offset >= 2 else { throw RelayImagePolicyError.invalidJpeg }
|
||||
return UInt16(data[offset]) << 8 | UInt16(data[offset + 1])
|
||||
}
|
||||
|
||||
private func readUInt32BigEndian(_ data: Data, at offset: Int) throws -> UInt32 {
|
||||
guard data.count - offset >= 4 else { throw RelayImagePolicyError.invalidPng }
|
||||
return UInt32(data[offset]) << 24 | UInt32(data[offset + 1]) << 16
|
||||
| UInt32(data[offset + 2]) << 8 | UInt32(data[offset + 3])
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ const _unsupportedAnimatedPngUploadMessage =
|
||||
'Animated PNG uploads are not supported on mobile yet';
|
||||
const _unsupportedAnimatedWebpUploadMessage =
|
||||
'Animated WebP uploads are not supported on mobile yet';
|
||||
const _mediaPolicyUploadMessage = "We couldn't prepare this image for upload.";
|
||||
|
||||
typedef PickGalleryImage = Future<XFile?> Function();
|
||||
typedef PickGalleryVideo = Future<XFile?> Function();
|
||||
@@ -56,6 +57,13 @@ typedef TranscodeImageToJpeg = Future<Uint8List> Function(Uint8List bytes);
|
||||
typedef TranscodeVideoToMp4 = Future<String> Function(String filePath);
|
||||
typedef ReadClipboardImage = Future<Uint8List?> Function();
|
||||
|
||||
class MediaPolicyUploadException implements Exception {
|
||||
const MediaPolicyUploadException();
|
||||
|
||||
@override
|
||||
String toString() => _mediaPolicyUploadMessage;
|
||||
}
|
||||
|
||||
@immutable
|
||||
class _PreparedUploadImage {
|
||||
final Uint8List bytes;
|
||||
@@ -256,6 +264,11 @@ class MediaUploadService {
|
||||
response = await http.Response.fromStream(streamed);
|
||||
}
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
if (_allowedImageMimeTypes.contains(mimeType) &&
|
||||
(response.statusCode == HttpStatus.unsupportedMediaType ||
|
||||
response.statusCode == HttpStatus.unprocessableEntity)) {
|
||||
throw const MediaPolicyUploadException();
|
||||
}
|
||||
throw Exception(
|
||||
'upload failed (${response.statusCode}): ${response.body}',
|
||||
);
|
||||
|
||||
@@ -810,6 +810,54 @@ void main() {
|
||||
expect(find.textContaining('upload failed'), findsOneWidget);
|
||||
});
|
||||
|
||||
for (final statusCode in [
|
||||
HttpStatus.unsupportedMediaType,
|
||||
HttpStatus.unprocessableEntity,
|
||||
]) {
|
||||
testWidgets('shows friendly copy for a $statusCode upload response', (
|
||||
tester,
|
||||
) async {
|
||||
final keychain = nostr.Keys.generate();
|
||||
final uploadService = MediaUploadService(
|
||||
baseUrl: 'https://relay.example',
|
||||
nsec: keychain.nsec,
|
||||
httpClient: http_testing.MockClient(
|
||||
(request) async => http.Response(
|
||||
'{"error":"media contains metadata or a non-canonical metadata channel"}',
|
||||
statusCode,
|
||||
),
|
||||
),
|
||||
pickGalleryVideo: () async => null,
|
||||
pickGalleryImage: () async =>
|
||||
XFile.fromData(_pngBytes, name: 'tiny.png'),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildComposeBar(
|
||||
uploadService: uploadService,
|
||||
onSend:
|
||||
(
|
||||
content,
|
||||
mentionPubkeys, {
|
||||
mediaTags = const <List<String>>[],
|
||||
}) async {},
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.byIcon(LucideIcons.paperclip));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Photo'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text("We couldn't prepare this image for upload."),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.textContaining('media contains metadata'), findsNothing);
|
||||
expect(find.textContaining('$statusCode'), findsNothing);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('shows a clean error when a GIF is picked', (tester) async {
|
||||
final keychain = nostr.Keys.generate();
|
||||
final nsec = keychain.nsec;
|
||||
|
||||
@@ -438,6 +438,72 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
for (final statusCode in [
|
||||
HttpStatus.unsupportedMediaType,
|
||||
HttpStatus.unprocessableEntity,
|
||||
]) {
|
||||
test(
|
||||
'maps $statusCode media policy responses to friendly copy',
|
||||
() async {
|
||||
final service = MediaUploadService(
|
||||
baseUrl: 'https://relay.example',
|
||||
nsec: nostr.Keys.generate().nsec,
|
||||
httpClient: http_testing.MockClient(
|
||||
(request) async => http.Response(
|
||||
'{"error":"media contains metadata"}',
|
||||
statusCode,
|
||||
),
|
||||
),
|
||||
pickGalleryVideo: () async => null,
|
||||
pickGalleryImage: () async => null,
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
service.uploadBytes(_pngBytes, mimeType: 'image/png'),
|
||||
throwsA(
|
||||
isA<MediaPolicyUploadException>().having(
|
||||
(error) => error.toString(),
|
||||
'message',
|
||||
"We couldn't prepare this image for upload.",
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test('preserves video policy response details', () async {
|
||||
final service = MediaUploadService(
|
||||
baseUrl: 'https://relay.example',
|
||||
nsec: nostr.Keys.generate().nsec,
|
||||
httpClient: http_testing.MockClient(
|
||||
(request) async => http.Response(
|
||||
'{"error":"unsupported video codec"}',
|
||||
HttpStatus.unprocessableEntity,
|
||||
),
|
||||
),
|
||||
pickGalleryVideo: () async => null,
|
||||
pickGalleryImage: () async => null,
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
service.uploadBytes(Uint8List(0), mimeType: 'video/mp4'),
|
||||
throwsA(
|
||||
isA<Exception>()
|
||||
.having(
|
||||
(error) => error,
|
||||
'type',
|
||||
isNot(isA<MediaPolicyUploadException>()),
|
||||
)
|
||||
.having(
|
||||
(error) => error.toString(),
|
||||
'message',
|
||||
contains('unsupported video codec'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'checks clipboard image availability through the platform channel',
|
||||
() async {
|
||||
|
||||
Reference in New Issue
Block a user