9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
470 lines
11 KiB
Dart
470 lines
11 KiB
Dart
import 'dart:convert';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:buzz/shared/relay/animated_image_sanitizer.dart';
|
|
|
|
void main() {
|
|
test('strips APNG metadata without changing animation chunks', () {
|
|
final clean = _animatedPng(metadata: false);
|
|
final dirty = _animatedPng(metadata: true)
|
|
..addAll(utf8.encode('trailing metadata'));
|
|
|
|
expect(
|
|
sanitizeAnimatedImageForUpload(Uint8List.fromList(dirty), 'image/png'),
|
|
clean,
|
|
);
|
|
});
|
|
|
|
test('strips identity APNG orientation but rejects display transforms', () {
|
|
final clean = _animatedPng(metadata: false);
|
|
expect(
|
|
sanitizeAnimatedImageForUpload(
|
|
Uint8List.fromList(_animatedPng(metadata: false, orientation: 1)),
|
|
'image/png',
|
|
),
|
|
clean,
|
|
);
|
|
|
|
for (final endian in [Endian.little, Endian.big]) {
|
|
expect(
|
|
() => sanitizeAnimatedImageForUpload(
|
|
Uint8List.fromList(
|
|
_animatedPng(
|
|
metadata: false,
|
|
orientation: 6,
|
|
orientationEndian: endian,
|
|
),
|
|
),
|
|
'image/png',
|
|
),
|
|
throwsA(
|
|
isA<FormatException>().having(
|
|
(error) => error.message,
|
|
'message',
|
|
contains('orientation'),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
});
|
|
|
|
test('rejects APNG ICC profiles that affect color rendering', () {
|
|
expect(
|
|
() => sanitizeAnimatedImageForUpload(
|
|
Uint8List.fromList(_animatedPng(metadata: false, iccProfile: true)),
|
|
'image/png',
|
|
),
|
|
throwsA(
|
|
isA<FormatException>().having(
|
|
(error) => error.message,
|
|
'message',
|
|
contains('ICC profile'),
|
|
),
|
|
),
|
|
);
|
|
});
|
|
|
|
test('strips animated WebP metadata and clears metadata flags', () {
|
|
final clean = _animatedWebp(metadata: false);
|
|
final dirty = _animatedWebp(metadata: true)
|
|
..addAll(utf8.encode('trailing metadata'));
|
|
|
|
expect(
|
|
sanitizeAnimatedImageForUpload(Uint8List.fromList(dirty), 'image/webp'),
|
|
clean,
|
|
);
|
|
});
|
|
|
|
test('strips identity WebP orientation but rejects display transforms', () {
|
|
final clean = _animatedWebp(metadata: false);
|
|
expect(
|
|
sanitizeAnimatedImageForUpload(
|
|
Uint8List.fromList(_animatedWebp(metadata: false, orientation: 1)),
|
|
'image/webp',
|
|
),
|
|
clean,
|
|
);
|
|
|
|
for (final endian in [Endian.little, Endian.big]) {
|
|
expect(
|
|
() => sanitizeAnimatedImageForUpload(
|
|
Uint8List.fromList(
|
|
_animatedWebp(
|
|
metadata: false,
|
|
orientation: 6,
|
|
orientationEndian: endian,
|
|
),
|
|
),
|
|
'image/webp',
|
|
),
|
|
throwsA(
|
|
isA<FormatException>().having(
|
|
(error) => error.message,
|
|
'message',
|
|
contains('orientation'),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
});
|
|
|
|
test('rejects animated WebP ICC profiles that affect color rendering', () {
|
|
expect(
|
|
() => sanitizeAnimatedImageForUpload(
|
|
Uint8List.fromList(_animatedWebp(metadata: false, iccProfile: true)),
|
|
'image/webp',
|
|
),
|
|
throwsA(
|
|
isA<FormatException>().having(
|
|
(error) => error.message,
|
|
'message',
|
|
contains('ICC profile'),
|
|
),
|
|
),
|
|
);
|
|
});
|
|
|
|
test('removes metadata chunks nested inside animated WebP frames', () {
|
|
expect(
|
|
sanitizeAnimatedImageForUpload(
|
|
Uint8List.fromList(
|
|
_animatedWebp(metadata: false, nestedMetadata: true),
|
|
),
|
|
'image/webp',
|
|
),
|
|
_animatedWebp(metadata: false),
|
|
);
|
|
});
|
|
|
|
test('strips GIF metadata without changing animation blocks', () {
|
|
final clean = _minimalGif();
|
|
final dirty = <int>[
|
|
...clean.sublist(0, 19),
|
|
..._gifCommentExtension(),
|
|
..._gifApplicationExtension('XMP DataXMP', utf8.encode('<x/>')),
|
|
...clean.sublist(19),
|
|
...utf8.encode('trailing metadata'),
|
|
];
|
|
|
|
expect(
|
|
sanitizeAnimatedImageForUpload(Uint8List.fromList(dirty), 'image/gif'),
|
|
clean,
|
|
);
|
|
});
|
|
|
|
test('canonicalizes GIF loop extensions with hidden sub-blocks', () {
|
|
final clean = _minimalGif();
|
|
final cleanLoop = _gifLoopExtension();
|
|
final dirty = <int>[
|
|
...clean.sublist(0, 19),
|
|
..._gifLoopExtension(extra: utf8.encode('location')),
|
|
...clean.sublist(19 + cleanLoop.length),
|
|
];
|
|
|
|
expect(
|
|
sanitizeAnimatedImageForUpload(Uint8List.fromList(dirty), 'image/gif'),
|
|
clean,
|
|
);
|
|
});
|
|
|
|
test('drops GIF applications with binary authentication codes', () {
|
|
final clean = _minimalGif();
|
|
final dirty = <int>[
|
|
...clean.sublist(0, 19),
|
|
..._gifApplicationExtensionBytes([
|
|
...ascii.encode('FOREIGN1'),
|
|
0xff,
|
|
0x80,
|
|
0x00,
|
|
], utf8.encode('private')),
|
|
...clean.sublist(19),
|
|
];
|
|
|
|
expect(
|
|
sanitizeAnimatedImageForUpload(Uint8List.fromList(dirty), 'image/gif'),
|
|
clean,
|
|
);
|
|
});
|
|
|
|
test('removes a GIF graphic control consumed by stripped plain text', () {
|
|
final clean = _minimalGif();
|
|
final dirty = <int>[
|
|
...clean.sublist(0, 19),
|
|
0x21,
|
|
0xf9,
|
|
4,
|
|
0x09,
|
|
0x1e,
|
|
0,
|
|
1,
|
|
0,
|
|
..._gifPlainTextExtension(),
|
|
...clean.sublist(19),
|
|
];
|
|
|
|
expect(
|
|
sanitizeAnimatedImageForUpload(Uint8List.fromList(dirty), 'image/gif'),
|
|
clean,
|
|
);
|
|
});
|
|
|
|
test('keeps clean animated containers byte-identical', () {
|
|
for (final (mimeType, bytes) in [
|
|
('image/png', _animatedPng(metadata: false)),
|
|
('image/webp', _animatedWebp(metadata: false)),
|
|
('image/gif', _minimalGif()),
|
|
]) {
|
|
expect(
|
|
sanitizeAnimatedImageForUpload(Uint8List.fromList(bytes), mimeType),
|
|
bytes,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('fails closed for malformed animated containers', () {
|
|
for (final (mimeType, bytes) in [
|
|
('image/png', <int>[0x89, 0x50, 0x4e, 0x47]),
|
|
('image/webp', ascii.encode('RIFFxxxxWEBP')),
|
|
('image/gif', ascii.encode('GIF89a')),
|
|
]) {
|
|
expect(
|
|
() =>
|
|
sanitizeAnimatedImageForUpload(Uint8List.fromList(bytes), mimeType),
|
|
throwsFormatException,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
List<int> _pngChunk(String type, List<int> payload) {
|
|
return [
|
|
..._uint32BigEndian(payload.length),
|
|
...ascii.encode(type),
|
|
...payload,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
];
|
|
}
|
|
|
|
List<int> _animatedPng({
|
|
required bool metadata,
|
|
int? orientation,
|
|
Endian orientationEndian = Endian.little,
|
|
bool iccProfile = false,
|
|
}) {
|
|
return [
|
|
0x89,
|
|
0x50,
|
|
0x4e,
|
|
0x47,
|
|
0x0d,
|
|
0x0a,
|
|
0x1a,
|
|
0x0a,
|
|
..._pngChunk('IHDR', List.filled(13, 0)),
|
|
..._pngChunk('acTL', [0, 0, 0, 2, 0, 0, 0, 0]),
|
|
if (iccProfile) ..._pngChunk('iCCP', utf8.encode('profile')),
|
|
if (orientation != null)
|
|
..._pngChunk(
|
|
'eXIf',
|
|
_exifOrientation(
|
|
orientation,
|
|
orientationEndian,
|
|
includePreamble: false,
|
|
),
|
|
),
|
|
if (metadata) ..._pngChunk('tEXt', utf8.encode('Location\u0000secret')),
|
|
if (metadata) ..._pngChunk('pHYs', List.filled(9, 0)),
|
|
..._pngChunk('fcTL', List.filled(26, 0)),
|
|
..._pngChunk('IDAT', [1, 2, 3]),
|
|
..._pngChunk('fdAT', [0, 0, 0, 1, 4, 5]),
|
|
..._pngChunk('IEND', const []),
|
|
];
|
|
}
|
|
|
|
List<int> _webpChunk(String type, List<int> payload) {
|
|
return [
|
|
...ascii.encode(type),
|
|
..._uint32LittleEndian(payload.length),
|
|
...payload,
|
|
if (payload.length.isOdd) 0,
|
|
];
|
|
}
|
|
|
|
List<int> _animatedWebp({
|
|
required bool metadata,
|
|
int? orientation,
|
|
Endian orientationEndian = Endian.little,
|
|
bool iccProfile = false,
|
|
bool nestedMetadata = false,
|
|
}) {
|
|
final hasExif = metadata || orientation != null;
|
|
final frame = <int>[
|
|
...List.filled(16, 0),
|
|
..._webpChunk('VP8 ', [1, 2, 3]),
|
|
if (nestedMetadata) ..._webpChunk('JUNK', utf8.encode('location')),
|
|
];
|
|
final chunks = <int>[
|
|
..._webpChunk('VP8X', [
|
|
0x02 | (hasExif ? 0x0c : 0) | (iccProfile ? 0x20 : 0),
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
]),
|
|
..._webpChunk('ANIM', List.filled(6, 0)),
|
|
if (iccProfile) ..._webpChunk('ICCP', utf8.encode('profile')),
|
|
if (orientation != null)
|
|
..._webpChunk(
|
|
'EXIF',
|
|
_exifOrientation(orientation, orientationEndian, includePreamble: true),
|
|
)
|
|
else if (metadata)
|
|
..._webpChunk('EXIF', utf8.encode('location')),
|
|
if (metadata) ..._webpChunk('XMP ', utf8.encode('<xmp/>')),
|
|
if (metadata) ..._webpChunk('JUNK', utf8.encode('private')),
|
|
..._webpChunk('ANMF', frame),
|
|
];
|
|
return [
|
|
...ascii.encode('RIFF'),
|
|
..._uint32LittleEndian(chunks.length + 4),
|
|
...ascii.encode('WEBP'),
|
|
...chunks,
|
|
];
|
|
}
|
|
|
|
List<int> _exifOrientation(
|
|
int orientation,
|
|
Endian endian, {
|
|
required bool includePreamble,
|
|
}) {
|
|
final bytes = BytesBuilder();
|
|
if (includePreamble) {
|
|
bytes
|
|
..add(ascii.encode('Exif'))
|
|
..add(const [0, 0]);
|
|
}
|
|
final tiff = ByteData(26);
|
|
if (endian == Endian.little) {
|
|
tiff.setUint8(0, 0x49);
|
|
tiff.setUint8(1, 0x49);
|
|
} else {
|
|
tiff.setUint8(0, 0x4d);
|
|
tiff.setUint8(1, 0x4d);
|
|
}
|
|
tiff.setUint16(2, 42, endian);
|
|
tiff.setUint32(4, 8, endian);
|
|
tiff.setUint16(8, 1, endian);
|
|
tiff.setUint16(10, 0x0112, endian);
|
|
tiff.setUint16(12, 3, endian);
|
|
tiff.setUint32(14, 1, endian);
|
|
tiff.setUint16(18, orientation, endian);
|
|
tiff.setUint32(22, 0, endian);
|
|
bytes.add(tiff.buffer.asUint8List());
|
|
return bytes.takeBytes();
|
|
}
|
|
|
|
List<int> _minimalGif() {
|
|
return [
|
|
...ascii.encode('GIF89a'),
|
|
2,
|
|
0,
|
|
2,
|
|
0,
|
|
0x80,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0xff,
|
|
0xff,
|
|
0xff,
|
|
..._gifLoopExtension(),
|
|
0x21,
|
|
0xf9,
|
|
4,
|
|
0,
|
|
10,
|
|
0,
|
|
0,
|
|
0,
|
|
0x2c,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
2,
|
|
0,
|
|
2,
|
|
0,
|
|
0,
|
|
2,
|
|
2,
|
|
0x44,
|
|
1,
|
|
0,
|
|
0x3b,
|
|
];
|
|
}
|
|
|
|
List<int> _gifLoopExtension({List<int> extra = const []}) {
|
|
return [
|
|
0x21,
|
|
0xff,
|
|
11,
|
|
...ascii.encode('NETSCAPE2.0'),
|
|
3,
|
|
1,
|
|
0,
|
|
0,
|
|
if (extra.isNotEmpty) ...[extra.length, ...extra],
|
|
0,
|
|
];
|
|
}
|
|
|
|
List<int> _gifCommentExtension() {
|
|
return [0x21, 0xfe, 5, ...ascii.encode('hello'), 0];
|
|
}
|
|
|
|
List<int> _gifApplicationExtension(String identifier, List<int> payload) {
|
|
return _gifApplicationExtensionBytes(ascii.encode(identifier), payload);
|
|
}
|
|
|
|
List<int> _gifApplicationExtensionBytes(
|
|
List<int> identifier,
|
|
List<int> payload,
|
|
) {
|
|
return [0x21, 0xff, 11, ...identifier, payload.length, ...payload, 0];
|
|
}
|
|
|
|
List<int> _gifPlainTextExtension() {
|
|
return [0x21, 0x01, 12, 0, 0, 0, 0, 2, 0, 2, 0, 1, 1, 1, 0, 1, 0x78, 0];
|
|
}
|
|
|
|
List<int> _uint32BigEndian(int value) {
|
|
return [
|
|
value >> 24 & 0xff,
|
|
value >> 16 & 0xff,
|
|
value >> 8 & 0xff,
|
|
value & 0xff,
|
|
];
|
|
}
|
|
|
|
List<int> _uint32LittleEndian(int value) {
|
|
return [
|
|
value & 0xff,
|
|
value >> 8 & 0xff,
|
|
value >> 16 & 0xff,
|
|
value >> 24 & 0xff,
|
|
];
|
|
}
|