From e2c2e10a9053413a057178ee19d393a0741952e6 Mon Sep 17 00:00:00 2001 From: rahulshahdev Date: Sun, 25 Oct 2026 13:48:45 +0545 Subject: [PATCH 1/4] fix: preserve URL extensions for generic downloads --- .../lib/src/compat/file_service_compat.dart | 18 +++++++----------- .../lib/src/web/file_service.dart | 18 +++++++----------- .../lib/src/web/mime_converter.dart | 19 +++++++++++++++++++ 3 files changed, 33 insertions(+), 22 deletions(-) diff --git a/flutter_cache_manager/lib/src/compat/file_service_compat.dart b/flutter_cache_manager/lib/src/compat/file_service_compat.dart index 7d6fdbc6..e4781696 100644 --- a/flutter_cache_manager/lib/src/compat/file_service_compat.dart +++ b/flutter_cache_manager/lib/src/compat/file_service_compat.dart @@ -15,15 +15,16 @@ class FileServiceCompat extends FileService { Map? headers, }) async { final legacyResponse = await fileFetcher(url, headers: headers); - return CompatFileServiceGetResponse(legacyResponse); + return CompatFileServiceGetResponse(legacyResponse, Uri.parse(url)); } } class CompatFileServiceGetResponse implements FileServiceResponse { final FileFetcherResponse legacyResponse; + final Uri _uri; final DateTime _receivedTime = clock.now(); - CompatFileServiceGetResponse(this.legacyResponse); + CompatFileServiceGetResponse(this.legacyResponse, this._uri); String? _header(String name) { return legacyResponse.header(name); @@ -64,15 +65,10 @@ class CompatFileServiceGetResponse implements FileServiceResponse { String? get eTag => _header(HttpHeaders.etagHeader); @override - String get fileExtension { - var fileExtension = ''; - final contentTypeHeader = _header(HttpHeaders.contentTypeHeader); - if (contentTypeHeader != null) { - final contentType = ContentType.parse(contentTypeHeader); - fileExtension = contentType.fileExtension; - } - return fileExtension; - } + String get fileExtension => fileExtensionFromContentTypeOrUri( + _header(HttpHeaders.contentTypeHeader), + _uri, + ); @override int get statusCode => legacyResponse.statusCode as int; diff --git a/flutter_cache_manager/lib/src/web/file_service.dart b/flutter_cache_manager/lib/src/web/file_service.dart index 376d672e..d7cca0d4 100644 --- a/flutter_cache_manager/lib/src/web/file_service.dart +++ b/flutter_cache_manager/lib/src/web/file_service.dart @@ -38,7 +38,7 @@ class HttpFileService extends FileService { } final httpResponse = await _httpClient.send(req); - return HttpGetResponse(httpResponse); + return HttpGetResponse(httpResponse, Uri.parse(url)); } } @@ -66,11 +66,12 @@ abstract class FileServiceResponse { /// Basic implementation of a [FileServiceResponse] for http requests. class HttpGetResponse implements FileServiceResponse { - HttpGetResponse(this._response); + HttpGetResponse(this._response, this._uri); final DateTime _receivedTime = clock.now(); final http.StreamedResponse _response; + final Uri _uri; @override int get statusCode => _response.statusCode; @@ -114,13 +115,8 @@ class HttpGetResponse implements FileServiceResponse { String? get eTag => _header(HttpHeaders.etagHeader); @override - String get fileExtension { - var fileExtension = ''; - final contentTypeHeader = _header(HttpHeaders.contentTypeHeader); - if (contentTypeHeader != null) { - final contentType = ContentType.parse(contentTypeHeader); - fileExtension = contentType.fileExtension; - } - return fileExtension; - } + String get fileExtension => fileExtensionFromContentTypeOrUri( + _header(HttpHeaders.contentTypeHeader), + _uri, + ); } diff --git a/flutter_cache_manager/lib/src/web/mime_converter.dart b/flutter_cache_manager/lib/src/web/mime_converter.dart index d73e772f..cbbb06f6 100644 --- a/flutter_cache_manager/lib/src/web/mime_converter.dart +++ b/flutter_cache_manager/lib/src/web/mime_converter.dart @@ -5,6 +5,25 @@ extension ContentTypeConverter on ContentType { String get fileExtension => mimeTypes[mimeType] ?? '.$subType'; } +String fileExtensionFromContentTypeOrUri(String? contentTypeHeader, Uri uri) { + final contentType = contentTypeHeader == null + ? null + : ContentType.parse(contentTypeHeader); + + if (contentType != null && + contentType.mimeType.toLowerCase() != 'application/octet-stream') { + return contentType.fileExtension; + } + + final fileName = uri.pathSegments.isEmpty ? '' : uri.pathSegments.last; + final extensionIndex = fileName.lastIndexOf('.'); + if (extensionIndex > 0 && extensionIndex < fileName.length - 1) { + return fileName.substring(extensionIndex); + } + + return contentType?.fileExtension ?? ''; +} + /// Source of MIME Types: /// https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types /// Updated on 20th of March in 2020 while being quarantined From bf0ac618b357675f55081154a2d817cb5a4997d5 Mon Sep 17 00:00:00 2001 From: rahulshahdev Date: Sun, 25 Oct 2026 13:51:53 +0545 Subject: [PATCH 2/4] test: cover download extension fallback --- .../test/http_file_fetcher_test.dart | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/flutter_cache_manager/test/http_file_fetcher_test.dart b/flutter_cache_manager/test/http_file_fetcher_test.dart index 34f81d0b..6809b5a3 100644 --- a/flutter_cache_manager/test/http_file_fetcher_test.dart +++ b/flutter_cache_manager/test/http_file_fetcher_test.dart @@ -7,6 +7,9 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart'; import 'package:http/testing.dart'; +import 'helpers/mock_cache_info_repository.dart'; +import 'helpers/test_configuration.dart' show TestFileSystem; + void main() { group('Check header values', () { test('Valid headers should be parsed normally', () async { @@ -77,6 +80,103 @@ void main() { expect(response.fileExtension, '.$fileExtension'); }); + test('Download uses the URL extension for generic content type', () async { + const url = 'https://example.com/video.mp4?token=secret'; + final client = MockClient((_) async { + return Response.bytes( + Uint8List(16), + 200, + headers: {'content-type': 'application/octet-stream'}, + ); + }); + final cacheManager = CacheManager( + Config( + 'test', + fileSystem: TestFileSystem(), + repo: MockCacheInfoRepository(), + fileService: HttpFileService(httpClient: client), + ), + ); + + final result = await cacheManager.downloadFile(url); + + expect(result.file.path, endsWith('.mp4')); + }); + + test('URL extension is used when content type is missing', () async { + final client = MockClient((_) async { + return Response.bytes(Uint8List(16), 200); + }); + + final response = await HttpFileService( + httpClient: client, + ).get('https://example.com/document.pdf?download=1'); + + expect(response.fileExtension, '.pdf'); + }); + + test('Specific content type takes precedence over URL extension', () async { + final client = MockClient((_) async { + return Response.bytes( + Uint8List(16), + 200, + headers: {'content-type': 'image/jpeg'}, + ); + }); + + final response = await HttpFileService( + httpClient: client, + ).get('https://example.com/video.mp4'); + + expect(response.fileExtension, '.jpg'); + }); + + test( + 'Legacy file service uses URL extension for generic content type', + () async { + final client = MockClient((_) async { + return Response.bytes( + Uint8List(16), + 200, + headers: {'content-type': 'application/octet-stream'}, + ); + }); + + Future defaultHttpGetter( + String url, { + Map? headers, + }) async { + final httpResponse = await client.get( + Uri.parse(url), + headers: headers, + ); + return HttpFileFetcherResponse(httpResponse); + } + + final response = await FileServiceCompat( + defaultHttpGetter, + ).get('https://example.com/video.mp4'); + + expect(response.fileExtension, '.mp4'); + }, + ); + + test('Generic content type without URL extension remains .bin', () async { + final client = MockClient((_) async { + return Response.bytes( + Uint8List(16), + 200, + headers: {'content-type': 'application/octet-stream'}, + ); + }); + + final response = await HttpFileService( + httpClient: client, + ).get('https://example.com/download'); + + expect(response.fileExtension, '.bin'); + }); + test('Test CompatFileService', () async { var eTag = 'test'; var fileExtension = 'jpg'; From b0d2c848e1ad01a1b08d3bed650f201eb275539d Mon Sep 17 00:00:00 2001 From: rahulshahdev Date: Mon, 26 Oct 2026 14:43:07 +0545 Subject: [PATCH 3/4] Keep URL extension fallback safe and compatible --- .../lib/src/compat/file_service_compat.dart | 4 +- .../lib/src/web/file_service.dart | 5 ++- .../lib/src/web/mime_converter.dart | 17 ++++++-- .../test/http_file_fetcher_test.dart | 39 +++++++++++++++++++ 4 files changed, 58 insertions(+), 7 deletions(-) diff --git a/flutter_cache_manager/lib/src/compat/file_service_compat.dart b/flutter_cache_manager/lib/src/compat/file_service_compat.dart index e4781696..44732790 100644 --- a/flutter_cache_manager/lib/src/compat/file_service_compat.dart +++ b/flutter_cache_manager/lib/src/compat/file_service_compat.dart @@ -21,10 +21,10 @@ class FileServiceCompat extends FileService { class CompatFileServiceGetResponse implements FileServiceResponse { final FileFetcherResponse legacyResponse; - final Uri _uri; + final Uri? _uri; final DateTime _receivedTime = clock.now(); - CompatFileServiceGetResponse(this.legacyResponse, this._uri); + CompatFileServiceGetResponse(this.legacyResponse, [this._uri]); String? _header(String name) { return legacyResponse.header(name); diff --git a/flutter_cache_manager/lib/src/web/file_service.dart b/flutter_cache_manager/lib/src/web/file_service.dart index d7cca0d4..8b050df6 100644 --- a/flutter_cache_manager/lib/src/web/file_service.dart +++ b/flutter_cache_manager/lib/src/web/file_service.dart @@ -66,12 +66,13 @@ abstract class FileServiceResponse { /// Basic implementation of a [FileServiceResponse] for http requests. class HttpGetResponse implements FileServiceResponse { - HttpGetResponse(this._response, this._uri); + HttpGetResponse(this._response, [Uri? uri]) + : _uri = uri ?? _response.request?.url; final DateTime _receivedTime = clock.now(); final http.StreamedResponse _response; - final Uri _uri; + final Uri? _uri; @override int get statusCode => _response.statusCode; diff --git a/flutter_cache_manager/lib/src/web/mime_converter.dart b/flutter_cache_manager/lib/src/web/mime_converter.dart index cbbb06f6..fb2a4b08 100644 --- a/flutter_cache_manager/lib/src/web/mime_converter.dart +++ b/flutter_cache_manager/lib/src/web/mime_converter.dart @@ -5,7 +5,11 @@ extension ContentTypeConverter on ContentType { String get fileExtension => mimeTypes[mimeType] ?? '.$subType'; } -String fileExtensionFromContentTypeOrUri(String? contentTypeHeader, Uri uri) { +final RegExp _invalidFileExtensionCharacters = RegExp( + r'[\x00-\x1f\x7f/\\:*?"<>|]', +); + +String fileExtensionFromContentTypeOrUri(String? contentTypeHeader, Uri? uri) { final contentType = contentTypeHeader == null ? null : ContentType.parse(contentTypeHeader); @@ -15,10 +19,17 @@ String fileExtensionFromContentTypeOrUri(String? contentTypeHeader, Uri uri) { return contentType.fileExtension; } - final fileName = uri.pathSegments.isEmpty ? '' : uri.pathSegments.last; + final pathSegments = uri?.pathSegments; + final fileName = pathSegments == null || pathSegments.isEmpty + ? '' + : pathSegments.last; final extensionIndex = fileName.lastIndexOf('.'); if (extensionIndex > 0 && extensionIndex < fileName.length - 1) { - return fileName.substring(extensionIndex); + final extension = fileName.substring(extensionIndex); + if (!_invalidFileExtensionCharacters.hasMatch(extension) && + !extension.endsWith(' ')) { + return extension; + } } return contentType?.fileExtension ?? ''; diff --git a/flutter_cache_manager/test/http_file_fetcher_test.dart b/flutter_cache_manager/test/http_file_fetcher_test.dart index 6809b5a3..43125597 100644 --- a/flutter_cache_manager/test/http_file_fetcher_test.dart +++ b/flutter_cache_manager/test/http_file_fetcher_test.dart @@ -80,6 +80,19 @@ void main() { expect(response.fileExtension, '.$fileExtension'); }); + test('HttpGetResponse keeps its original one-argument constructor', () { + final response = HttpGetResponse( + StreamedResponse( + const Stream>.empty(), + 200, + headers: {'content-type': 'application/octet-stream'}, + request: Request('GET', Uri.parse('https://example.com/video.mp4')), + ), + ); + + expect(response.fileExtension, '.mp4'); + }); + test('Download uses the URL extension for generic content type', () async { const url = 'https://example.com/video.mp4?token=secret'; final client = MockClient((_) async { @@ -177,6 +190,32 @@ void main() { expect(response.fileExtension, '.bin'); }); + test( + 'Encoded path separators are not used in cached file extensions', + () async { + const url = 'https://example.com/video.mp4%2Fpart'; + final client = MockClient((_) async { + return Response.bytes( + Uint8List(16), + 200, + headers: {'content-type': 'application/octet-stream'}, + ); + }); + final cacheManager = CacheManager( + Config( + 'test', + fileSystem: TestFileSystem(), + repo: MockCacheInfoRepository(), + fileService: HttpFileService(httpClient: client), + ), + ); + + final result = await cacheManager.downloadFile(url); + + expect(result.file.path, endsWith('.bin')); + }, + ); + test('Test CompatFileService', () async { var eTag = 'test'; var fileExtension = 'jpg'; From 69f551a007faea26938deb9ffff02ed219e134a8 Mon Sep 17 00:00:00 2001 From: rahulshahdev Date: Tue, 27 Oct 2026 16:42:32 +0545 Subject: [PATCH 4/4] fix: validate URL extension fallback --- flutter_cache_manager/CHANGELOG.md | 4 + .../lib/src/compat/file_service_compat.dart | 2 +- .../lib/src/web/file_service.dart | 2 +- .../lib/src/web/mime_converter.dart | 28 +++-- flutter_cache_manager/pubspec.yaml | 2 +- .../test/http_file_fetcher_test.dart | 110 +++++++++++++++++- 6 files changed, 134 insertions(+), 14 deletions(-) diff --git a/flutter_cache_manager/CHANGELOG.md b/flutter_cache_manager/CHANGELOG.md index 275c791b..c16a5bc6 100644 --- a/flutter_cache_manager/CHANGELOG.md +++ b/flutter_cache_manager/CHANGELOG.md @@ -1,3 +1,7 @@ +## [3.4.7] - 2026-10-27 + +* Preserves short, safe URL extensions for downloads with missing or generic content types, ignores dynamic endpoint suffixes, and tolerates malformed legacy URLs ([#489](https://github.com/Baseflow/flutter_cache_manager/issues/489)). + ## [3.4.6] - 2026-10-27 * Catches unhandled asynchronous errors when updating cache-hit metadata and during scheduled cleanup in `CacheStore` ([#535](https://github.com/Baseflow/flutter_cache_manager/issues/535)). diff --git a/flutter_cache_manager/lib/src/compat/file_service_compat.dart b/flutter_cache_manager/lib/src/compat/file_service_compat.dart index 44732790..1d78826b 100644 --- a/flutter_cache_manager/lib/src/compat/file_service_compat.dart +++ b/flutter_cache_manager/lib/src/compat/file_service_compat.dart @@ -15,7 +15,7 @@ class FileServiceCompat extends FileService { Map? headers, }) async { final legacyResponse = await fileFetcher(url, headers: headers); - return CompatFileServiceGetResponse(legacyResponse, Uri.parse(url)); + return CompatFileServiceGetResponse(legacyResponse, Uri.tryParse(url)); } } diff --git a/flutter_cache_manager/lib/src/web/file_service.dart b/flutter_cache_manager/lib/src/web/file_service.dart index 8b050df6..4ba1b886 100644 --- a/flutter_cache_manager/lib/src/web/file_service.dart +++ b/flutter_cache_manager/lib/src/web/file_service.dart @@ -38,7 +38,7 @@ class HttpFileService extends FileService { } final httpResponse = await _httpClient.send(req); - return HttpGetResponse(httpResponse, Uri.parse(url)); + return HttpGetResponse(httpResponse); } } diff --git a/flutter_cache_manager/lib/src/web/mime_converter.dart b/flutter_cache_manager/lib/src/web/mime_converter.dart index fb2a4b08..2acffead 100644 --- a/flutter_cache_manager/lib/src/web/mime_converter.dart +++ b/flutter_cache_manager/lib/src/web/mime_converter.dart @@ -5,17 +5,31 @@ extension ContentTypeConverter on ContentType { String get fileExtension => mimeTypes[mimeType] ?? '.$subType'; } -final RegExp _invalidFileExtensionCharacters = RegExp( - r'[\x00-\x1f\x7f/\\:*?"<>|]', -); +final RegExp _safeFileExtension = RegExp(r'^\.[A-Za-z0-9]{1,10}$'); +const _genericContentTypes = { + 'application/octet-stream', + 'application/x-octet-stream', + 'application/force-download', + 'application/download', + 'binary/octet-stream', +}; + +const _dynamicEndpointExtensions = {'.php', '.asp', '.aspx', '.cgi', '.jsp'}; + +/// Gets an extension from a specific content type or a safe URL path suffix. +/// +/// Specific content types take precedence. Missing or generic content types +/// use a short alphanumeric URL suffix when available. Known server-side +/// endpoint suffixes are ignored. The result falls back to `.bin` for generic +/// types or an empty string when the content type is absent. String fileExtensionFromContentTypeOrUri(String? contentTypeHeader, Uri? uri) { final contentType = contentTypeHeader == null ? null : ContentType.parse(contentTypeHeader); if (contentType != null && - contentType.mimeType.toLowerCase() != 'application/octet-stream') { + !_genericContentTypes.contains(contentType.mimeType)) { return contentType.fileExtension; } @@ -26,13 +40,13 @@ String fileExtensionFromContentTypeOrUri(String? contentTypeHeader, Uri? uri) { final extensionIndex = fileName.lastIndexOf('.'); if (extensionIndex > 0 && extensionIndex < fileName.length - 1) { final extension = fileName.substring(extensionIndex); - if (!_invalidFileExtensionCharacters.hasMatch(extension) && - !extension.endsWith(' ')) { + if (_safeFileExtension.hasMatch(extension) && + !_dynamicEndpointExtensions.contains(extension.toLowerCase())) { return extension; } } - return contentType?.fileExtension ?? ''; + return contentType == null ? '' : '.bin'; } /// Source of MIME Types: diff --git a/flutter_cache_manager/pubspec.yaml b/flutter_cache_manager/pubspec.yaml index 8456c4b5..c3d22cd7 100644 --- a/flutter_cache_manager/pubspec.yaml +++ b/flutter_cache_manager/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_cache_manager description: Generic cache manager for flutter. Saves web files on the storages of the device and saves the cache info using sqflite. -version: 3.4.6 +version: 3.4.7 homepage: https://github.com/Baseflow/flutter_cache_manager topics: - cache diff --git a/flutter_cache_manager/test/http_file_fetcher_test.dart b/flutter_cache_manager/test/http_file_fetcher_test.dart index 43125597..f3387142 100644 --- a/flutter_cache_manager/test/http_file_fetcher_test.dart +++ b/flutter_cache_manager/test/http_file_fetcher_test.dart @@ -95,11 +95,12 @@ void main() { test('Download uses the URL extension for generic content type', () async { const url = 'https://example.com/video.mp4?token=secret'; - final client = MockClient((_) async { + final client = MockClient((request) async { return Response.bytes( Uint8List(16), 200, headers: {'content-type': 'application/octet-stream'}, + request: request, ); }); final cacheManager = CacheManager( @@ -117,8 +118,8 @@ void main() { }); test('URL extension is used when content type is missing', () async { - final client = MockClient((_) async { - return Response.bytes(Uint8List(16), 200); + final client = MockClient((request) async { + return Response.bytes(Uint8List(16), 200, request: request); }); final response = await HttpFileService( @@ -190,15 +191,116 @@ void main() { expect(response.fileExtension, '.bin'); }); + for (final contentType in const [ + 'application/octet-stream', + 'binary/octet-stream', + 'application/x-octet-stream', + 'application/force-download', + 'application/download', + ]) { + test( + 'URL extension is used for generic MIME type $contentType', + () async { + final client = MockClient((request) async { + return Response.bytes( + Uint8List(16), + 200, + headers: {'content-type': contentType}, + request: request, + ); + }); + + final response = await HttpFileService( + httpClient: client, + ).get('https://example.com/video.mp4'); + + expect(response.fileExtension, '.mp4'); + }, + ); + } + + test('URL extension is limited to short alphanumeric suffixes', () async { + final client = MockClient((request) async { + return Response.bytes( + Uint8List(16), + 200, + headers: {'content-type': 'application/octet-stream'}, + request: request, + ); + }); + + final response = await HttpFileService( + httpClient: client, + ).get('https://example.com/file.${'a' * 250}'); + + expect(response.fileExtension, '.bin'); + }); + + test('URL version suffix is not treated as a file extension', () async { + final client = MockClient((request) async { + return Response.bytes( + Uint8List(16), + 200, + headers: {'content-type': 'application/octet-stream'}, + request: request, + ); + }); + + final response = await HttpFileService( + httpClient: client, + ).get('https://example.com/files/v1.2-beta'); + + expect(response.fileExtension, '.bin'); + }); + + test( + 'Dynamic download endpoint is not used as the file extension', + () async { + final client = MockClient((request) async { + return Response.bytes( + Uint8List(16), + 200, + headers: {'content-type': 'application/octet-stream'}, + request: request, + ); + }); + + final response = await HttpFileService( + httpClient: client, + ).get('https://example.com/download.php?id=7'); + + expect(response.fileExtension, '.bin'); + }, + ); + + test( + 'Malformed legacy URL does not fail after the fetch succeeds', + () async { + final legacyResponse = HttpFileFetcherResponse( + Response.bytes( + Uint8List(16), + 200, + headers: {'content-type': 'application/octet-stream'}, + ), + ); + final response = await FileServiceCompat( + (url, {headers}) async => legacyResponse, + ).get('https://[::1/video.mp4'); + + expect(response.fileExtension, '.bin'); + }, + ); + test( 'Encoded path separators are not used in cached file extensions', () async { const url = 'https://example.com/video.mp4%2Fpart'; - final client = MockClient((_) async { + final client = MockClient((request) async { return Response.bytes( Uint8List(16), 200, headers: {'content-type': 'application/octet-stream'}, + request: request, ); }); final cacheManager = CacheManager(