Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions flutter_cache_manager/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)).
Expand Down
18 changes: 7 additions & 11 deletions flutter_cache_manager/lib/src/compat/file_service_compat.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,16 @@ class FileServiceCompat extends FileService {
Map<String, String>? headers,
}) async {
final legacyResponse = await fileFetcher(url, headers: headers);
return CompatFileServiceGetResponse(legacyResponse);
return CompatFileServiceGetResponse(legacyResponse, Uri.tryParse(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);
Expand Down Expand Up @@ -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;
Expand Down
17 changes: 7 additions & 10 deletions flutter_cache_manager/lib/src/web/file_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,13 @@ abstract class FileServiceResponse {

/// Basic implementation of a [FileServiceResponse] for http requests.
class HttpGetResponse implements FileServiceResponse {
HttpGetResponse(this._response);
HttpGetResponse(this._response, [Uri? uri])
: _uri = uri ?? _response.request?.url;

final DateTime _receivedTime = clock.now();

final http.StreamedResponse _response;
final Uri? _uri;

@override
int get statusCode => _response.statusCode;
Expand Down Expand Up @@ -114,13 +116,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,
);
}
44 changes: 44 additions & 0 deletions flutter_cache_manager/lib/src/web/mime_converter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,50 @@ extension ContentTypeConverter on ContentType {
String get fileExtension => mimeTypes[mimeType] ?? '.$subType';
}

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) {
Comment thread
rahulshahDEV marked this conversation as resolved.
final contentType = contentTypeHeader == null
? null
: ContentType.parse(contentTypeHeader);

if (contentType != null &&
!_genericContentTypes.contains(contentType.mimeType)) {
return contentType.fileExtension;
}

final pathSegments = uri?.pathSegments;
final fileName = pathSegments == null || pathSegments.isEmpty
? ''
: pathSegments.last;
final extensionIndex = fileName.lastIndexOf('.');
Comment thread
rahulshahDEV marked this conversation as resolved.
if (extensionIndex > 0 && extensionIndex < fileName.length - 1) {
final extension = fileName.substring(extensionIndex);
Comment thread
rahulshahDEV marked this conversation as resolved.
if (_safeFileExtension.hasMatch(extension) &&
!_dynamicEndpointExtensions.contains(extension.toLowerCase())) {
return extension;
}
}

return contentType == null ? '' : '.bin';
}

/// 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
Expand Down
2 changes: 1 addition & 1 deletion flutter_cache_manager/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
241 changes: 241 additions & 0 deletions flutter_cache_manager/test/http_file_fetcher_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -77,6 +80,244 @@ void main() {
expect(response.fileExtension, '.$fileExtension');
});

test('HttpGetResponse keeps its original one-argument constructor', () {
final response = HttpGetResponse(
StreamedResponse(
const Stream<List<int>>.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((request) async {
return Response.bytes(
Uint8List(16),
200,
headers: {'content-type': 'application/octet-stream'},
request: request,
);
});
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((request) async {
return Response.bytes(Uint8List(16), 200, request: request);
});

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<FileFetcherResponse> defaultHttpGetter(
String url, {
Map<String, String>? 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');
});

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((request) async {
return Response.bytes(
Uint8List(16),
200,
headers: {'content-type': 'application/octet-stream'},
request: request,
);
});
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';
Expand Down