From a98707ef3fba3bfb4868472b25dfa235656ea88c Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Mon, 6 Oct 2025 18:31:32 +0200 Subject: [PATCH 01/37] feat: Initial working version of cache busting. --- example/static_files/hello.txt | 1 + example/static_files/logo.svg | 25 ++++ example/static_files_example.dart | 52 +++++++ lib/relic.dart | 1 + lib/src/io/static/static_handler.dart | 10 ++ .../middleware/middleware_cache_busting.dart | 127 ++++++++++++++++++ .../cache_busting_middleware_test.dart | 100 ++++++++++++++ 7 files changed, 316 insertions(+) create mode 100644 example/static_files/hello.txt create mode 100644 example/static_files/logo.svg create mode 100644 example/static_files_example.dart create mode 100644 lib/src/middleware/middleware_cache_busting.dart create mode 100644 test/middleware/cache_busting_middleware_test.dart diff --git a/example/static_files/hello.txt b/example/static_files/hello.txt new file mode 100644 index 00000000..4e745b20 --- /dev/null +++ b/example/static_files/hello.txt @@ -0,0 +1 @@ +Hello! :) \ No newline at end of file diff --git a/example/static_files/logo.svg b/example/static_files/logo.svg new file mode 100644 index 00000000..55e963de --- /dev/null +++ b/example/static_files/logo.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/static_files_example.dart b/example/static_files_example.dart new file mode 100644 index 00000000..1f718bb8 --- /dev/null +++ b/example/static_files_example.dart @@ -0,0 +1,52 @@ +import 'dart:io'; + +import 'package:relic/io_adapter.dart'; +import 'package:relic/relic.dart'; + +/// Minimal example serving files from the local example/static_files directory. +/// +/// - Serves static files under the URL prefix "/static". +/// - Try opening: http://localhost:8080/static/hello.txt +/// - Or: http://localhost:8080/static/logo.svg +Future main() async { + final staticDir = File('static_files').absolute.path; + + // Router mounts the static handler under /static/** and shows an index that + // demonstrates cache-busted URLs. + final router = Router() + ..get('/', respondWith((final _) async { + final helloUrl = await withCacheBusting( + mountPrefix: '/static', + fileSystemRoot: staticDir, + staticPath: '/static/hello.txt', + ); + final logoUrl = await withCacheBusting( + mountPrefix: '/static', + fileSystemRoot: staticDir, + staticPath: '/static/logo.svg', + ); + final html = '' + '

Static files with cache busting

' + '' + ''; + return Response.ok(body: Body.fromString(html, mimeType: MimeType.html)); + })) + ..get( + '/static/**', + createStaticHandler( + staticDir, + cacheControl: (final _, final __) => null, + )); + + final handler = const Pipeline() + .addMiddleware(logRequests()) + .addMiddleware(stripCacheBusting('/static')) + .addMiddleware(routeWith(router)) + .addHandler(respondWith((final _) => Response.notFound())); + + await serve(handler, InternetAddress.loopbackIPv4, 8080); + // Now open your browser at: http://localhost:8080/ +} diff --git a/lib/relic.dart b/lib/relic.dart index b73d5376..b8ffa83a 100644 --- a/lib/relic.dart +++ b/lib/relic.dart @@ -20,6 +20,7 @@ export 'src/message/request.dart' show Request; export 'src/message/response.dart' show Response; export 'src/middleware/context_property.dart'; export 'src/middleware/middleware.dart' show Middleware, createMiddleware; +export 'src/middleware/middleware_cache_busting.dart'; export 'src/middleware/middleware_extensions.dart' show MiddlewareExtensions; export 'src/middleware/middleware_logger.dart' show logRequests; export 'src/middleware/routing_middleware.dart'; diff --git a/lib/src/io/static/static_handler.dart b/lib/src/io/static/static_handler.dart index 21c48b54..b971e2d0 100644 --- a/lib/src/io/static/static_handler.dart +++ b/lib/src/io/static/static_handler.dart @@ -39,6 +39,16 @@ typedef CacheControlFactory = CacheControlHeader? Function( /// LRU cache for file information to avoid repeated file system operations. final _fileInfoCache = LruCache(10000); +/// Public accessor for retrieving [FileInfo] for a given [file]. +/// +/// Uses the same logic as the internal cache/population used by the static +/// file handler and respects MIME type detection. +Future getStaticFileInfo( + final File file, { + final MimeTypeResolver? mimeResolver, +}) async => + _getFileInfo(file, mimeResolver ?? _defaultMimeTypeResolver); + /// Creates a Relic [Handler] that serves files from the provided [fileSystemPath]. /// /// When a file is requested, it is served with appropriate headers including diff --git a/lib/src/middleware/middleware_cache_busting.dart b/lib/src/middleware/middleware_cache_busting.dart new file mode 100644 index 00000000..ecae29f5 --- /dev/null +++ b/lib/src/middleware/middleware_cache_busting.dart @@ -0,0 +1,127 @@ +import 'dart:io'; + +import '../../relic.dart'; +import '../adapter/context.dart'; + +/// Middleware and helpers for cache-busted asset URLs in the form +/// "/path/name@`hash`.ext". +/// +/// Typical flow: +/// - Outgoing URLs are generated with [withCacheBusting] using a known mount +/// prefix (e.g. "/static") and a filesystem root directory to compute the +/// file's ETag hash. This returns "/static/name@hash.ext". +/// - Incoming requests are passed through [stripCacheBusting] so that +/// downstream handlers (e.g. static handler) receive a path without the +/// embedded hash. + +/// Returns a middleware that strips an inline cache-busting hash in the last +/// path segment under the provided [mountPrefix]. +/// +/// For a request like "/static/images/logo@abc123.png" and mountPrefix +/// "/static", this rewrites the request URL to "/static/images/logo.png" +/// before calling the next handler. +Middleware stripCacheBusting(final String mountPrefix) { + final normalizedMount = _normalizeMount(mountPrefix); + + return (final inner) { + return (final ctx) async { + final req = ctx.request; + final fullPath = Uri.decodeFull(req.requestedUri.path); + + if (!fullPath.startsWith(normalizedMount)) { + return await inner(ctx); + } + + // Extract the portion after mount prefix + final relative = fullPath.substring(normalizedMount.length); + final segments = relative.split('/'); + if (segments.isEmpty || segments.last.isEmpty) { + return await inner(ctx); + } + + final last = segments.last; + final strippedLast = _stripHashFromFilename(last); + if (identical(strippedLast, last)) { + return await inner(ctx); + } + + segments[segments.length - 1] = strippedLast; + final rewrittenRelative = segments.join('/'); + + // Rebuild a new Request only by updating requestedUri path; do not touch + // handlerPath so that routers and mounts continue to work as configured. + final newRequested = req.requestedUri.replace( + path: '$normalizedMount$rewrittenRelative', + ); + final rewrittenRequest = req.copyWith(requestedUri: newRequested); + return await inner(rewrittenRequest.toContext(ctx.token)); + }; + }; +} + +/// Produces a cache-busted path by appending `@etag` before the file +/// extension, if any. Example: "/static/img/logo.png" -> +/// "/static/img/logo@etag.png". +/// +/// - [mountPrefix]: the URL prefix under which static assets are served +/// (e.g., "/static"). Must start with "/". +/// - [fileSystemRoot]: absolute or relative path to the directory used by the +/// static handler to serve files. The path after [mountPrefix] is mapped onto +/// this filesystem root to locate the actual file and its ETag. +Future withCacheBusting({ + required final String mountPrefix, + required final String fileSystemRoot, + required final String staticPath, +}) async { + final normalizedMount = _normalizeMount(mountPrefix); + if (!staticPath.startsWith(normalizedMount)) return staticPath; + + // Determine relative path after the mount prefix + final relative = staticPath.substring(normalizedMount.length); + final filePath = File(_joinPaths(fileSystemRoot, relative)); + + final info = await getStaticFileInfo(filePath); + + // Insert @etag before extension + final lastSlash = staticPath.lastIndexOf('/'); + final dir = lastSlash >= 0 ? staticPath.substring(0, lastSlash + 1) : ''; + final fileName = + lastSlash >= 0 ? staticPath.substring(lastSlash + 1) : staticPath; + + final dot = fileName.lastIndexOf('.'); + if (dot <= 0 || dot == fileName.length - 1) { + return '$dir${_appendHashToBasename(fileName, info.etag)}'; + } + + final base = fileName.substring(0, dot); + final ext = fileName.substring(dot); // includes dot + return '$dir${_appendHashToBasename(base, info.etag)}$ext'; +} + +String _appendHashToBasename(final String base, final String etag) => + '$base@$etag'; + +String _normalizeMount(final String mountPrefix) { + if (!mountPrefix.startsWith('/')) { + throw ArgumentError('mountPrefix must start with "/"'); + } + return mountPrefix.endsWith('/') ? mountPrefix : '$mountPrefix/'; +} + +String _stripHashFromFilename(final String fileName) { + // Match name@hash.ext or name@hash (no extension) + final dot = fileName.lastIndexOf('.'); + final main = dot > 0 ? fileName.substring(0, dot) : fileName; + final ext = dot > 0 ? fileName.substring(dot) : ''; + + final at = main.lastIndexOf('@'); + if (at <= 0) return fileName; // no hash or starts with '@' + + final base = main.substring(0, at); + return '$base$ext'; +} + +String _joinPaths(final String a, final String b) { + if (a.endsWith('/')) return '$a$b'; + return '$a/$b'; +} diff --git a/test/middleware/cache_busting_middleware_test.dart b/test/middleware/cache_busting_middleware_test.dart new file mode 100644 index 00000000..186087f9 --- /dev/null +++ b/test/middleware/cache_busting_middleware_test.dart @@ -0,0 +1,100 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:relic/relic.dart'; +import 'package:test/test.dart'; +import 'package:test_descriptor/test_descriptor.dart' as d; + +import '../static/test_util.dart'; + +void main() { + group('Cache busting middleware', () { + setUp(() async { + await d.dir('static', [ + d.file('logo.png', 'png-bytes'), + d.dir('images', [d.file('logo.png', 'nested-bytes')]), + ]).create(); + }); + + test('generates cache-busted URL and serves via root-mounted handler', + () async { + final staticRoot = p.join(d.sandbox, 'static'); + final handler = const Pipeline() + .addMiddleware(stripCacheBusting('/static')) + .addHandler(createStaticHandler( + staticRoot, + cacheControl: (final _, final __) => null, + )); + + const original = '/static/logo.png'; + final busted = await withCacheBusting( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + staticPath: original, + ); + expect(busted, isNot(original)); + expect(busted, startsWith('/static/logo@')); + expect(busted, endsWith('.png')); + + final response = await makeRequest( + handler, + busted, + handlerPath: 'static', + ); + expect(response.statusCode, HttpStatus.ok); + expect(await response.readAsString(), 'png-bytes'); + }); + + test('works when static handler is mounted under router', () async { + final staticRoot = p.join(d.sandbox, 'static'); + final router = Router() + ..get( + '/assets/**', + createStaticHandler( + staticRoot, + cacheControl: (final _, final __) => null, + )); + + final handler = const Pipeline() + .addMiddleware(stripCacheBusting('/assets')) + .addMiddleware(routeWith(router)) + .addHandler(respondWith((final _) => Response.notFound())); + + const original = '/assets/images/logo.png'; + final busted = await withCacheBusting( + mountPrefix: '/assets', + fileSystemRoot: staticRoot, + staticPath: original, + ); + + final response = await makeRequest(handler, busted); + expect(response.statusCode, HttpStatus.ok); + expect(await response.readAsString(), 'nested-bytes'); + }); + + test('works with custom handlerPath mounted under root', () async { + final staticRoot = p.join(d.sandbox, 'static'); + final handler = const Pipeline() + .addMiddleware(stripCacheBusting('/static')) + .addHandler(createStaticHandler( + staticRoot, + cacheControl: (final _, final __) => null, + )); + + const original = '/static/images/logo.png'; + final busted = await withCacheBusting( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + staticPath: original, + ); + + final response = await makeRequest( + handler, + busted, + handlerPath: 'static', + ); + expect(response.statusCode, HttpStatus.ok); + expect(await response.readAsString(), 'nested-bytes'); + }); + }); +} From ed3af1fb6aad5aedf9892e57667c518dbae30c41 Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Mon, 6 Oct 2025 19:23:36 +0200 Subject: [PATCH 02/37] feat: Cleaned up example and structure. --- example/static_files_example.dart | 22 +++--- .../middleware/middleware_cache_busting.dart | 76 ++++++++++--------- .../cache_busting_middleware_test.dart | 39 ++++++---- 3 files changed, 72 insertions(+), 65 deletions(-) diff --git a/example/static_files_example.dart b/example/static_files_example.dart index 1f718bb8..a0bcb4bd 100644 --- a/example/static_files_example.dart +++ b/example/static_files_example.dart @@ -9,22 +9,18 @@ import 'package:relic/relic.dart'; /// - Try opening: http://localhost:8080/static/hello.txt /// - Or: http://localhost:8080/static/logo.svg Future main() async { - final staticDir = File('static_files').absolute.path; + final staticDir = Directory('static_files'); + final cacheCfg = CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticDir, + ); // Router mounts the static handler under /static/** and shows an index that // demonstrates cache-busted URLs. final router = Router() ..get('/', respondWith((final _) async { - final helloUrl = await withCacheBusting( - mountPrefix: '/static', - fileSystemRoot: staticDir, - staticPath: '/static/hello.txt', - ); - final logoUrl = await withCacheBusting( - mountPrefix: '/static', - fileSystemRoot: staticDir, - staticPath: '/static/logo.svg', - ); + final helloUrl = await cacheCfg.asset('/static/hello.txt'); + final logoUrl = await cacheCfg.asset('/static/logo.svg'); final html = '' '

Static files with cache busting

' '
    ' @@ -37,13 +33,13 @@ Future main() async { ..get( '/static/**', createStaticHandler( - staticDir, + staticDir.path, cacheControl: (final _, final __) => null, )); final handler = const Pipeline() .addMiddleware(logRequests()) - .addMiddleware(stripCacheBusting('/static')) + .addMiddleware(cacheBusting(cacheCfg)) .addMiddleware(routeWith(router)) .addHandler(respondWith((final _) => Response.notFound())); diff --git a/lib/src/middleware/middleware_cache_busting.dart b/lib/src/middleware/middleware_cache_busting.dart index ecae29f5..fe97bd5a 100644 --- a/lib/src/middleware/middleware_cache_busting.dart +++ b/lib/src/middleware/middleware_cache_busting.dart @@ -20,8 +20,8 @@ import '../adapter/context.dart'; /// For a request like "/static/images/logo@abc123.png" and mountPrefix /// "/static", this rewrites the request URL to "/static/images/logo.png" /// before calling the next handler. -Middleware stripCacheBusting(final String mountPrefix) { - final normalizedMount = _normalizeMount(mountPrefix); +Middleware cacheBusting(final CacheBustingConfig config) { + final normalizedMount = _normalizeMount(config.mountPrefix); return (final inner) { return (final ctx) async { @@ -59,43 +59,45 @@ Middleware stripCacheBusting(final String mountPrefix) { }; } -/// Produces a cache-busted path by appending `@etag` before the file -/// extension, if any. Example: "/static/img/logo.png" -> -/// "/static/img/logo@etag.png". -/// -/// - [mountPrefix]: the URL prefix under which static assets are served -/// (e.g., "/static"). Must start with "/". -/// - [fileSystemRoot]: absolute or relative path to the directory used by the -/// static handler to serve files. The path after [mountPrefix] is mapped onto -/// this filesystem root to locate the actual file and its ETag. -Future withCacheBusting({ - required final String mountPrefix, - required final String fileSystemRoot, - required final String staticPath, -}) async { - final normalizedMount = _normalizeMount(mountPrefix); - if (!staticPath.startsWith(normalizedMount)) return staticPath; - - // Determine relative path after the mount prefix - final relative = staticPath.substring(normalizedMount.length); - final filePath = File(_joinPaths(fileSystemRoot, relative)); - - final info = await getStaticFileInfo(filePath); - - // Insert @etag before extension - final lastSlash = staticPath.lastIndexOf('/'); - final dir = lastSlash >= 0 ? staticPath.substring(0, lastSlash + 1) : ''; - final fileName = - lastSlash >= 0 ? staticPath.substring(lastSlash + 1) : staticPath; +/// Holds configuration for generating cache-busted asset URLs. +class CacheBustingConfig { + /// The URL prefix under which static assets are served (e.g., "/static"). + final String mountPrefix; - final dot = fileName.lastIndexOf('.'); - if (dot <= 0 || dot == fileName.length - 1) { - return '$dir${_appendHashToBasename(fileName, info.etag)}'; - } + /// Filesystem root corresponding to [mountPrefix]. + final Directory fileSystemRoot; + + CacheBustingConfig({ + required this.mountPrefix, + required final Directory fileSystemRoot, + }) : fileSystemRoot = fileSystemRoot.absolute; + + /// Returns the cache-busted URL for the given [staticPath]. + /// + /// Example: '/static/logo.svg' -> '/static/logo@etag.svg' + Future asset(final String staticPath) async { + final normalizedMount = _normalizeMount(mountPrefix); + if (!staticPath.startsWith(normalizedMount)) return staticPath; - final base = fileName.substring(0, dot); - final ext = fileName.substring(dot); // includes dot - return '$dir${_appendHashToBasename(base, info.etag)}$ext'; + final relative = staticPath.substring(normalizedMount.length); + final filePath = File(_joinPaths(fileSystemRoot.path, relative)); + + final info = await getStaticFileInfo(filePath); + + final lastSlash = staticPath.lastIndexOf('/'); + final dir = lastSlash >= 0 ? staticPath.substring(0, lastSlash + 1) : ''; + final fileName = + lastSlash >= 0 ? staticPath.substring(lastSlash + 1) : staticPath; + + final dot = fileName.lastIndexOf('.'); + if (dot <= 0 || dot == fileName.length - 1) { + return '$dir${_appendHashToBasename(fileName, info.etag)}'; + } + + final base = fileName.substring(0, dot); + final ext = fileName.substring(dot); // includes dot + return '$dir${_appendHashToBasename(base, info.etag)}$ext'; + } } String _appendHashToBasename(final String base, final String etag) => diff --git a/test/middleware/cache_busting_middleware_test.dart b/test/middleware/cache_busting_middleware_test.dart index 186087f9..bb36daaa 100644 --- a/test/middleware/cache_busting_middleware_test.dart +++ b/test/middleware/cache_busting_middleware_test.dart @@ -18,20 +18,23 @@ void main() { test('generates cache-busted URL and serves via root-mounted handler', () async { - final staticRoot = p.join(d.sandbox, 'static'); + final staticRoot = Directory(p.join(d.sandbox, 'static')); final handler = const Pipeline() - .addMiddleware(stripCacheBusting('/static')) + .addMiddleware(cacheBusting(CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + ))) .addHandler(createStaticHandler( - staticRoot, + staticRoot.path, cacheControl: (final _, final __) => null, )); const original = '/static/logo.png'; - final busted = await withCacheBusting( + final cfg = CacheBustingConfig( mountPrefix: '/static', fileSystemRoot: staticRoot, - staticPath: original, ); + final busted = await cfg.asset(original); expect(busted, isNot(original)); expect(busted, startsWith('/static/logo@')); expect(busted, endsWith('.png')); @@ -46,26 +49,29 @@ void main() { }); test('works when static handler is mounted under router', () async { - final staticRoot = p.join(d.sandbox, 'static'); + final staticRoot = Directory(p.join(d.sandbox, 'static')); final router = Router() ..get( '/assets/**', createStaticHandler( - staticRoot, + staticRoot.path, cacheControl: (final _, final __) => null, )); final handler = const Pipeline() - .addMiddleware(stripCacheBusting('/assets')) + .addMiddleware(cacheBusting(CacheBustingConfig( + mountPrefix: '/assets', + fileSystemRoot: staticRoot, + ))) .addMiddleware(routeWith(router)) .addHandler(respondWith((final _) => Response.notFound())); const original = '/assets/images/logo.png'; - final busted = await withCacheBusting( + final cfg = CacheBustingConfig( mountPrefix: '/assets', fileSystemRoot: staticRoot, - staticPath: original, ); + final busted = await cfg.asset(original); final response = await makeRequest(handler, busted); expect(response.statusCode, HttpStatus.ok); @@ -73,20 +79,23 @@ void main() { }); test('works with custom handlerPath mounted under root', () async { - final staticRoot = p.join(d.sandbox, 'static'); + final staticRoot = Directory(p.join(d.sandbox, 'static')); final handler = const Pipeline() - .addMiddleware(stripCacheBusting('/static')) + .addMiddleware(cacheBusting(CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + ))) .addHandler(createStaticHandler( - staticRoot, + staticRoot.path, cacheControl: (final _, final __) => null, )); const original = '/static/images/logo.png'; - final busted = await withCacheBusting( + final cfg = CacheBustingConfig( mountPrefix: '/static', fileSystemRoot: staticRoot, - staticPath: original, ); + final busted = await cfg.asset(original); final response = await makeRequest( handler, From 857ce1c122930289edad6eb0afd65dc03beaa4d2 Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Mon, 6 Oct 2025 19:26:48 +0200 Subject: [PATCH 03/37] refactor: Moves cache busting to io/static directory. --- lib/relic.dart | 2 +- lib/src/{middleware => io/static}/middleware_cache_busting.dart | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename lib/src/{middleware => io/static}/middleware_cache_busting.dart (100%) diff --git a/lib/relic.dart b/lib/relic.dart index b8ffa83a..97858e49 100644 --- a/lib/relic.dart +++ b/lib/relic.dart @@ -15,12 +15,12 @@ export 'src/headers/header_accessor.dart'; export 'src/headers/headers.dart'; export 'src/headers/standard_headers_extensions.dart'; export 'src/headers/typed/typed_headers.dart'; +export 'src/io/static/middleware_cache_busting.dart'; export 'src/io/static/static_handler.dart'; export 'src/message/request.dart' show Request; export 'src/message/response.dart' show Response; export 'src/middleware/context_property.dart'; export 'src/middleware/middleware.dart' show Middleware, createMiddleware; -export 'src/middleware/middleware_cache_busting.dart'; export 'src/middleware/middleware_extensions.dart' show MiddlewareExtensions; export 'src/middleware/middleware_logger.dart' show logRequests; export 'src/middleware/routing_middleware.dart'; diff --git a/lib/src/middleware/middleware_cache_busting.dart b/lib/src/io/static/middleware_cache_busting.dart similarity index 100% rename from lib/src/middleware/middleware_cache_busting.dart rename to lib/src/io/static/middleware_cache_busting.dart From 7f7e8b25a2c475f964fdad9cac73ec75138ac6e7 Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Mon, 6 Oct 2025 19:32:49 +0200 Subject: [PATCH 04/37] fix: Renames asset to bust. --- example/static_files_example.dart | 4 ++-- lib/src/io/static/middleware_cache_busting.dart | 6 +++--- test/middleware/cache_busting_middleware_test.dart | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/example/static_files_example.dart b/example/static_files_example.dart index a0bcb4bd..ba212e0c 100644 --- a/example/static_files_example.dart +++ b/example/static_files_example.dart @@ -19,8 +19,8 @@ Future main() async { // demonstrates cache-busted URLs. final router = Router() ..get('/', respondWith((final _) async { - final helloUrl = await cacheCfg.asset('/static/hello.txt'); - final logoUrl = await cacheCfg.asset('/static/logo.svg'); + final helloUrl = await cacheCfg.bust('/static/hello.txt'); + final logoUrl = await cacheCfg.bust('/static/logo.svg'); final html = '' '

    Static files with cache busting

    ' '
      ' diff --git a/lib/src/io/static/middleware_cache_busting.dart b/lib/src/io/static/middleware_cache_busting.dart index fe97bd5a..bfc3454b 100644 --- a/lib/src/io/static/middleware_cache_busting.dart +++ b/lib/src/io/static/middleware_cache_busting.dart @@ -1,7 +1,7 @@ import 'dart:io'; -import '../../relic.dart'; -import '../adapter/context.dart'; +import '../../../relic.dart'; +import '../../adapter/context.dart'; /// Middleware and helpers for cache-busted asset URLs in the form /// "/path/name@`hash`.ext". @@ -75,7 +75,7 @@ class CacheBustingConfig { /// Returns the cache-busted URL for the given [staticPath]. /// /// Example: '/static/logo.svg' -> '/static/logo@etag.svg' - Future asset(final String staticPath) async { + Future bust(final String staticPath) async { final normalizedMount = _normalizeMount(mountPrefix); if (!staticPath.startsWith(normalizedMount)) return staticPath; diff --git a/test/middleware/cache_busting_middleware_test.dart b/test/middleware/cache_busting_middleware_test.dart index bb36daaa..a70bc289 100644 --- a/test/middleware/cache_busting_middleware_test.dart +++ b/test/middleware/cache_busting_middleware_test.dart @@ -34,7 +34,7 @@ void main() { mountPrefix: '/static', fileSystemRoot: staticRoot, ); - final busted = await cfg.asset(original); + final busted = await cfg.bust(original); expect(busted, isNot(original)); expect(busted, startsWith('/static/logo@')); expect(busted, endsWith('.png')); @@ -71,7 +71,7 @@ void main() { mountPrefix: '/assets', fileSystemRoot: staticRoot, ); - final busted = await cfg.asset(original); + final busted = await cfg.bust(original); final response = await makeRequest(handler, busted); expect(response.statusCode, HttpStatus.ok); @@ -95,7 +95,7 @@ void main() { mountPrefix: '/static', fileSystemRoot: staticRoot, ); - final busted = await cfg.asset(original); + final busted = await cfg.bust(original); final response = await makeRequest( handler, From 3490dc365ae01e703b234bf7e83db1fec0750b4b Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Mon, 6 Oct 2025 20:24:23 +0200 Subject: [PATCH 05/37] fix: Cleaned up code and fixed the tests. --- .../io/static/middleware_cache_busting.dart | 104 ++++++++++-------- .../cache_busting_middleware_test.dart | 25 +++++ 2 files changed, 81 insertions(+), 48 deletions(-) diff --git a/lib/src/io/static/middleware_cache_busting.dart b/lib/src/io/static/middleware_cache_busting.dart index bfc3454b..275943bd 100644 --- a/lib/src/io/static/middleware_cache_busting.dart +++ b/lib/src/io/static/middleware_cache_busting.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'package:path/path.dart' as p; import '../../../relic.dart'; import '../../adapter/context.dart'; @@ -21,37 +22,35 @@ import '../../adapter/context.dart'; /// "/static", this rewrites the request URL to "/static/images/logo.png" /// before calling the next handler. Middleware cacheBusting(final CacheBustingConfig config) { - final normalizedMount = _normalizeMount(config.mountPrefix); - return (final inner) { return (final ctx) async { final req = ctx.request; - final fullPath = Uri.decodeFull(req.requestedUri.path); + final fullPath = req.requestedUri.path; - if (!fullPath.startsWith(normalizedMount)) { + if (!fullPath.startsWith(config.mountPrefix)) { return await inner(ctx); } // Extract the portion after mount prefix - final relative = fullPath.substring(normalizedMount.length); - final segments = relative.split('/'); - if (segments.isEmpty || segments.last.isEmpty) { + final relative = fullPath.substring(config.mountPrefix.length); + final last = p.url.basename(relative); + if (last.isEmpty) { return await inner(ctx); } - final last = segments.last; final strippedLast = _stripHashFromFilename(last); - if (identical(strippedLast, last)) { + if (strippedLast == last) { return await inner(ctx); } - segments[segments.length - 1] = strippedLast; - final rewrittenRelative = segments.join('/'); + final directory = p.url.dirname(relative); + final rewrittenRelative = + directory == '.' ? strippedLast : p.url.join(directory, strippedLast); // Rebuild a new Request only by updating requestedUri path; do not touch // handlerPath so that routers and mounts continue to work as configured. final newRequested = req.requestedUri.replace( - path: '$normalizedMount$rewrittenRelative', + path: '${config.mountPrefix}$rewrittenRelative', ); final rewrittenRequest = req.copyWith(requestedUri: newRequested); return await inner(rewrittenRequest.toContext(ctx.token)); @@ -59,6 +58,18 @@ Middleware cacheBusting(final CacheBustingConfig config) { }; } +String _stripHashFromFilename(final String fileName) { + // Match name@hash.ext or name@hash (no extension) + final ext = p.url.extension(fileName); + final base = p.url.basenameWithoutExtension(fileName); + + final at = base.lastIndexOf('@'); + if (at <= 0) return fileName; // no hash or starts with '@' + + final cleanBase = base.substring(0, at); + return p.url.setExtension(cleanBase, ext); +} + /// Holds configuration for generating cache-busted asset URLs. class CacheBustingConfig { /// The URL prefix under which static assets are served (e.g., "/static"). @@ -68,40 +79,53 @@ class CacheBustingConfig { final Directory fileSystemRoot; CacheBustingConfig({ - required this.mountPrefix, + required final String mountPrefix, required final Directory fileSystemRoot, - }) : fileSystemRoot = fileSystemRoot.absolute; + }) : mountPrefix = _normalizeMount(mountPrefix), + fileSystemRoot = fileSystemRoot.absolute; /// Returns the cache-busted URL for the given [staticPath]. /// /// Example: '/static/logo.svg' -> '/static/logo@etag.svg' Future bust(final String staticPath) async { - final normalizedMount = _normalizeMount(mountPrefix); - if (!staticPath.startsWith(normalizedMount)) return staticPath; + if (!staticPath.startsWith(mountPrefix)) return staticPath; + + final relative = staticPath.substring(mountPrefix.length); + final filePath = File(p.join(fileSystemRoot.path, relative)); - final relative = staticPath.substring(normalizedMount.length); - final filePath = File(_joinPaths(fileSystemRoot.path, relative)); + // Fail fast with a consistent exception type for non-existent files + if (!filePath.existsSync()) { + throw PathNotFoundException( + filePath.path, + const OSError('No such file or directory', 2), + ); + } final info = await getStaticFileInfo(filePath); - final lastSlash = staticPath.lastIndexOf('/'); - final dir = lastSlash >= 0 ? staticPath.substring(0, lastSlash + 1) : ''; - final fileName = - lastSlash >= 0 ? staticPath.substring(lastSlash + 1) : staticPath; + // Build the busted URL using URL path helpers for readability/portability + final directory = p.url.dirname(staticPath); + final baseName = p.url.basenameWithoutExtension(staticPath); + final ext = p.url.extension(staticPath); // includes leading dot or '' - final dot = fileName.lastIndexOf('.'); - if (dot <= 0 || dot == fileName.length - 1) { - return '$dir${_appendHashToBasename(fileName, info.etag)}'; - } + final bustedName = '$baseName@${info.etag}$ext'; + return directory == '.' + ? '/$bustedName' + : p.url.join(directory, bustedName); + } - final base = fileName.substring(0, dot); - final ext = fileName.substring(dot); // includes dot - return '$dir${_appendHashToBasename(base, info.etag)}$ext'; + /// Attempts to generate a cache-busted URL. If the underlying file cannot be + /// found or read, it returns [staticPath] unchanged. + Future tryBust(final String staticPath) async { + try { + return await bust(staticPath); + } catch (_) { + return staticPath; + } } } -String _appendHashToBasename(final String base, final String etag) => - '$base@$etag'; +// removed helper for inlining String _normalizeMount(final String mountPrefix) { if (!mountPrefix.startsWith('/')) { @@ -110,20 +134,4 @@ String _normalizeMount(final String mountPrefix) { return mountPrefix.endsWith('/') ? mountPrefix : '$mountPrefix/'; } -String _stripHashFromFilename(final String fileName) { - // Match name@hash.ext or name@hash (no extension) - final dot = fileName.lastIndexOf('.'); - final main = dot > 0 ? fileName.substring(0, dot) : fileName; - final ext = dot > 0 ? fileName.substring(dot) : ''; - - final at = main.lastIndexOf('@'); - if (at <= 0) return fileName; // no hash or starts with '@' - - final base = main.substring(0, at); - return '$base$ext'; -} - -String _joinPaths(final String a, final String b) { - if (a.endsWith('/')) return '$a$b'; - return '$a/$b'; -} +// path joining handled by package:path's p.join diff --git a/test/middleware/cache_busting_middleware_test.dart b/test/middleware/cache_busting_middleware_test.dart index a70bc289..aa4de93d 100644 --- a/test/middleware/cache_busting_middleware_test.dart +++ b/test/middleware/cache_busting_middleware_test.dart @@ -105,5 +105,30 @@ void main() { expect(response.statusCode, HttpStatus.ok); expect(await response.readAsString(), 'nested-bytes'); }); + + test('bust throws when file does not exist', () async { + final staticRoot = Directory(p.join(d.sandbox, 'static')); + final cfg = CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + ); + + expect( + cfg.bust('/static/does-not-exist.txt'), + throwsA(isA()), + ); + }); + + test('tryBust returns original path when file does not exist', () async { + final staticRoot = Directory(p.join(d.sandbox, 'static')); + final cfg = CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + ); + + const original = '/static/does-not-exist.txt'; + final result = await cfg.tryBust(original); + expect(result, original); + }); }); } From 181c8d1ffa967824d8f436fcf3c9b35a01820bd7 Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Mon, 6 Oct 2025 20:35:20 +0200 Subject: [PATCH 06/37] docs: Cleans up comments in middleware. --- .../io/static/middleware_cache_busting.dart | 40 ++++++++----------- 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/lib/src/io/static/middleware_cache_busting.dart b/lib/src/io/static/middleware_cache_busting.dart index 275943bd..c10e6201 100644 --- a/lib/src/io/static/middleware_cache_busting.dart +++ b/lib/src/io/static/middleware_cache_busting.dart @@ -4,23 +4,19 @@ import 'package:path/path.dart' as p; import '../../../relic.dart'; import '../../adapter/context.dart'; -/// Middleware and helpers for cache-busted asset URLs in the form -/// "/path/name@`hash`.ext". +/// Cache-busting for asset URLs that embed a content hash. /// /// Typical flow: -/// - Outgoing URLs are generated with [withCacheBusting] using a known mount -/// prefix (e.g. "/static") and a filesystem root directory to compute the -/// file's ETag hash. This returns "/static/name@hash.ext". -/// - Incoming requests are passed through [stripCacheBusting] so that -/// downstream handlers (e.g. static handler) receive a path without the -/// embedded hash. - -/// Returns a middleware that strips an inline cache-busting hash in the last -/// path segment under the provided [mountPrefix]. +/// - Outgoing URLs: call [CacheBustingConfig.bust] (or +/// [CacheBustingConfig.tryBust]) with a known mount prefix (e.g. "/static") +/// to get "/static/name@hash.ext". +/// - Incoming requests: add this [cacheBusting] middleware so downstream +/// handlers (e.g., the static file handler) receive "/path/name.ext" without +/// the hash. /// -/// For a request like "/static/images/logo@abc123.png" and mountPrefix -/// "/static", this rewrites the request URL to "/static/images/logo.png" -/// before calling the next handler. +/// This middleware strips an inline cache-busting hash from the last path segment +/// for requests under [CacheBustingConfig.mountPrefix]. Example: +/// "/static/images/logo@abc123.png" → "/static/images/logo.png". Middleware cacheBusting(final CacheBustingConfig config) { return (final inner) { return (final ctx) async { @@ -58,8 +54,9 @@ Middleware cacheBusting(final CacheBustingConfig config) { }; } +/// Removes a trailing "@hash" segment from a file name, preserving any +/// extension. Matches both "name@hash.ext" and "name@hash". String _stripHashFromFilename(final String fileName) { - // Match name@hash.ext or name@hash (no extension) final ext = p.url.extension(fileName); final base = p.url.basenameWithoutExtension(fileName); @@ -70,7 +67,7 @@ String _stripHashFromFilename(final String fileName) { return p.url.setExtension(cleanBase, ext); } -/// Holds configuration for generating cache-busted asset URLs. +/// Configuration and helpers for generating cache-busted asset URLs. class CacheBustingConfig { /// The URL prefix under which static assets are served (e.g., "/static"). final String mountPrefix; @@ -86,7 +83,7 @@ class CacheBustingConfig { /// Returns the cache-busted URL for the given [staticPath]. /// - /// Example: '/static/logo.svg' -> '/static/logo@etag.svg' + /// Example: '/static/logo.svg' → '/static/logo@hash.svg'. Future bust(final String staticPath) async { if (!staticPath.startsWith(mountPrefix)) return staticPath; @@ -114,8 +111,8 @@ class CacheBustingConfig { : p.url.join(directory, bustedName); } - /// Attempts to generate a cache-busted URL. If the underlying file cannot be - /// found or read, it returns [staticPath] unchanged. + /// Attempts to generate a cache-busted URL. If the file cannot be found or + /// read, returns [staticPath] unchanged. Future tryBust(final String staticPath) async { try { return await bust(staticPath); @@ -125,13 +122,10 @@ class CacheBustingConfig { } } -// removed helper for inlining - +/// Ensures [mountPrefix] starts with '/' and ends with '/'. String _normalizeMount(final String mountPrefix) { if (!mountPrefix.startsWith('/')) { throw ArgumentError('mountPrefix must start with "/"'); } return mountPrefix.endsWith('/') ? mountPrefix : '$mountPrefix/'; } - -// path joining handled by package:path's p.join From ea59dd10862de0c04ae21226ca9be4f0830a9682 Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Mon, 6 Oct 2025 20:35:38 +0200 Subject: [PATCH 07/37] refactor: Moves tests to correct directory. --- test/{middleware => static}/cache_busting_middleware_test.dart | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename test/{middleware => static}/cache_busting_middleware_test.dart (100%) diff --git a/test/middleware/cache_busting_middleware_test.dart b/test/static/cache_busting_middleware_test.dart similarity index 100% rename from test/middleware/cache_busting_middleware_test.dart rename to test/static/cache_busting_middleware_test.dart From 08af514fdfb89be0bd960dbd0ee28247e96dba2c Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Mon, 6 Oct 2025 20:49:33 +0200 Subject: [PATCH 08/37] test: Improved tests. --- .../static/cache_busting_middleware_test.dart | 112 +++++++++++++++++- 1 file changed, 111 insertions(+), 1 deletion(-) diff --git a/test/static/cache_busting_middleware_test.dart b/test/static/cache_busting_middleware_test.dart index aa4de93d..8316b4ff 100644 --- a/test/static/cache_busting_middleware_test.dart +++ b/test/static/cache_busting_middleware_test.dart @@ -5,7 +5,7 @@ import 'package:relic/relic.dart'; import 'package:test/test.dart'; import 'package:test_descriptor/test_descriptor.dart' as d; -import '../static/test_util.dart'; +import 'test_util.dart'; void main() { group('Cache busting middleware', () { @@ -130,5 +130,115 @@ void main() { final result = await cfg.tryBust(original); expect(result, original); }); + + test('bust/tryBust leave paths outside mountPrefix unchanged', () async { + final staticRoot = Directory(p.join(d.sandbox, 'static')); + final cfg = CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + ); + + const outside = '/other/logo.png'; + expect(await cfg.tryBust(outside), outside); + expect(await cfg.bust(outside), outside); + }); + + test('middleware does not rewrite when not under mount prefix', () async { + final staticRoot = Directory(p.join(d.sandbox, 'static')); + final handler = const Pipeline() + .addMiddleware(cacheBusting(CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + ))) + .addHandler(createStaticHandler( + staticRoot.path, + cacheControl: (final _, final __) => null, + )); + + final response = await makeRequest(handler, '/other/logo.png'); + expect(response.statusCode, HttpStatus.notFound); + }); + + test('bust/no-ext works and serves via middleware', () async { + // Add a no-extension file + await d.file(p.join('static', 'logo'), 'content-noext').create(); + + final staticRoot = Directory(p.join(d.sandbox, 'static')); + final cfg = CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + ); + final handler = const Pipeline() + .addMiddleware(cacheBusting(cfg)) + .addHandler(createStaticHandler( + staticRoot.path, + cacheControl: (final _, final __) => null, + )); + + const original = '/static/logo'; + final busted = await cfg.bust(original); + expect(busted, startsWith('/static/logo@')); + expect(busted, isNot(endsWith('.'))); + + final response = + await makeRequest(handler, busted, handlerPath: 'static'); + expect(response.statusCode, HttpStatus.ok); + expect(await response.readAsString(), 'content-noext'); + }); + + test('directory name containing @ is not affected', () async { + // Add nested directory with @ in its name + await d.dir(p.join('static', 'img@foo'), [ + d.file('logo.png', 'dir-at-bytes'), + ]).create(); + + final staticRoot = Directory(p.join(d.sandbox, 'static')); + final cfg = CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + ); + final handler = const Pipeline() + .addMiddleware(cacheBusting(cfg)) + .addHandler(createStaticHandler( + staticRoot.path, + cacheControl: (final _, final __) => null, + )); + + const original = '/static/img@foo/logo.png'; + final busted = await cfg.bust(original); + expect(busted, startsWith('/static/img@foo/logo@')); + expect(busted, endsWith('.png')); + + final response = + await makeRequest(handler, busted, handlerPath: 'static'); + expect(response.statusCode, HttpStatus.ok); + expect(await response.readAsString(), 'dir-at-bytes'); + }); + + test('filename starting with @ busts and strips correctly', () async { + await d.file(p.join('static', '@logo.png'), 'at-logo').create(); + + final staticRoot = Directory(p.join(d.sandbox, 'static')); + final cfg = CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + ); + final handler = const Pipeline() + .addMiddleware(cacheBusting(cfg)) + .addHandler(createStaticHandler( + staticRoot.path, + cacheControl: (final _, final __) => null, + )); + + const original = '/static/@logo.png'; + final busted = await cfg.bust(original); + expect(busted, startsWith('/static/@logo@')); + expect(busted, endsWith('.png')); + + final response = + await makeRequest(handler, busted, handlerPath: 'static'); + expect(response.statusCode, HttpStatus.ok); + expect(await response.readAsString(), 'at-logo'); + }); }); } From 154865b2fd3b5d516b43eba0063cefff7e94c598 Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Mon, 6 Oct 2025 20:49:43 +0200 Subject: [PATCH 09/37] docs: Cleans up example. --- example/static_files_example.dart | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/example/static_files_example.dart b/example/static_files_example.dart index ba212e0c..462fc520 100644 --- a/example/static_files_example.dart +++ b/example/static_files_example.dart @@ -3,11 +3,10 @@ import 'dart:io'; import 'package:relic/io_adapter.dart'; import 'package:relic/relic.dart'; -/// Minimal example serving files from the local example/static_files directory. +/// A minimal server that serves static files with cache busting. /// -/// - Serves static files under the URL prefix "/static". -/// - Try opening: http://localhost:8080/static/hello.txt -/// - Or: http://localhost:8080/static/logo.svg +/// - Serves files under the URL prefix "/static" from `example/static_files`. +/// - Try: http://localhost:8080/ Future main() async { final staticDir = Directory('static_files'); final cacheCfg = CacheBustingConfig( @@ -15,8 +14,7 @@ Future main() async { fileSystemRoot: staticDir, ); - // Router mounts the static handler under /static/** and shows an index that - // demonstrates cache-busted URLs. + // Setup router and a small index page showing cache-busted URLs. final router = Router() ..get('/', respondWith((final _) async { final helloUrl = await cacheCfg.bust('/static/hello.txt'); @@ -37,12 +35,13 @@ Future main() async { cacheControl: (final _, final __) => null, )); + // Setup a handler pipeline with logging, cache busting, and routing. final handler = const Pipeline() .addMiddleware(logRequests()) .addMiddleware(cacheBusting(cacheCfg)) .addMiddleware(routeWith(router)) .addHandler(respondWith((final _) => Response.notFound())); + // Start the server await serve(handler, InternetAddress.loopbackIPv4, 8080); - // Now open your browser at: http://localhost:8080/ } From 5ecd930c6d76aa68b50e9aef32a6641f6d5130e0 Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Mon, 6 Oct 2025 21:58:34 +0200 Subject: [PATCH 10/37] test: Improved test coverage. --- .../static/cache_busting_middleware_test.dart | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/test/static/cache_busting_middleware_test.dart b/test/static/cache_busting_middleware_test.dart index 8316b4ff..1a6ae017 100644 --- a/test/static/cache_busting_middleware_test.dart +++ b/test/static/cache_busting_middleware_test.dart @@ -159,6 +159,77 @@ void main() { expect(response.statusCode, HttpStatus.notFound); }); + test('middleware does not rewrite trailing-slash requests (empty basename)', + () async { + final staticRoot = Directory(p.join(d.sandbox, 'static')); + final handler = const Pipeline() + .addMiddleware(cacheBusting(CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + ))) + .addHandler(createStaticHandler( + staticRoot.path, + cacheControl: (final _, final __) => null, + )); + + // Requesting a directory trailing slash should not be rewritten and + // directory listings are not supported -> 404. + final response = + await makeRequest(handler, '/static/images/', handlerPath: 'static'); + expect(response.statusCode, HttpStatus.notFound); + }); + + test('middleware passes through non-busted filenames (no @)', () async { + final staticRoot = Directory(p.join(d.sandbox, 'static')); + final cfg = CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + ); + final handler = const Pipeline() + .addMiddleware(cacheBusting(cfg)) + .addHandler(createStaticHandler( + staticRoot.path, + cacheControl: (final _, final __) => null, + )); + + final response = + await makeRequest(handler, '/static/logo.png', handlerPath: 'static'); + expect(response.statusCode, HttpStatus.ok); + expect(await response.readAsString(), 'png-bytes'); + }); + + test('middleware does not strip leading @ when no hash present', () async { + await d.file(p.join('static', '@plain.txt'), 'plain-at').create(); + + final staticRoot = Directory(p.join(d.sandbox, 'static')); + final cfg = CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + ); + final handler = const Pipeline() + .addMiddleware(cacheBusting(cfg)) + .addHandler(createStaticHandler( + staticRoot.path, + cacheControl: (final _, final __) => null, + )); + + final response = await makeRequest(handler, '/static/@plain.txt', + handlerPath: 'static'); + expect(response.statusCode, HttpStatus.ok); + expect(await response.readAsString(), 'plain-at'); + }); + + test('invalid mountPrefix throws (must start with /)', () async { + final staticRoot = Directory(p.join(d.sandbox, 'static')); + expect( + () => CacheBustingConfig( + mountPrefix: 'static', + fileSystemRoot: staticRoot, + ), + throwsA(isA()), + ); + }); + test('bust/no-ext works and serves via middleware', () async { // Add a no-extension file await d.file(p.join('static', 'logo'), 'content-noext').create(); From 71cb46e7b3663d07965774f3ef7b5ce17d5a2e66 Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Mon, 6 Oct 2025 22:06:03 +0200 Subject: [PATCH 11/37] fix: Removes unused code. --- lib/src/io/static/middleware_cache_busting.dart | 4 ++-- test/static/cache_busting_middleware_test.dart | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/src/io/static/middleware_cache_busting.dart b/lib/src/io/static/middleware_cache_busting.dart index c10e6201..fdacf565 100644 --- a/lib/src/io/static/middleware_cache_busting.dart +++ b/lib/src/io/static/middleware_cache_busting.dart @@ -29,10 +29,10 @@ Middleware cacheBusting(final CacheBustingConfig config) { // Extract the portion after mount prefix final relative = fullPath.substring(config.mountPrefix.length); - final last = p.url.basename(relative); - if (last.isEmpty) { + if (relative.isEmpty) { return await inner(ctx); } + final last = p.url.basename(relative); final strippedLast = _stripHashFromFilename(last); if (strippedLast == last) { diff --git a/test/static/cache_busting_middleware_test.dart b/test/static/cache_busting_middleware_test.dart index 1a6ae017..ed6db021 100644 --- a/test/static/cache_busting_middleware_test.dart +++ b/test/static/cache_busting_middleware_test.dart @@ -172,10 +172,10 @@ void main() { cacheControl: (final _, final __) => null, )); - // Requesting a directory trailing slash should not be rewritten and + // Requesting the mount prefix itself should not be rewritten and // directory listings are not supported -> 404. final response = - await makeRequest(handler, '/static/images/', handlerPath: 'static'); + await makeRequest(handler, '/static/', handlerPath: 'static'); expect(response.statusCode, HttpStatus.notFound); }); From 372d0b26d1de3af026d333232d16228e8b90b649 Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Mon, 6 Oct 2025 22:14:21 +0200 Subject: [PATCH 12/37] test: Adds test for coverage. --- test/static/cache_busting_middleware_test.dart | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/test/static/cache_busting_middleware_test.dart b/test/static/cache_busting_middleware_test.dart index ed6db021..b98a3df6 100644 --- a/test/static/cache_busting_middleware_test.dart +++ b/test/static/cache_busting_middleware_test.dart @@ -179,6 +179,24 @@ void main() { expect(response.statusCode, HttpStatus.notFound); }); + test('middleware early-returns for mount root (relative empty)', () async { + final staticRoot = Directory(p.join(d.sandbox, 'static')); + final cfg = CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + ); + + // Echo handler to observe the requestedUri.path after middleware. + final handler = const Pipeline() + .addMiddleware(cacheBusting(cfg)) + .addHandler(respondWith((final ctx) => + Response.ok(body: Body.fromString(ctx.requestedUri.path)))); + + final response = await makeRequest(handler, '/static/'); + expect(response.statusCode, HttpStatus.ok); + expect(await response.readAsString(), '/static/'); + }); + test('middleware passes through non-busted filenames (no @)', () async { final staticRoot = Directory(p.join(d.sandbox, 'static')); final cfg = CacheBustingConfig( From 11b1b043e163f96838b4e6630ed38834e43938d2 Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Tue, 7 Oct 2025 07:31:26 +0200 Subject: [PATCH 13/37] fix: Fixes a potential security issue, where etags could have been accessed outside the root directory. --- .../io/static/middleware_cache_busting.dart | 34 ++++++++++++++++--- .../static/cache_busting_middleware_test.dart | 19 +++++++++++ 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/lib/src/io/static/middleware_cache_busting.dart b/lib/src/io/static/middleware_cache_busting.dart index fdacf565..d98f38a8 100644 --- a/lib/src/io/static/middleware_cache_busting.dart +++ b/lib/src/io/static/middleware_cache_busting.dart @@ -88,17 +88,41 @@ class CacheBustingConfig { if (!staticPath.startsWith(mountPrefix)) return staticPath; final relative = staticPath.substring(mountPrefix.length); - final filePath = File(p.join(fileSystemRoot.path, relative)); + final resolvedRootPath = fileSystemRoot.resolveSymbolicLinksSync(); + final joinedPath = p.join(resolvedRootPath, relative); + final normalizedPath = p.normalize(joinedPath); + + // Reject traversal before hitting the filesystem + if (!p.isWithin(resolvedRootPath, normalizedPath) && + normalizedPath != resolvedRootPath) { + throw ArgumentError.value( + staticPath, + 'staticPath', + 'must stay within $mountPrefix', + ); + } - // Fail fast with a consistent exception type for non-existent files - if (!filePath.existsSync()) { + // Ensure target exists (files only) before resolving symlinks + final entityType = + FileSystemEntity.typeSync(normalizedPath, followLinks: false); + if (entityType == FileSystemEntityType.notFound || + entityType == FileSystemEntityType.directory) { throw PathNotFoundException( - filePath.path, + normalizedPath, const OSError('No such file or directory', 2), ); } - final info = await getStaticFileInfo(filePath); + final resolvedFilePath = File(normalizedPath).resolveSymbolicLinksSync(); + if (!p.isWithin(resolvedRootPath, resolvedFilePath)) { + throw ArgumentError.value( + staticPath, + 'staticPath', + 'must stay within $mountPrefix', + ); + } + + final info = await getStaticFileInfo(File(resolvedFilePath)); // Build the busted URL using URL path helpers for readability/portability final directory = p.url.dirname(staticPath); diff --git a/test/static/cache_busting_middleware_test.dart b/test/static/cache_busting_middleware_test.dart index b98a3df6..26c3cb65 100644 --- a/test/static/cache_busting_middleware_test.dart +++ b/test/static/cache_busting_middleware_test.dart @@ -119,6 +119,25 @@ void main() { ); }); + test('bust prevents path traversal outside static root', () async { + final staticRoot = Directory(p.join(d.sandbox, 'static')); + final cfg = CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + ); + + expect( + cfg.bust('/static/../secret.txt'), + throwsA(isA()), + ); + + // Also reject absolute after mount + expect( + cfg.bust('/static//etc/passwd'), + throwsA(isA()), + ); + }); + test('tryBust returns original path when file does not exist', () async { final staticRoot = Directory(p.join(d.sandbox, 'static')); final cfg = CacheBustingConfig( From cc1d3dab242d962aae86d13b69d67557e6e18b0e Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Tue, 7 Oct 2025 07:39:21 +0200 Subject: [PATCH 14/37] fix: Updates example to show error message if running from incorrect directory. --- example/static_files_example.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/example/static_files_example.dart b/example/static_files_example.dart index 462fc520..2678d0bc 100644 --- a/example/static_files_example.dart +++ b/example/static_files_example.dart @@ -1,3 +1,5 @@ +// ignore_for_file: avoid_print + import 'dart:io'; import 'package:relic/io_adapter.dart'; @@ -9,6 +11,10 @@ import 'package:relic/relic.dart'; /// - Try: http://localhost:8080/ Future main() async { final staticDir = Directory('static_files'); + if (!staticDir.existsSync()) { + print('Please run this example from example directory (cd example).'); + return; + } final cacheCfg = CacheBustingConfig( mountPrefix: '/static', fileSystemRoot: staticDir, From 9e140d4a0f86ce6172b3712422a8b62f38c6c949 Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Tue, 7 Oct 2025 07:44:40 +0200 Subject: [PATCH 15/37] test: Extending code coverage. --- .../static/cache_busting_middleware_test.dart | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/test/static/cache_busting_middleware_test.dart b/test/static/cache_busting_middleware_test.dart index 26c3cb65..4db5337d 100644 --- a/test/static/cache_busting_middleware_test.dart +++ b/test/static/cache_busting_middleware_test.dart @@ -138,6 +138,34 @@ void main() { ); }); + test('bust prevents symlink traversal outside static root', () async { + // Create a file outside the static root + final outsidePath = p.join(d.sandbox, 'outside.txt'); + await File(outsidePath).writeAsString('outside'); + + // Create a symlink inside static pointing to the outside file + final linkPath = p.join(d.sandbox, 'static', 'escape.txt'); + final link = Link(linkPath); + try { + // Use absolute target to be explicit + await link.create(outsidePath); + } on FileSystemException { + // If the platform forbids symlinks, skip this test gracefully + return; + } + + final staticRoot = Directory(p.join(d.sandbox, 'static')); + final cfg = CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + ); + + expect( + cfg.bust('/static/escape.txt'), + throwsA(isA()), + ); + }); + test('tryBust returns original path when file does not exist', () async { final staticRoot = Directory(p.join(d.sandbox, 'static')); final cfg = CacheBustingConfig( From 527907cd08d75d60f4d2b5574a8ee7bcbea3e798 Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Tue, 7 Oct 2025 17:14:29 +0200 Subject: [PATCH 16/37] test: Moves to Given-When-Then structure for naming the tests. --- .../static/cache_busting_middleware_test.dart | 67 ++++++++++++++----- 1 file changed, 49 insertions(+), 18 deletions(-) diff --git a/test/static/cache_busting_middleware_test.dart b/test/static/cache_busting_middleware_test.dart index 4db5337d..3d169b92 100644 --- a/test/static/cache_busting_middleware_test.dart +++ b/test/static/cache_busting_middleware_test.dart @@ -8,7 +8,7 @@ import 'package:test_descriptor/test_descriptor.dart' as d; import 'test_util.dart'; void main() { - group('Cache busting middleware', () { + group('Given cache busting middleware and a static directory', () { setUp(() async { await d.dir('static', [ d.file('logo.png', 'png-bytes'), @@ -16,7 +16,8 @@ void main() { ]).create(); }); - test('generates cache-busted URL and serves via root-mounted handler', + test( + 'when the static handler is mounted at root then requesting a busted URL serves the file', () async { final staticRoot = Directory(p.join(d.sandbox, 'static')); final handler = const Pipeline() @@ -48,7 +49,9 @@ void main() { expect(await response.readAsString(), 'png-bytes'); }); - test('works when static handler is mounted under router', () async { + test( + 'and the static handler is router-mounted when requesting a busted URL then it serves the file', + () async { final staticRoot = Directory(p.join(d.sandbox, 'static')); final router = Router() ..get( @@ -78,7 +81,9 @@ void main() { expect(await response.readAsString(), 'nested-bytes'); }); - test('works with custom handlerPath mounted under root', () async { + test( + 'and handlerPath is used under root when requesting a busted URL then it serves the file', + () async { final staticRoot = Directory(p.join(d.sandbox, 'static')); final handler = const Pipeline() .addMiddleware(cacheBusting(CacheBustingConfig( @@ -106,7 +111,9 @@ void main() { expect(await response.readAsString(), 'nested-bytes'); }); - test('bust throws when file does not exist', () async { + test( + 'when bust is called for a missing file then it throws PathNotFoundException', + () async { final staticRoot = Directory(p.join(d.sandbox, 'static')); final cfg = CacheBustingConfig( mountPrefix: '/static', @@ -119,7 +126,9 @@ void main() { ); }); - test('bust prevents path traversal outside static root', () async { + test( + 'when traversal attempts are made then bust rejects paths outside root', + () async { final staticRoot = Directory(p.join(d.sandbox, 'static')); final cfg = CacheBustingConfig( mountPrefix: '/static', @@ -138,7 +147,9 @@ void main() { ); }); - test('bust prevents symlink traversal outside static root', () async { + test( + 'and a symlink escapes the root when calling bust then it rejects paths outside root', + () async { // Create a file outside the static root final outsidePath = p.join(d.sandbox, 'outside.txt'); await File(outsidePath).writeAsString('outside'); @@ -166,7 +177,9 @@ void main() { ); }); - test('tryBust returns original path when file does not exist', () async { + test( + 'when tryBust is called for a missing file then it returns the original path', + () async { final staticRoot = Directory(p.join(d.sandbox, 'static')); final cfg = CacheBustingConfig( mountPrefix: '/static', @@ -178,7 +191,9 @@ void main() { expect(result, original); }); - test('bust/tryBust leave paths outside mountPrefix unchanged', () async { + test( + 'when the path is outside mountPrefix then bust/tryBust return it unchanged', + () async { final staticRoot = Directory(p.join(d.sandbox, 'static')); final cfg = CacheBustingConfig( mountPrefix: '/static', @@ -190,7 +205,9 @@ void main() { expect(await cfg.bust(outside), outside); }); - test('middleware does not rewrite when not under mount prefix', () async { + test( + 'when a request is outside mountPrefix then middleware does not rewrite', + () async { final staticRoot = Directory(p.join(d.sandbox, 'static')); final handler = const Pipeline() .addMiddleware(cacheBusting(CacheBustingConfig( @@ -206,7 +223,8 @@ void main() { expect(response.statusCode, HttpStatus.notFound); }); - test('middleware does not rewrite trailing-slash requests (empty basename)', + test( + 'and the request is the mount root (trailing slash) then it is not rewritten', () async { final staticRoot = Directory(p.join(d.sandbox, 'static')); final handler = const Pipeline() @@ -226,7 +244,9 @@ void main() { expect(response.statusCode, HttpStatus.notFound); }); - test('middleware early-returns for mount root (relative empty)', () async { + test( + 'and the request is the mount root then middleware early-returns unchanged', + () async { final staticRoot = Directory(p.join(d.sandbox, 'static')); final cfg = CacheBustingConfig( mountPrefix: '/static', @@ -244,7 +264,8 @@ void main() { expect(await response.readAsString(), '/static/'); }); - test('middleware passes through non-busted filenames (no @)', () async { + test('and the filename is not busted then middleware serves the file', + () async { final staticRoot = Directory(p.join(d.sandbox, 'static')); final cfg = CacheBustingConfig( mountPrefix: '/static', @@ -263,7 +284,9 @@ void main() { expect(await response.readAsString(), 'png-bytes'); }); - test('middleware does not strip leading @ when no hash present', () async { + test( + 'and the filename starts with @ and has no hash then it is not stripped', + () async { await d.file(p.join('static', '@plain.txt'), 'plain-at').create(); final staticRoot = Directory(p.join(d.sandbox, 'static')); @@ -284,7 +307,9 @@ void main() { expect(await response.readAsString(), 'plain-at'); }); - test('invalid mountPrefix throws (must start with /)', () async { + test( + 'when creating config with invalid mountPrefix then it throws ArgumentError', + () async { final staticRoot = Directory(p.join(d.sandbox, 'static')); expect( () => CacheBustingConfig( @@ -295,7 +320,9 @@ void main() { ); }); - test('bust/no-ext works and serves via middleware', () async { + test( + 'and the file has no extension when calling bust then it serves via middleware', + () async { // Add a no-extension file await d.file(p.join('static', 'logo'), 'content-noext').create(); @@ -322,7 +349,9 @@ void main() { expect(await response.readAsString(), 'content-noext'); }); - test('directory name containing @ is not affected', () async { + test( + 'and a directory name contains @ when calling bust then only the filename is affected', + () async { // Add nested directory with @ in its name await d.dir(p.join('static', 'img@foo'), [ d.file('logo.png', 'dir-at-bytes'), @@ -351,7 +380,9 @@ void main() { expect(await response.readAsString(), 'dir-at-bytes'); }); - test('filename starting with @ busts and strips correctly', () async { + test( + 'Given a filename starting with @ when calling bust then it busts and strips correctly', + () async { await d.file(p.join('static', '@logo.png'), 'at-logo').create(); final staticRoot = Directory(p.join(d.sandbox, 'static')); From 1c5ef4255638195d003383728d829713d21380e8 Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Tue, 7 Oct 2025 17:42:06 +0200 Subject: [PATCH 17/37] fix: Adds cache control to the example. --- example/static_files_example.dart | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/example/static_files_example.dart b/example/static_files_example.dart index 2678d0bc..4dfd1de6 100644 --- a/example/static_files_example.dart +++ b/example/static_files_example.dart @@ -20,7 +20,8 @@ Future main() async { fileSystemRoot: staticDir, ); - // Setup router and a small index page showing cache-busted URLs. + // Setup router and a small index page showing cache-busted URLs. We're + // setting the cache control header to immutable for a year. final router = Router() ..get('/', respondWith((final _) async { final helloUrl = await cacheCfg.bust('/static/hello.txt'); @@ -38,7 +39,11 @@ Future main() async { '/static/**', createStaticHandler( staticDir.path, - cacheControl: (final _, final __) => null, + cacheControl: (final _, final __) => CacheControlHeader( + maxAge: 31536000, + publicCache: true, + immutable: true, + ), )); // Setup a handler pipeline with logging, cache busting, and routing. From 23e82b45ecfd6dff4f37a402e5a503d014919ab9 Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Tue, 7 Oct 2025 17:43:47 +0200 Subject: [PATCH 18/37] fix: Renames the bust method to assetPath. --- example/static_files_example.dart | 8 +++---- .../io/static/middleware_cache_busting.dart | 6 ++--- .../static/cache_busting_middleware_test.dart | 22 +++++++++---------- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/example/static_files_example.dart b/example/static_files_example.dart index 4dfd1de6..64fe5a38 100644 --- a/example/static_files_example.dart +++ b/example/static_files_example.dart @@ -15,7 +15,7 @@ Future main() async { print('Please run this example from example directory (cd example).'); return; } - final cacheCfg = CacheBustingConfig( + final buster = CacheBustingConfig( mountPrefix: '/static', fileSystemRoot: staticDir, ); @@ -24,8 +24,8 @@ Future main() async { // setting the cache control header to immutable for a year. final router = Router() ..get('/', respondWith((final _) async { - final helloUrl = await cacheCfg.bust('/static/hello.txt'); - final logoUrl = await cacheCfg.bust('/static/logo.svg'); + final helloUrl = await buster.assetPath('/static/hello.txt'); + final logoUrl = await buster.assetPath('/static/logo.svg'); final html = '' '

      Static files with cache busting

      ' '
        ' @@ -49,7 +49,7 @@ Future main() async { // Setup a handler pipeline with logging, cache busting, and routing. final handler = const Pipeline() .addMiddleware(logRequests()) - .addMiddleware(cacheBusting(cacheCfg)) + .addMiddleware(cacheBusting(buster)) .addMiddleware(routeWith(router)) .addHandler(respondWith((final _) => Response.notFound())); diff --git a/lib/src/io/static/middleware_cache_busting.dart b/lib/src/io/static/middleware_cache_busting.dart index d98f38a8..4f6b4f55 100644 --- a/lib/src/io/static/middleware_cache_busting.dart +++ b/lib/src/io/static/middleware_cache_busting.dart @@ -7,7 +7,7 @@ import '../../adapter/context.dart'; /// Cache-busting for asset URLs that embed a content hash. /// /// Typical flow: -/// - Outgoing URLs: call [CacheBustingConfig.bust] (or +/// - Outgoing URLs: call [CacheBustingConfig.assetPath] (or /// [CacheBustingConfig.tryBust]) with a known mount prefix (e.g. "/static") /// to get "/static/name@hash.ext". /// - Incoming requests: add this [cacheBusting] middleware so downstream @@ -84,7 +84,7 @@ class CacheBustingConfig { /// Returns the cache-busted URL for the given [staticPath]. /// /// Example: '/static/logo.svg' → '/static/logo@hash.svg'. - Future bust(final String staticPath) async { + Future assetPath(final String staticPath) async { if (!staticPath.startsWith(mountPrefix)) return staticPath; final relative = staticPath.substring(mountPrefix.length); @@ -139,7 +139,7 @@ class CacheBustingConfig { /// read, returns [staticPath] unchanged. Future tryBust(final String staticPath) async { try { - return await bust(staticPath); + return await assetPath(staticPath); } catch (_) { return staticPath; } diff --git a/test/static/cache_busting_middleware_test.dart b/test/static/cache_busting_middleware_test.dart index 3d169b92..fdebf006 100644 --- a/test/static/cache_busting_middleware_test.dart +++ b/test/static/cache_busting_middleware_test.dart @@ -35,7 +35,7 @@ void main() { mountPrefix: '/static', fileSystemRoot: staticRoot, ); - final busted = await cfg.bust(original); + final busted = await cfg.assetPath(original); expect(busted, isNot(original)); expect(busted, startsWith('/static/logo@')); expect(busted, endsWith('.png')); @@ -74,7 +74,7 @@ void main() { mountPrefix: '/assets', fileSystemRoot: staticRoot, ); - final busted = await cfg.bust(original); + final busted = await cfg.assetPath(original); final response = await makeRequest(handler, busted); expect(response.statusCode, HttpStatus.ok); @@ -100,7 +100,7 @@ void main() { mountPrefix: '/static', fileSystemRoot: staticRoot, ); - final busted = await cfg.bust(original); + final busted = await cfg.assetPath(original); final response = await makeRequest( handler, @@ -121,7 +121,7 @@ void main() { ); expect( - cfg.bust('/static/does-not-exist.txt'), + cfg.assetPath('/static/does-not-exist.txt'), throwsA(isA()), ); }); @@ -136,13 +136,13 @@ void main() { ); expect( - cfg.bust('/static/../secret.txt'), + cfg.assetPath('/static/../secret.txt'), throwsA(isA()), ); // Also reject absolute after mount expect( - cfg.bust('/static//etc/passwd'), + cfg.assetPath('/static//etc/passwd'), throwsA(isA()), ); }); @@ -172,7 +172,7 @@ void main() { ); expect( - cfg.bust('/static/escape.txt'), + cfg.assetPath('/static/escape.txt'), throwsA(isA()), ); }); @@ -202,7 +202,7 @@ void main() { const outside = '/other/logo.png'; expect(await cfg.tryBust(outside), outside); - expect(await cfg.bust(outside), outside); + expect(await cfg.assetPath(outside), outside); }); test( @@ -339,7 +339,7 @@ void main() { )); const original = '/static/logo'; - final busted = await cfg.bust(original); + final busted = await cfg.assetPath(original); expect(busted, startsWith('/static/logo@')); expect(busted, isNot(endsWith('.'))); @@ -370,7 +370,7 @@ void main() { )); const original = '/static/img@foo/logo.png'; - final busted = await cfg.bust(original); + final busted = await cfg.assetPath(original); expect(busted, startsWith('/static/img@foo/logo@')); expect(busted, endsWith('.png')); @@ -398,7 +398,7 @@ void main() { )); const original = '/static/@logo.png'; - final busted = await cfg.bust(original); + final busted = await cfg.assetPath(original); expect(busted, startsWith('/static/@logo@')); expect(busted, endsWith('.png')); From b472b395dc19e7a2ae9664d217616163b940a4fe Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Tue, 7 Oct 2025 17:56:24 +0200 Subject: [PATCH 19/37] feat: Makes separator configurable. --- .../io/static/middleware_cache_busting.dart | 21 +++++--- .../static/cache_busting_middleware_test.dart | 53 +++++++++++++++++++ 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/lib/src/io/static/middleware_cache_busting.dart b/lib/src/io/static/middleware_cache_busting.dart index 4f6b4f55..4c479a86 100644 --- a/lib/src/io/static/middleware_cache_busting.dart +++ b/lib/src/io/static/middleware_cache_busting.dart @@ -34,7 +34,7 @@ Middleware cacheBusting(final CacheBustingConfig config) { } final last = p.url.basename(relative); - final strippedLast = _stripHashFromFilename(last); + final strippedLast = _stripHashFromFilename(last, config.separator); if (strippedLast == last) { return await inner(ctx); } @@ -54,14 +54,17 @@ Middleware cacheBusting(final CacheBustingConfig config) { }; } -/// Removes a trailing "@hash" segment from a file name, preserving any -/// extension. Matches both "name@hash.ext" and "name@hash". -String _stripHashFromFilename(final String fileName) { +/// Removes a trailing "``hash" segment from a file name, preserving any +/// extension. Matches both "`namehash`.ext" and "`namehash`". +String _stripHashFromFilename( + final String fileName, + final String separator, +) { final ext = p.url.extension(fileName); final base = p.url.basenameWithoutExtension(fileName); - final at = base.lastIndexOf('@'); - if (at <= 0) return fileName; // no hash or starts with '@' + final at = base.lastIndexOf(separator); + if (at <= 0) return fileName; // no hash or starts with separator final cleanBase = base.substring(0, at); return p.url.setExtension(cleanBase, ext); @@ -75,9 +78,13 @@ class CacheBustingConfig { /// Filesystem root corresponding to [mountPrefix]. final Directory fileSystemRoot; + /// Separator between base filename and hash (e.g., "@"). + final String separator; + CacheBustingConfig({ required final String mountPrefix, required final Directory fileSystemRoot, + this.separator = '@', }) : mountPrefix = _normalizeMount(mountPrefix), fileSystemRoot = fileSystemRoot.absolute; @@ -129,7 +136,7 @@ class CacheBustingConfig { final baseName = p.url.basenameWithoutExtension(staticPath); final ext = p.url.extension(staticPath); // includes leading dot or '' - final bustedName = '$baseName@${info.etag}$ext'; + final bustedName = '$baseName$separator${info.etag}$ext'; return directory == '.' ? '/$bustedName' : p.url.join(directory, bustedName); diff --git a/test/static/cache_busting_middleware_test.dart b/test/static/cache_busting_middleware_test.dart index fdebf006..8246571b 100644 --- a/test/static/cache_busting_middleware_test.dart +++ b/test/static/cache_busting_middleware_test.dart @@ -349,6 +349,59 @@ void main() { expect(await response.readAsString(), 'content-noext'); }); + test( + 'and a custom separator is configured when requesting a busted URL then it serves the file', + () async { + final staticRoot = Directory(p.join(d.sandbox, 'static')); + final cfg = CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + separator: '--', + ); + final handler = const Pipeline() + .addMiddleware(cacheBusting(cfg)) + .addHandler(createStaticHandler( + staticRoot.path, + cacheControl: (final _, final __) => null, + )); + + const original = '/static/logo.png'; + final busted = await cfg.assetPath(original); + expect(busted, isNot(original)); + expect(busted, startsWith('/static/logo--')); + expect(busted, endsWith('.png')); + + final response = + await makeRequest(handler, busted, handlerPath: 'static'); + expect(response.statusCode, HttpStatus.ok); + expect(await response.readAsString(), 'png-bytes'); + }); + + test( + 'and the filename starts with the separator and has no hash then it is not stripped', + () async { + // Create a file that starts with the separator characters + await d.file(p.join('static', '--plain.txt'), 'dashdash').create(); + + final staticRoot = Directory(p.join(d.sandbox, 'static')); + final cfg = CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + separator: '--', + ); + final handler = const Pipeline() + .addMiddleware(cacheBusting(cfg)) + .addHandler(createStaticHandler( + staticRoot.path, + cacheControl: (final _, final __) => null, + )); + + final response = await makeRequest(handler, '/static/--plain.txt', + handlerPath: 'static'); + expect(response.statusCode, HttpStatus.ok); + expect(await response.readAsString(), 'dashdash'); + }); + test( 'and a directory name contains @ when calling bust then only the filename is affected', () async { From b5ab9348cf387bfb4344eeced46ec52c5510be7e Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Tue, 7 Oct 2025 18:04:54 +0200 Subject: [PATCH 20/37] fix: Splits test in two. --- test/static/cache_busting_middleware_test.dart | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/test/static/cache_busting_middleware_test.dart b/test/static/cache_busting_middleware_test.dart index 8246571b..e4c10fa5 100644 --- a/test/static/cache_busting_middleware_test.dart +++ b/test/static/cache_busting_middleware_test.dart @@ -126,8 +126,7 @@ void main() { ); }); - test( - 'when traversal attempts are made then bust rejects paths outside root', + test('when path contains .. segments then bust rejects paths outside root', () async { final staticRoot = Directory(p.join(d.sandbox, 'static')); final cfg = CacheBustingConfig( @@ -139,8 +138,17 @@ void main() { cfg.assetPath('/static/../secret.txt'), throwsA(isA()), ); + }); + + test( + 'when absolute path segment appears after mount then bust rejects paths outside root', + () async { + final staticRoot = Directory(p.join(d.sandbox, 'static')); + final cfg = CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + ); - // Also reject absolute after mount expect( cfg.assetPath('/static//etc/passwd'), throwsA(isA()), From 7da74c509a3b5ca2f6f6aa7ea65b4e5676a64b12 Mon Sep 17 00:00:00 2001 From: Viktor Lidholt Date: Wed, 8 Oct 2025 08:41:58 +0200 Subject: [PATCH 21/37] test: Splits up tests in two files. --- test/static/cache_busting_config_test.dart | 204 ++++++++++++++++++ .../static/cache_busting_middleware_test.dart | 167 +------------- 2 files changed, 214 insertions(+), 157 deletions(-) create mode 100644 test/static/cache_busting_config_test.dart diff --git a/test/static/cache_busting_config_test.dart b/test/static/cache_busting_config_test.dart new file mode 100644 index 00000000..425e1548 --- /dev/null +++ b/test/static/cache_busting_config_test.dart @@ -0,0 +1,204 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:relic/relic.dart'; +import 'package:test/test.dart'; +import 'package:test_descriptor/test_descriptor.dart' as d; + +void main() { + group('Given CacheBustingConfig and a static directory', () { + setUp(() async { + await d.dir('static', [ + d.file('logo.png', 'png-bytes'), + d.dir('images', [d.file('logo.png', 'nested-bytes')]), + ]).create(); + }); + + test( + 'when bust is called for a missing file then it throws PathNotFoundException', + () async { + final staticRoot = Directory(p.join(d.sandbox, 'static')); + final cfg = CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + ); + + expect( + cfg.assetPath('/static/does-not-exist.txt'), + throwsA(isA()), + ); + }); + + test('when path contains .. segments then bust rejects paths outside root', + () async { + final staticRoot = Directory(p.join(d.sandbox, 'static')); + final cfg = CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + ); + + expect( + cfg.assetPath('/static/../secret.txt'), + throwsA(isA()), + ); + }); + + test( + 'when absolute path segment appears after mount then bust rejects paths outside root', + () async { + final staticRoot = Directory(p.join(d.sandbox, 'static')); + final cfg = CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + ); + + expect( + cfg.assetPath('/static//etc/passwd'), + throwsA(isA()), + ); + }); + + test( + 'and a symlink escapes the root when calling bust then it rejects paths outside root', + () async { + final outsidePath = p.join(d.sandbox, 'outside.txt'); + await File(outsidePath).writeAsString('outside'); + + final linkPath = p.join(d.sandbox, 'static', 'escape.txt'); + final link = Link(linkPath); + try { + await link.create(outsidePath); + } on FileSystemException { + return; // platform forbids symlinks + } + + final staticRoot = Directory(p.join(d.sandbox, 'static')); + final cfg = CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + ); + + expect( + cfg.assetPath('/static/escape.txt'), + throwsA(isA()), + ); + }); + + test( + 'when tryBust is called for a missing file then it returns the original path', + () async { + final staticRoot = Directory(p.join(d.sandbox, 'static')); + final cfg = CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + ); + + const original = '/static/does-not-exist.txt'; + final result = await cfg.tryBust(original); + expect(result, original); + }); + + test( + 'when the path is outside mountPrefix then tryBust returns it unchanged', + () async { + final staticRoot = Directory(p.join(d.sandbox, 'static')); + final cfg = CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + ); + + const outside = '/other/logo.png'; + expect(await cfg.tryBust(outside), outside); + }); + + test( + 'when the path is outside mountPrefix then assetPath returns it unchanged', + () async { + final staticRoot = Directory(p.join(d.sandbox, 'static')); + final cfg = CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + ); + + const outside = '/other/logo.png'; + expect(await cfg.assetPath(outside), outside); + }); + + test( + 'when creating config with invalid mountPrefix then it throws ArgumentError', + () async { + final staticRoot = Directory(p.join(d.sandbox, 'static')); + expect( + () => CacheBustingConfig( + mountPrefix: 'static', + fileSystemRoot: staticRoot, + ), + throwsA(isA()), + ); + }); + + test('when busting a file without extension then it returns a busted path', + () async { + await d.file(p.join('static', 'logo'), 'content-noext').create(); + + final staticRoot = Directory(p.join(d.sandbox, 'static')); + final cfg = CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + ); + + const original = '/static/logo'; + final busted = await cfg.assetPath(original); + expect(busted, startsWith('/static/logo@')); + }); + + test( + 'and a custom separator is configured when busting then it uses that separator', + () async { + final staticRoot = Directory(p.join(d.sandbox, 'static')); + final cfg = CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + separator: '--', + ); + + const original = '/static/logo.png'; + final busted = await cfg.assetPath(original); + expect(busted, startsWith('/static/logo--')); + }); + + test( + 'and a directory name contains @ when calling bust then only the filename is affected', + () async { + await d.dir(p.join('static', 'img@foo'), [ + d.file('logo.png', 'dir-at-bytes'), + ]).create(); + + final staticRoot = Directory(p.join(d.sandbox, 'static')); + final cfg = CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + ); + + const original = '/static/img@foo/logo.png'; + final busted = await cfg.assetPath(original); + expect(busted, startsWith('/static/img@foo/logo@')); + }); + + test( + 'Given a filename starting with @ when calling bust then it busts correctly', + () async { + await d.file(p.join('static', '@logo.png'), 'at-logo').create(); + + final staticRoot = Directory(p.join(d.sandbox, 'static')); + final cfg = CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + ); + + const original = '/static/@logo.png'; + final busted = await cfg.assetPath(original); + expect(busted, startsWith('/static/@logo@')); + }); + }); +} diff --git a/test/static/cache_busting_middleware_test.dart b/test/static/cache_busting_middleware_test.dart index e4c10fa5..21a96708 100644 --- a/test/static/cache_busting_middleware_test.dart +++ b/test/static/cache_busting_middleware_test.dart @@ -30,15 +30,7 @@ void main() { cacheControl: (final _, final __) => null, )); - const original = '/static/logo.png'; - final cfg = CacheBustingConfig( - mountPrefix: '/static', - fileSystemRoot: staticRoot, - ); - final busted = await cfg.assetPath(original); - expect(busted, isNot(original)); - expect(busted, startsWith('/static/logo@')); - expect(busted, endsWith('.png')); + const busted = '/static/logo@abc.png'; final response = await makeRequest( handler, @@ -69,12 +61,7 @@ void main() { .addMiddleware(routeWith(router)) .addHandler(respondWith((final _) => Response.notFound())); - const original = '/assets/images/logo.png'; - final cfg = CacheBustingConfig( - mountPrefix: '/assets', - fileSystemRoot: staticRoot, - ); - final busted = await cfg.assetPath(original); + const busted = '/assets/images/logo@abc.png'; final response = await makeRequest(handler, busted); expect(response.statusCode, HttpStatus.ok); @@ -95,12 +82,7 @@ void main() { cacheControl: (final _, final __) => null, )); - const original = '/static/images/logo.png'; - final cfg = CacheBustingConfig( - mountPrefix: '/static', - fileSystemRoot: staticRoot, - ); - final busted = await cfg.assetPath(original); + const busted = '/static/images/logo@abc.png'; final response = await makeRequest( handler, @@ -111,108 +93,6 @@ void main() { expect(await response.readAsString(), 'nested-bytes'); }); - test( - 'when bust is called for a missing file then it throws PathNotFoundException', - () async { - final staticRoot = Directory(p.join(d.sandbox, 'static')); - final cfg = CacheBustingConfig( - mountPrefix: '/static', - fileSystemRoot: staticRoot, - ); - - expect( - cfg.assetPath('/static/does-not-exist.txt'), - throwsA(isA()), - ); - }); - - test('when path contains .. segments then bust rejects paths outside root', - () async { - final staticRoot = Directory(p.join(d.sandbox, 'static')); - final cfg = CacheBustingConfig( - mountPrefix: '/static', - fileSystemRoot: staticRoot, - ); - - expect( - cfg.assetPath('/static/../secret.txt'), - throwsA(isA()), - ); - }); - - test( - 'when absolute path segment appears after mount then bust rejects paths outside root', - () async { - final staticRoot = Directory(p.join(d.sandbox, 'static')); - final cfg = CacheBustingConfig( - mountPrefix: '/static', - fileSystemRoot: staticRoot, - ); - - expect( - cfg.assetPath('/static//etc/passwd'), - throwsA(isA()), - ); - }); - - test( - 'and a symlink escapes the root when calling bust then it rejects paths outside root', - () async { - // Create a file outside the static root - final outsidePath = p.join(d.sandbox, 'outside.txt'); - await File(outsidePath).writeAsString('outside'); - - // Create a symlink inside static pointing to the outside file - final linkPath = p.join(d.sandbox, 'static', 'escape.txt'); - final link = Link(linkPath); - try { - // Use absolute target to be explicit - await link.create(outsidePath); - } on FileSystemException { - // If the platform forbids symlinks, skip this test gracefully - return; - } - - final staticRoot = Directory(p.join(d.sandbox, 'static')); - final cfg = CacheBustingConfig( - mountPrefix: '/static', - fileSystemRoot: staticRoot, - ); - - expect( - cfg.assetPath('/static/escape.txt'), - throwsA(isA()), - ); - }); - - test( - 'when tryBust is called for a missing file then it returns the original path', - () async { - final staticRoot = Directory(p.join(d.sandbox, 'static')); - final cfg = CacheBustingConfig( - mountPrefix: '/static', - fileSystemRoot: staticRoot, - ); - - const original = '/static/does-not-exist.txt'; - final result = await cfg.tryBust(original); - expect(result, original); - }); - - test( - 'when the path is outside mountPrefix then bust/tryBust return it unchanged', - () async { - final staticRoot = Directory(p.join(d.sandbox, 'static')); - final cfg = CacheBustingConfig( - mountPrefix: '/static', - fileSystemRoot: staticRoot, - ); - - const outside = '/other/logo.png'; - expect(await cfg.tryBust(outside), outside); - expect(await cfg.assetPath(outside), outside); - }); - test( 'when a request is outside mountPrefix then middleware does not rewrite', () async { @@ -316,20 +196,7 @@ void main() { }); test( - 'when creating config with invalid mountPrefix then it throws ArgumentError', - () async { - final staticRoot = Directory(p.join(d.sandbox, 'static')); - expect( - () => CacheBustingConfig( - mountPrefix: 'static', - fileSystemRoot: staticRoot, - ), - throwsA(isA()), - ); - }); - - test( - 'and the file has no extension when calling bust then it serves via middleware', + 'and the file has no extension when requesting a busted URL then it serves via middleware', () async { // Add a no-extension file await d.file(p.join('static', 'logo'), 'content-noext').create(); @@ -346,10 +213,7 @@ void main() { cacheControl: (final _, final __) => null, )); - const original = '/static/logo'; - final busted = await cfg.assetPath(original); - expect(busted, startsWith('/static/logo@')); - expect(busted, isNot(endsWith('.'))); + const busted = '/static/logo@abc'; final response = await makeRequest(handler, busted, handlerPath: 'static'); @@ -373,11 +237,7 @@ void main() { cacheControl: (final _, final __) => null, )); - const original = '/static/logo.png'; - final busted = await cfg.assetPath(original); - expect(busted, isNot(original)); - expect(busted, startsWith('/static/logo--')); - expect(busted, endsWith('.png')); + const busted = '/static/logo--abc.png'; final response = await makeRequest(handler, busted, handlerPath: 'static'); @@ -411,7 +271,7 @@ void main() { }); test( - 'and a directory name contains @ when calling bust then only the filename is affected', + 'and a directory name contains @ when requesting a busted URL then only the filename is affected', () async { // Add nested directory with @ in its name await d.dir(p.join('static', 'img@foo'), [ @@ -430,19 +290,15 @@ void main() { cacheControl: (final _, final __) => null, )); - const original = '/static/img@foo/logo.png'; - final busted = await cfg.assetPath(original); - expect(busted, startsWith('/static/img@foo/logo@')); - expect(busted, endsWith('.png')); + const busted = '/static/img@foo/logo@abc.png'; final response = await makeRequest(handler, busted, handlerPath: 'static'); expect(response.statusCode, HttpStatus.ok); expect(await response.readAsString(), 'dir-at-bytes'); }); - test( - 'Given a filename starting with @ when calling bust then it busts and strips correctly', + 'Given a filename starting with @ when requesting a busted URL then it serves the file', () async { await d.file(p.join('static', '@logo.png'), 'at-logo').create(); @@ -458,10 +314,7 @@ void main() { cacheControl: (final _, final __) => null, )); - const original = '/static/@logo.png'; - final busted = await cfg.assetPath(original); - expect(busted, startsWith('/static/@logo@')); - expect(busted, endsWith('.png')); + const busted = '/static/@logo@abc.png'; final response = await makeRequest(handler, busted, handlerPath: 'static'); From 591cc000664096b9b385f5db4b875aa8486bf00c Mon Sep 17 00:00:00 2001 From: Alexander Sandor Date: Wed, 8 Oct 2025 09:00:08 +0200 Subject: [PATCH 22/37] refactor: Rename to match assetPath. --- lib/src/io/static/middleware_cache_busting.dart | 4 ++-- test/static/cache_busting_config_test.dart | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/src/io/static/middleware_cache_busting.dart b/lib/src/io/static/middleware_cache_busting.dart index 4c479a86..e5764174 100644 --- a/lib/src/io/static/middleware_cache_busting.dart +++ b/lib/src/io/static/middleware_cache_busting.dart @@ -8,7 +8,7 @@ import '../../adapter/context.dart'; /// /// Typical flow: /// - Outgoing URLs: call [CacheBustingConfig.assetPath] (or -/// [CacheBustingConfig.tryBust]) with a known mount prefix (e.g. "/static") +/// [CacheBustingConfig.tryAssetPath]) with a known mount prefix (e.g. "/static") /// to get "/static/name@hash.ext". /// - Incoming requests: add this [cacheBusting] middleware so downstream /// handlers (e.g., the static file handler) receive "/path/name.ext" without @@ -144,7 +144,7 @@ class CacheBustingConfig { /// Attempts to generate a cache-busted URL. If the file cannot be found or /// read, returns [staticPath] unchanged. - Future tryBust(final String staticPath) async { + Future tryAssetPath(final String staticPath) async { try { return await assetPath(staticPath); } catch (_) { diff --git a/test/static/cache_busting_config_test.dart b/test/static/cache_busting_config_test.dart index 425e1548..7f9e53ac 100644 --- a/test/static/cache_busting_config_test.dart +++ b/test/static/cache_busting_config_test.dart @@ -85,7 +85,7 @@ void main() { }); test( - 'when tryBust is called for a missing file then it returns the original path', + 'when tryAssetPath is called for a missing file then it returns the original path', () async { final staticRoot = Directory(p.join(d.sandbox, 'static')); final cfg = CacheBustingConfig( @@ -94,12 +94,12 @@ void main() { ); const original = '/static/does-not-exist.txt'; - final result = await cfg.tryBust(original); + final result = await cfg.tryAssetPath(original); expect(result, original); }); test( - 'when the path is outside mountPrefix then tryBust returns it unchanged', + 'when the path is outside mountPrefix then tryAssetPath returns it unchanged', () async { final staticRoot = Directory(p.join(d.sandbox, 'static')); final cfg = CacheBustingConfig( @@ -108,7 +108,7 @@ void main() { ); const outside = '/other/logo.png'; - expect(await cfg.tryBust(outside), outside); + expect(await cfg.tryAssetPath(outside), outside); }); test( From 7176d53cfd3c918f589dd9045742e88225c91ff1 Mon Sep 17 00:00:00 2001 From: Alexander Sandor Date: Wed, 8 Oct 2025 11:59:36 +0200 Subject: [PATCH 23/37] fix: Refactor tests and add additional validation on separator and fileSystemRoot. --- .../io/static/middleware_cache_busting.dart | 30 +- test/static/cache_busting_config_test.dart | 324 +++++++++++------- 2 files changed, 237 insertions(+), 117 deletions(-) diff --git a/lib/src/io/static/middleware_cache_busting.dart b/lib/src/io/static/middleware_cache_busting.dart index e5764174..7bef6d56 100644 --- a/lib/src/io/static/middleware_cache_busting.dart +++ b/lib/src/io/static/middleware_cache_busting.dart @@ -84,9 +84,10 @@ class CacheBustingConfig { CacheBustingConfig({ required final String mountPrefix, required final Directory fileSystemRoot, - this.separator = '@', + final String separator = '@', }) : mountPrefix = _normalizeMount(mountPrefix), - fileSystemRoot = fileSystemRoot.absolute; + fileSystemRoot = _validateFileSystemRoot(fileSystemRoot.absolute), + separator = _validateSeparator(separator); /// Returns the cache-busted URL for the given [staticPath]. /// @@ -160,3 +161,28 @@ String _normalizeMount(final String mountPrefix) { } return mountPrefix.endsWith('/') ? mountPrefix : '$mountPrefix/'; } + +Directory _validateFileSystemRoot(final Directory dir) { + if (!dir.existsSync()) { + throw ArgumentError.value(dir.path, 'fileSystemRoot', 'does not exist'); + } + + final resolved = dir.absolute.resolveSymbolicLinksSync(); + final entityType = FileSystemEntity.typeSync(resolved); + if (entityType != FileSystemEntityType.directory) { + throw ArgumentError.value( + dir.path, + 'fileSystemRoot', + 'is not a directory', + ); + } + + return dir; +} + +String _validateSeparator(final String separator) { + if (separator.isEmpty) { + throw ArgumentError('separator cannot be empty'); + } + return separator; +} diff --git a/test/static/cache_busting_config_test.dart b/test/static/cache_busting_config_test.dart index 7f9e53ac..cb7884d7 100644 --- a/test/static/cache_busting_config_test.dart +++ b/test/static/cache_busting_config_test.dart @@ -6,199 +6,293 @@ import 'package:test/test.dart'; import 'package:test_descriptor/test_descriptor.dart' as d; void main() { - group('Given CacheBustingConfig and a static directory', () { - setUp(() async { - await d.dir('static', [ - d.file('logo.png', 'png-bytes'), - d.dir('images', [d.file('logo.png', 'nested-bytes')]), - ]).create(); - }); + test( + 'Given mountPrefix not starting with "/" when creating CacheBustingConfig then it throws ArgumentError', + () async { + final staticRoot = Directory(p.join(d.sandbox, 'static')); + expect( + () => CacheBustingConfig( + mountPrefix: 'static', + fileSystemRoot: staticRoot, + ), + throwsA(isA()), + ); + }); - test( - 'when bust is called for a missing file then it throws PathNotFoundException', - () async { - final staticRoot = Directory(p.join(d.sandbox, 'static')); - final cfg = CacheBustingConfig( + test( + 'Given empty separator when creating CacheBustingConfig then it throws ArgumentError', + () async { + final staticRoot = Directory(p.join(d.sandbox, 'static')); + expect( + () => CacheBustingConfig( mountPrefix: '/static', fileSystemRoot: staticRoot, - ); - - expect( - cfg.assetPath('/static/does-not-exist.txt'), - throwsA(isA()), - ); - }); + separator: '', + ), + throwsA(isA()), + ); + }); - test('when path contains .. segments then bust rejects paths outside root', - () async { - final staticRoot = Directory(p.join(d.sandbox, 'static')); - final cfg = CacheBustingConfig( + test( + 'Given no directory at staticRoot when creating CacheBustingConfig then it throws ArgumentError', + () { + final staticRoot = Directory(p.join(d.sandbox, 'static')); + expect( + () => CacheBustingConfig( mountPrefix: '/static', fileSystemRoot: staticRoot, - ); + separator: '', + ), + throwsA(isA()), + ); + }); - expect( - cfg.assetPath('/static/../secret.txt'), - throwsA(isA()), - ); - }); + test( + 'Given file at staticRoot when creating CacheBustingConfig then it throws ArgumentError', + () async { + await d.file('static', 'content').create(); + final staticRoot = Directory(p.join(d.sandbox, 'static')); + expect( + () => CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + separator: '', + ), + throwsA(isA()), + ); + }); - test( - 'when absolute path segment appears after mount then bust rejects paths outside root', - () async { + group('Given CacheBustingConfig configured for a directory without files', + () { + late CacheBustingConfig cfg; + setUp(() async { + await d.dir('static', []).create(); final staticRoot = Directory(p.join(d.sandbox, 'static')); - final cfg = CacheBustingConfig( + cfg = CacheBustingConfig( mountPrefix: '/static', fileSystemRoot: staticRoot, ); - - expect( - cfg.assetPath('/static//etc/passwd'), - throwsA(isA()), - ); }); test( - 'and a symlink escapes the root when calling bust then it rejects paths outside root', + 'when assetPath is called for a missing file then it throws PathNotFoundException', () async { - final outsidePath = p.join(d.sandbox, 'outside.txt'); - await File(outsidePath).writeAsString('outside'); - - final linkPath = p.join(d.sandbox, 'static', 'escape.txt'); - final link = Link(linkPath); - try { - await link.create(outsidePath); - } on FileSystemException { - return; // platform forbids symlinks - } - - final staticRoot = Directory(p.join(d.sandbox, 'static')); - final cfg = CacheBustingConfig( - mountPrefix: '/static', - fileSystemRoot: staticRoot, - ); - expect( - cfg.assetPath('/static/escape.txt'), - throwsA(isA()), + cfg.assetPath('/static/does-not-exist.txt'), + throwsA(isA()), ); }); test( 'when tryAssetPath is called for a missing file then it returns the original path', () async { - final staticRoot = Directory(p.join(d.sandbox, 'static')); - final cfg = CacheBustingConfig( - mountPrefix: '/static', - fileSystemRoot: staticRoot, - ); - const original = '/static/does-not-exist.txt'; final result = await cfg.tryAssetPath(original); expect(result, original); }); - - test( - 'when the path is outside mountPrefix then tryAssetPath returns it unchanged', - () async { + }); + group( + 'Given CacheBustingConfig without explicit separator configured for a directory with files', + () { + late CacheBustingConfig cfg; + setUp(() async { + await d.dir('static', [d.file('logo.png', 'png-bytes')]).create(); final staticRoot = Directory(p.join(d.sandbox, 'static')); - final cfg = CacheBustingConfig( + cfg = CacheBustingConfig( mountPrefix: '/static', fileSystemRoot: staticRoot, ); - - const outside = '/other/logo.png'; - expect(await cfg.tryAssetPath(outside), outside); }); test( - 'when the path is outside mountPrefix then assetPath returns it unchanged', + 'when assetPath is called for existing file then default cache busting separator is "@"', () async { + const original = '/static/logo.png'; + final busted = await cfg.assetPath(original); + expect(busted, contains('@')); + }); + }); + + group('Given CacheBustingConfig configured for a directory with files', () { + late CacheBustingConfig cfg; + setUp(() async { + await d.dir('static', [d.file('logo.png', 'png-bytes')]).create(); final staticRoot = Directory(p.join(d.sandbox, 'static')); - final cfg = CacheBustingConfig( + cfg = CacheBustingConfig( mountPrefix: '/static', fileSystemRoot: staticRoot, + separator: '@', ); + }); - const outside = '/other/logo.png'; - expect(await cfg.assetPath(outside), outside); + test( + 'when assetPath is called for an existing file then it returns a cache busted path', + () async { + const original = '/static/logo.png'; + final busted = await cfg.assetPath(original); + expect(busted, startsWith('/static/logo@')); }); test( - 'when creating config with invalid mountPrefix then it throws ArgumentError', + 'when tryAssetPath is called for an existing file then it returns a cache busted path', + () async { + const original = '/static/logo.png'; + final busted = await cfg.tryAssetPath(original); + expect(busted, startsWith('/static/logo@')); + }); + + test( + 'when assetPath is called with an absolute path segment after mount then argument error is thrown', () async { - final staticRoot = Directory(p.join(d.sandbox, 'static')); expect( - () => CacheBustingConfig( - mountPrefix: 'static', - fileSystemRoot: staticRoot, - ), + cfg.assetPath('/static//logo.png'), throwsA(isA()), ); }); - test('when busting a file without extension then it returns a busted path', + test( + 'when tryAssetPath is called with an absolute path segment after mount then it returns the original path', () async { - await d.file(p.join('static', 'logo'), 'content-noext').create(); - final staticRoot = Directory(p.join(d.sandbox, 'static')); final cfg = CacheBustingConfig( mountPrefix: '/static', fileSystemRoot: staticRoot, ); - const original = '/static/logo'; - final busted = await cfg.assetPath(original); - expect(busted, startsWith('/static/logo@')); + const original = '/static//logo.png'; + expect(await cfg.tryAssetPath(original), original); }); + }); - test( - 'and a custom separator is configured when busting then it uses that separator', - () async { + group('Given files outside of CacheBustingConfig fileSystemRoot', () { + late CacheBustingConfig cfg; + setUp(() async { + await d.dir('static', [d.file('logo.png', 'png-bytes')]).create(); + await d.file('secret.txt', 'top-secret').create(); final staticRoot = Directory(p.join(d.sandbox, 'static')); - final cfg = CacheBustingConfig( + cfg = CacheBustingConfig( mountPrefix: '/static', fileSystemRoot: staticRoot, - separator: '--', ); - - const original = '/static/logo.png'; - final busted = await cfg.assetPath(original); - expect(busted, startsWith('/static/logo--')); }); test( - 'and a directory name contains @ when calling bust then only the filename is affected', + 'when assetPath is called for a path that traverses outside of the mount prefix then it throws ArgumentError', () async { - await d.dir(p.join('static', 'img@foo'), [ - d.file('logo.png', 'dir-at-bytes'), - ]).create(); - - final staticRoot = Directory(p.join(d.sandbox, 'static')); - final cfg = CacheBustingConfig( - mountPrefix: '/static', - fileSystemRoot: staticRoot, + expect( + cfg.assetPath('/static/../secret.txt'), + throwsA(isA()), ); + }); - const original = '/static/img@foo/logo.png'; - final busted = await cfg.assetPath(original); - expect(busted, startsWith('/static/img@foo/logo@')); + test( + 'when assetPath is called for a path outside of the mount prefix then it returns it unchanged', + () async { + const outside = '/secret.txt'; + expect(await cfg.assetPath(outside), outside); }); test( - 'Given a filename starting with @ when calling bust then it busts correctly', + 'when tryAssetPath is called for a path outside of mount prefix then returns it unchanged', () async { - await d.file(p.join('static', '@logo.png'), 'at-logo').create(); + const outside = '/secret.txt'; + expect(await cfg.tryAssetPath(outside), outside); + }); + }); + + test( + 'Given file without extension in CacheBustingConfig directory when calling assetPath then it returns a cache busting path', + () async { + await d.dir('static', [d.file('logo', 'content-noext')]).create(); + final staticRoot = Directory(p.join(d.sandbox, 'static')); + final cfg = CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + separator: '@', + ); + + const original = '/static/logo'; + final busted = await cfg.assetPath(original); + expect(busted, startsWith('/static/logo@')); + }); + + test( + 'Given a CacheBustingConfig with custom separator when calling assetPath then it uses that separator', + () async { + await d.dir('static', [d.file('logo.png', 'png-bytes')]).create(); + final staticRoot = Directory(p.join(d.sandbox, 'static')); + final cfg = CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + separator: '--', + ); + const original = '/static/logo.png'; + final busted = await cfg.assetPath(original); + expect(busted, startsWith('/static/logo--')); + }); + + test( + 'Given a CacheBustingConfig serving a directory where the directory name contains the separator when calling assetPath then only the filename is affected', + () async { + await d.dir(p.join('static', 'img@foo'), [ + d.file('logo.png', 'dir-at-bytes'), + ]).create(); + + final staticRoot = Directory(p.join(d.sandbox, 'static')); + final cfg = CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + separator: '@', + ); + + const original = '/static/img@foo/logo.png'; + final busted = await cfg.assetPath(original); + expect(busted, startsWith('/static/img@foo/logo@')); + }); + + test( + 'Given a CacheBustingConfig serving a file starting wht the separator when calling assetPath then cache busting path is created correctly', + () async { + await d.dir('static', [d.file('@logo.png', 'at-logo')]).create(); + final staticRoot = Directory(p.join(d.sandbox, 'static')); + final cfg = CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + separator: '@', + ); + + const original = '/static/@logo.png'; + final busted = await cfg.assetPath(original); + expect(busted, startsWith('/static/@logo@')); + }); + + group('Given a CacheBustingConfig serving a symlink that escapes the root', + () { + late CacheBustingConfig cfg; + setUp(() async { + await d.file('outside.txt', 'outside').create(); + await d.dir('static').create(); + final outsidePath = p.join(d.sandbox, 'outside.txt'); + final linkPath = p.join(d.sandbox, 'static', 'escape.txt'); + Link(linkPath).createSync(outsidePath); final staticRoot = Directory(p.join(d.sandbox, 'static')); - final cfg = CacheBustingConfig( + cfg = CacheBustingConfig( mountPrefix: '/static', fileSystemRoot: staticRoot, ); + }); + test('when calling assetPath then it throws ArgumentError', () async { + expect( + cfg.assetPath('/static/escape.txt'), + throwsA(isA()), + ); + }); - const original = '/static/@logo.png'; - final busted = await cfg.assetPath(original); - expect(busted, startsWith('/static/@logo@')); + test('when calling tryAssetPath then asset path is returned unchanged', + () async { + expect( + await cfg.tryAssetPath('/static/escape.txt'), '/static/escape.txt'); }); }); } From a7fd708b5c465229da90f07b2e3f69a9481a129d Mon Sep 17 00:00:00 2001 From: Alexander Sandor Date: Wed, 8 Oct 2025 21:19:52 +0200 Subject: [PATCH 24/37] test: Add test to validate mount prefix. --- test/static/cache_busting_config_test.dart | 33 ++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/test/static/cache_busting_config_test.dart b/test/static/cache_busting_config_test.dart index cb7884d7..d182f342 100644 --- a/test/static/cache_busting_config_test.dart +++ b/test/static/cache_busting_config_test.dart @@ -295,4 +295,37 @@ void main() { await cfg.tryAssetPath('/static/escape.txt'), '/static/escape.txt'); }); }); + + group( + 'Given a CacheBustingConfig with a mountPrefix that does not match fileSystemRoot', + () { + late CacheBustingConfig cfg; + setUp(() async { + await d.dir('static', [ + d.file('logo.png', 'png-bytes'), + ]).create(); + final staticRoot = Directory(p.join(d.sandbox, 'static')); + cfg = CacheBustingConfig( + mountPrefix: '/web', + fileSystemRoot: staticRoot, + separator: '@', + ); + }); + + test( + 'when assetPath is called for an existing file then it returns a cache busted path', + () async { + const original = '/web/logo.png'; + final busted = await cfg.assetPath(original); + expect(busted, startsWith('/web/logo@')); + }); + + test( + 'when assetPath is called for file using fileSystemRoot instead of mountPrefix as base then it returns path unchanged', + () async { + const original = '/static/logo.png'; + final busted = await cfg.assetPath(original); + expect(busted, equals('/static/logo.png')); + }); + }); } From 5c209f81b2fbb64fde25d7b38556a81a2bdf4d9c Mon Sep 17 00:00:00 2001 From: Alexander Sandor Date: Wed, 8 Oct 2025 22:43:32 +0200 Subject: [PATCH 25/37] test: Refactor tests to follow given when then pattern. --- .../static/cache_busting_middleware_test.dart | 328 ++++++++---------- 1 file changed, 154 insertions(+), 174 deletions(-) diff --git a/test/static/cache_busting_middleware_test.dart b/test/static/cache_busting_middleware_test.dart index 21a96708..f34b8656 100644 --- a/test/static/cache_busting_middleware_test.dart +++ b/test/static/cache_busting_middleware_test.dart @@ -8,19 +8,14 @@ import 'package:test_descriptor/test_descriptor.dart' as d; import 'test_util.dart'; void main() { - group('Given cache busting middleware and a static directory', () { + group( + 'Given a static asset served through a root-mounted cache busting middleware', + () { + late Handler handler; setUp(() async { - await d.dir('static', [ - d.file('logo.png', 'png-bytes'), - d.dir('images', [d.file('logo.png', 'nested-bytes')]), - ]).create(); - }); - - test( - 'when the static handler is mounted at root then requesting a busted URL serves the file', - () async { + await d.dir('static', [d.file('logo.png', 'png-bytes')]).create(); final staticRoot = Directory(p.join(d.sandbox, 'static')); - final handler = const Pipeline() + handler = const Pipeline() .addMiddleware(cacheBusting(CacheBustingConfig( mountPrefix: '/static', fileSystemRoot: staticRoot, @@ -29,297 +24,282 @@ void main() { staticRoot.path, cacheControl: (final _, final __) => null, )); + }); - const busted = '/static/logo@abc.png'; - - final response = await makeRequest( - handler, - busted, - handlerPath: 'static', - ); + test('when requesting asset with a non-busted URL then it serves the asset', + () async { + final response = + await makeRequest(handler, '/static/logo.png', handlerPath: 'static'); expect(response.statusCode, HttpStatus.ok); expect(await response.readAsString(), 'png-bytes'); }); test( - 'and the static handler is router-mounted when requesting a busted URL then it serves the file', + 'when requesting asset with a cache busted URL then it serves the asset', () async { + final response = await makeRequest(handler, '/static/logo@abc.png', + handlerPath: 'static'); + expect(response.statusCode, HttpStatus.ok); + expect(await response.readAsString(), 'png-bytes'); + }); + }); + + group( + 'Given a static asset served through a router-mounted static handler and pipeline mounted cache busting middleware', + () { + late Handler handler; + setUp(() async { + await d.dir('static', [d.file('logo.png', 'png-bytes')]).create(); final staticRoot = Directory(p.join(d.sandbox, 'static')); final router = Router() ..get( - '/assets/**', + '/static/**', createStaticHandler( staticRoot.path, cacheControl: (final _, final __) => null, )); - final handler = const Pipeline() + handler = const Pipeline() .addMiddleware(cacheBusting(CacheBustingConfig( - mountPrefix: '/assets', + mountPrefix: '/static', fileSystemRoot: staticRoot, ))) .addMiddleware(routeWith(router)) .addHandler(respondWith((final _) => Response.notFound())); - - const busted = '/assets/images/logo@abc.png'; - - final response = await makeRequest(handler, busted); - expect(response.statusCode, HttpStatus.ok); - expect(await response.readAsString(), 'nested-bytes'); }); - test( - 'and handlerPath is used under root when requesting a busted URL then it serves the file', + test('when requesting asset with a non-busted URL then it serves the asset', () async { - final staticRoot = Directory(p.join(d.sandbox, 'static')); - final handler = const Pipeline() - .addMiddleware(cacheBusting(CacheBustingConfig( - mountPrefix: '/static', - fileSystemRoot: staticRoot, - ))) - .addHandler(createStaticHandler( - staticRoot.path, - cacheControl: (final _, final __) => null, - )); - - const busted = '/static/images/logo@abc.png'; - - final response = await makeRequest( - handler, - busted, - handlerPath: 'static', - ); + final response = await makeRequest(handler, '/static/logo.png'); expect(response.statusCode, HttpStatus.ok); - expect(await response.readAsString(), 'nested-bytes'); + expect(await response.readAsString(), 'png-bytes'); }); test( - 'when a request is outside mountPrefix then middleware does not rewrite', + 'when requesting asset with a cache busted URL then it serves the asset', () async { - final staticRoot = Directory(p.join(d.sandbox, 'static')); - final handler = const Pipeline() - .addMiddleware(cacheBusting(CacheBustingConfig( - mountPrefix: '/static', - fileSystemRoot: staticRoot, - ))) - .addHandler(createStaticHandler( - staticRoot.path, - cacheControl: (final _, final __) => null, - )); - - final response = await makeRequest(handler, '/other/logo.png'); - expect(response.statusCode, HttpStatus.notFound); + final response = await makeRequest(handler, '/static/logo@abc.png'); + expect(response.statusCode, HttpStatus.ok); + expect(await response.readAsString(), 'png-bytes'); }); + }); - test( - 'and the request is the mount root (trailing slash) then it is not rewritten', - () async { - final staticRoot = Directory(p.join(d.sandbox, 'static')); - final handler = const Pipeline() - .addMiddleware(cacheBusting(CacheBustingConfig( - mountPrefix: '/static', - fileSystemRoot: staticRoot, - ))) - .addHandler(createStaticHandler( - staticRoot.path, - cacheControl: (final _, final __) => null, - )); + test( + 'Given a cache busting middleware when the request is the mount root then middleware early-returns unchanged', + () async { + await d.dir('static').create(); + final staticRoot = Directory(p.join(d.sandbox, 'static')); + final cfg = CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + ); + + // Echo handler to observe the requestedUri.path after middleware. + final handler = const Pipeline() + .addMiddleware(cacheBusting(cfg)) + .addHandler(respondWith((final ctx) => + Response.ok(body: Body.fromString(ctx.requestedUri.path)))); + + final response = await makeRequest(handler, '/static/@abs'); + expect(response.statusCode, HttpStatus.ok); + expect(await response.readAsString(), '/static/@abs'); + }); - // Requesting the mount prefix itself should not be rewritten and - // directory listings are not supported -> 404. - final response = - await makeRequest(handler, '/static/', handlerPath: 'static'); - expect(response.statusCode, HttpStatus.notFound); - }); + group('Given static asset served outside of cache busting mountPrefix', () { + late Handler handler; - test( - 'and the request is the mount root then middleware early-returns unchanged', - () async { - final staticRoot = Directory(p.join(d.sandbox, 'static')); + setUp(() async { + await d.dir('other', [d.file('logo.png', 'png-bytes')]).create(); + final staticRoot = Directory(p.join(d.sandbox, 'other')); final cfg = CacheBustingConfig( mountPrefix: '/static', fileSystemRoot: staticRoot, ); - // Echo handler to observe the requestedUri.path after middleware. - final handler = const Pipeline() - .addMiddleware(cacheBusting(cfg)) - .addHandler(respondWith((final ctx) => - Response.ok(body: Body.fromString(ctx.requestedUri.path)))); - - final response = await makeRequest(handler, '/static/'); - expect(response.statusCode, HttpStatus.ok); - expect(await response.readAsString(), '/static/'); - }); - - test('and the filename is not busted then middleware serves the file', - () async { - final staticRoot = Directory(p.join(d.sandbox, 'static')); - final cfg = CacheBustingConfig( - mountPrefix: '/static', - fileSystemRoot: staticRoot, - ); - final handler = const Pipeline() + handler = const Pipeline() .addMiddleware(cacheBusting(cfg)) .addHandler(createStaticHandler( staticRoot.path, cacheControl: (final _, final __) => null, )); + }); + test('when requesting asset with a non-busted URL then it serves the asset', + () async { final response = - await makeRequest(handler, '/static/logo.png', handlerPath: 'static'); + await makeRequest(handler, '/other/logo.png', handlerPath: 'other'); expect(response.statusCode, HttpStatus.ok); expect(await response.readAsString(), 'png-bytes'); }); test( - 'and the filename starts with @ and has no hash then it is not stripped', + 'when requesting asset with a busted URL then URL is not rewritten resulting in asset not being found', () async { - await d.file(p.join('static', '@plain.txt'), 'plain-at').create(); + final response = await makeRequest(handler, '/other/logo@abc.png', + handlerPath: 'other'); + expect(response.statusCode, HttpStatus.notFound); + }); + }); + group( + 'Given a static asset with a filename starting with separator served through cache busting middleware', + () { + late Handler handler; + setUp(() async { + await d.dir('static', [d.file('@logo.png', 'png-bytes')]).create(); final staticRoot = Directory(p.join(d.sandbox, 'static')); final cfg = CacheBustingConfig( mountPrefix: '/static', fileSystemRoot: staticRoot, + separator: '@', ); - final handler = const Pipeline() + handler = const Pipeline() .addMiddleware(cacheBusting(cfg)) .addHandler(createStaticHandler( staticRoot.path, cacheControl: (final _, final __) => null, )); + }); - final response = await makeRequest(handler, '/static/@plain.txt', + test('when requesting asset with a non-busted URL then it serves the asset', + () async { + final response = await makeRequest(handler, '/static/@logo.png', handlerPath: 'static'); expect(response.statusCode, HttpStatus.ok); - expect(await response.readAsString(), 'plain-at'); + expect(await response.readAsString(), 'png-bytes'); }); test( - 'and the file has no extension when requesting a busted URL then it serves via middleware', + 'when requesting asset with a cache busted URL then it serves the asset', () async { - // Add a no-extension file - await d.file(p.join('static', 'logo'), 'content-noext').create(); + final response = await makeRequest(handler, '/static/@logo@abc.png', + handlerPath: 'static'); + expect(response.statusCode, HttpStatus.ok); + expect(await response.readAsString(), 'png-bytes'); + }); + }); + group( + 'Given a static asset without extension served through cache busting middleware', + () { + late Handler handler; + setUp(() async { + await d.dir('static', [d.file('logo', 'file-contents')]).create(); final staticRoot = Directory(p.join(d.sandbox, 'static')); final cfg = CacheBustingConfig( mountPrefix: '/static', fileSystemRoot: staticRoot, + separator: '@', ); - final handler = const Pipeline() + handler = const Pipeline() .addMiddleware(cacheBusting(cfg)) .addHandler(createStaticHandler( staticRoot.path, cacheControl: (final _, final __) => null, )); + }); - const busted = '/static/logo@abc'; - + test('when requesting asset with a non-busted URL then it serves the asset', + () async { final response = - await makeRequest(handler, busted, handlerPath: 'static'); + await makeRequest(handler, '/static/logo', handlerPath: 'static'); expect(response.statusCode, HttpStatus.ok); - expect(await response.readAsString(), 'content-noext'); + expect(await response.readAsString(), 'file-contents'); }); test( - 'and a custom separator is configured when requesting a busted URL then it serves the file', + 'when requesting asset with a cache busted URL then it serves the asset', () async { + final response = + await makeRequest(handler, '/static/logo@abc', handlerPath: 'static'); + expect(response.statusCode, HttpStatus.ok); + expect(await response.readAsString(), 'file-contents'); + }); + }); + + group( + 'Given a static asset served through cache busting middleware with a custom separator', + () { + late Handler handler; + setUp(() async { + await d.dir('static', [d.file('logo.png', 'png-bytes')]).create(); final staticRoot = Directory(p.join(d.sandbox, 'static')); final cfg = CacheBustingConfig( mountPrefix: '/static', fileSystemRoot: staticRoot, separator: '--', ); - final handler = const Pipeline() + handler = const Pipeline() .addMiddleware(cacheBusting(cfg)) .addHandler(createStaticHandler( staticRoot.path, cacheControl: (final _, final __) => null, )); + }); - const busted = '/static/logo--abc.png'; - + test('when requesting asset with a non-busted URL then it serves the asset', + () async { final response = - await makeRequest(handler, busted, handlerPath: 'static'); + await makeRequest(handler, '/static/logo.png', handlerPath: 'static'); expect(response.statusCode, HttpStatus.ok); expect(await response.readAsString(), 'png-bytes'); }); test( - 'and the filename starts with the separator and has no hash then it is not stripped', + 'when requesting asset with a cache busted URL using the custom separator then it serves the asset', () async { - // Create a file that starts with the separator characters - await d.file(p.join('static', '--plain.txt'), 'dashdash').create(); - - final staticRoot = Directory(p.join(d.sandbox, 'static')); - final cfg = CacheBustingConfig( - mountPrefix: '/static', - fileSystemRoot: staticRoot, - separator: '--', - ); - final handler = const Pipeline() - .addMiddleware(cacheBusting(cfg)) - .addHandler(createStaticHandler( - staticRoot.path, - cacheControl: (final _, final __) => null, - )); - - final response = await makeRequest(handler, '/static/--plain.txt', + final response = await makeRequest(handler, '/static/logo--abc.png', handlerPath: 'static'); expect(response.statusCode, HttpStatus.ok); - expect(await response.readAsString(), 'dashdash'); + expect(await response.readAsString(), 'png-bytes'); }); test( - 'and a directory name contains @ when requesting a busted URL then only the filename is affected', + 'when requesting asset with a cache busted URL using the default separator then it does not find the asset', () async { - // Add nested directory with @ in its name - await d.dir(p.join('static', 'img@foo'), [ - d.file('logo.png', 'dir-at-bytes'), - ]).create(); + final response = await makeRequest(handler, '/static/logo@abc.png', + handlerPath: 'static'); + expect(response.statusCode, HttpStatus.notFound); + }); + }); + group('Given a static asset in a nested directory containing the separator', + () { + late Handler handler; + setUp(() async { + await d.dir('static', [ + d.dir('@images', [d.file('logo.png', 'nested-bytes')]) + ]).create(); final staticRoot = Directory(p.join(d.sandbox, 'static')); final cfg = CacheBustingConfig( mountPrefix: '/static', fileSystemRoot: staticRoot, + separator: '@', ); - final handler = const Pipeline() + handler = const Pipeline() .addMiddleware(cacheBusting(cfg)) .addHandler(createStaticHandler( staticRoot.path, cacheControl: (final _, final __) => null, )); + }); - const busted = '/static/img@foo/logo@abc.png'; - - final response = - await makeRequest(handler, busted, handlerPath: 'static'); + test('when requesting asset with a non-busted URL then it serves the asset', + () async { + final response = await makeRequest(handler, '/static/@images/logo.png', + handlerPath: 'static'); expect(response.statusCode, HttpStatus.ok); - expect(await response.readAsString(), 'dir-at-bytes'); + expect(await response.readAsString(), 'nested-bytes'); }); + test( - 'Given a filename starting with @ when requesting a busted URL then it serves the file', + 'when requesting asset with a cache busted URL then it serves the asset', () async { - await d.file(p.join('static', '@logo.png'), 'at-logo').create(); - - final staticRoot = Directory(p.join(d.sandbox, 'static')); - final cfg = CacheBustingConfig( - mountPrefix: '/static', - fileSystemRoot: staticRoot, - ); - final handler = const Pipeline() - .addMiddleware(cacheBusting(cfg)) - .addHandler(createStaticHandler( - staticRoot.path, - cacheControl: (final _, final __) => null, - )); - - const busted = '/static/@logo@abc.png'; - - final response = - await makeRequest(handler, busted, handlerPath: 'static'); + final response = await makeRequest( + handler, '/static/@images/logo@abc.png', + handlerPath: 'static'); expect(response.statusCode, HttpStatus.ok); - expect(await response.readAsString(), 'at-logo'); + expect(await response.readAsString(), 'nested-bytes'); }); }); } From 96da68c8a2cf2c9bfb6604a96b748c7a30e96f4f Mon Sep 17 00:00:00 2001 From: Alexander Sandor Date: Wed, 8 Oct 2025 22:43:48 +0200 Subject: [PATCH 26/37] test: Add missing (failing) test scenario. --- .../static/cache_busting_middleware_test.dart | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/test/static/cache_busting_middleware_test.dart b/test/static/cache_busting_middleware_test.dart index f34b8656..a680a28f 100644 --- a/test/static/cache_busting_middleware_test.dart +++ b/test/static/cache_busting_middleware_test.dart @@ -302,4 +302,46 @@ void main() { expect(await response.readAsString(), 'nested-bytes'); }); }); + + group( + 'Given a static asset served through a router-mounted static handler and cache busting middleware', + () { + late Handler handler; + setUp(() async { + await d.dir('static', [d.file('logo.png', 'png-bytes')]).create(); + final staticRoot = Directory(p.join(d.sandbox, 'static')); + final router = Router() + ..get( + '/static/**', + createStaticHandler( + staticRoot.path, + cacheControl: (final _, final __) => null, + )) + ..use( + '/static/**', + cacheBusting(CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + ))); + + handler = const Pipeline() + .addMiddleware(routeWith(router)) + .addHandler(respondWith((final _) => Response.notFound())); + }); + + test('when requesting asset with a non-busted URL then it serves the asset', + () async { + final response = await makeRequest(handler, '/static/logo.png'); + expect(response.statusCode, HttpStatus.ok); + expect(await response.readAsString(), 'png-bytes'); + }); + + test( + 'when requesting asset with a cache busted URL then it serves the asset', + () async { + final response = await makeRequest(handler, '/static/logo@abc.png'); + expect(response.statusCode, HttpStatus.ok); + expect(await response.readAsString(), 'png-bytes'); + }); + }); } From 455fe4535921bcb115f97fcda7953648b888be8e Mon Sep 17 00:00:00 2001 From: Alexander Sandor Date: Wed, 8 Oct 2025 22:49:40 +0200 Subject: [PATCH 27/37] test: Use explicit separator for all CacheBustingConfigs in test. --- test/static/cache_busting_middleware_test.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/static/cache_busting_middleware_test.dart b/test/static/cache_busting_middleware_test.dart index a680a28f..b7a09ec7 100644 --- a/test/static/cache_busting_middleware_test.dart +++ b/test/static/cache_busting_middleware_test.dart @@ -19,6 +19,7 @@ void main() { .addMiddleware(cacheBusting(CacheBustingConfig( mountPrefix: '/static', fileSystemRoot: staticRoot, + separator: '@', ))) .addHandler(createStaticHandler( staticRoot.path, @@ -63,6 +64,7 @@ void main() { .addMiddleware(cacheBusting(CacheBustingConfig( mountPrefix: '/static', fileSystemRoot: staticRoot, + separator: '@', ))) .addMiddleware(routeWith(router)) .addHandler(respondWith((final _) => Response.notFound())); @@ -92,6 +94,7 @@ void main() { final cfg = CacheBustingConfig( mountPrefix: '/static', fileSystemRoot: staticRoot, + separator: '@', ); // Echo handler to observe the requestedUri.path after middleware. @@ -114,6 +117,7 @@ void main() { final cfg = CacheBustingConfig( mountPrefix: '/static', fileSystemRoot: staticRoot, + separator: '@', ); handler = const Pipeline() @@ -322,6 +326,7 @@ void main() { cacheBusting(CacheBustingConfig( mountPrefix: '/static', fileSystemRoot: staticRoot, + separator: '@', ))); handler = const Pipeline() From be2de2f2af99b4d2744213fc0be3f80ccd83feca Mon Sep 17 00:00:00 2001 From: Alexander Sandor Date: Fri, 10 Oct 2025 10:34:46 +0200 Subject: [PATCH 28/37] fix: Expose tryStripHashFromFilename from CacheBustingConfig. --- .../io/static/middleware_cache_busting.dart | 40 +++++---- test/static/cache_busting_config_test.dart | 84 +++++++++++++++---- 2 files changed, 92 insertions(+), 32 deletions(-) diff --git a/lib/src/io/static/middleware_cache_busting.dart b/lib/src/io/static/middleware_cache_busting.dart index 7bef6d56..86aefb62 100644 --- a/lib/src/io/static/middleware_cache_busting.dart +++ b/lib/src/io/static/middleware_cache_busting.dart @@ -34,7 +34,7 @@ Middleware cacheBusting(final CacheBustingConfig config) { } final last = p.url.basename(relative); - final strippedLast = _stripHashFromFilename(last, config.separator); + final strippedLast = config.tryStripHashFromFilename(last); if (strippedLast == last) { return await inner(ctx); } @@ -54,22 +54,6 @@ Middleware cacheBusting(final CacheBustingConfig config) { }; } -/// Removes a trailing "``hash" segment from a file name, preserving any -/// extension. Matches both "`namehash`.ext" and "`namehash`". -String _stripHashFromFilename( - final String fileName, - final String separator, -) { - final ext = p.url.extension(fileName); - final base = p.url.basenameWithoutExtension(fileName); - - final at = base.lastIndexOf(separator); - if (at <= 0) return fileName; // no hash or starts with separator - - final cleanBase = base.substring(0, at); - return p.url.setExtension(cleanBase, ext); -} - /// Configuration and helpers for generating cache-busted asset URLs. class CacheBustingConfig { /// The URL prefix under which static assets are served (e.g., "/static"). @@ -152,6 +136,28 @@ class CacheBustingConfig { return staticPath; } } + + /// Removes a trailing "``hash" segment from a [fileName], preserving any + /// extension. Matches both "`namehash`.ext" and "`namehash`". + /// + /// If no hash is found, returns [fileName] unchanged. + /// + /// Examples: + /// `logo@abc.png` -> `logo.png` + /// `logo@abc` -> `logo` + /// `logo.png` -> `logo.png` (no change) + String tryStripHashFromFilename( + final String fileName, + ) { + final ext = p.url.extension(fileName); + final base = p.url.basenameWithoutExtension(fileName); + + final at = base.lastIndexOf(separator); + if (at <= 0) return fileName; // no hash or starts with separator + + final cleanBase = base.substring(0, at); + return p.url.setExtension(cleanBase, ext); + } } /// Ensures [mountPrefix] starts with '/' and ends with '/'. diff --git a/test/static/cache_busting_config_test.dart b/test/static/cache_busting_config_test.dart index d182f342..3c13de61 100644 --- a/test/static/cache_busting_config_test.dart +++ b/test/static/cache_busting_config_test.dart @@ -91,6 +91,7 @@ void main() { expect(result, original); }); }); + group( 'Given CacheBustingConfig without explicit separator configured for a directory with files', () { @@ -216,19 +217,29 @@ void main() { expect(busted, startsWith('/static/logo@')); }); - test( - 'Given a CacheBustingConfig with custom separator when calling assetPath then it uses that separator', - () async { - await d.dir('static', [d.file('logo.png', 'png-bytes')]).create(); - final staticRoot = Directory(p.join(d.sandbox, 'static')); - final cfg = CacheBustingConfig( - mountPrefix: '/static', - fileSystemRoot: staticRoot, - separator: '--', - ); - const original = '/static/logo.png'; - final busted = await cfg.assetPath(original); - expect(busted, startsWith('/static/logo--')); + group('Given a CacheBustingConfig with custom separator', () { + late CacheBustingConfig cfg; + setUp(() async { + await d.dir('static', [d.file('logo.png', 'png-bytes')]).create(); + final staticRoot = Directory(p.join(d.sandbox, 'static')); + cfg = CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + separator: '--', + ); + }); + + test('when assetPath is called then it uses that separator', () async { + const original = '/static/logo.png'; + final busted = await cfg.assetPath(original); + expect(busted, startsWith('/static/logo--')); + }); + + test( + 'when tryStripHashFromFilename is called with busted path then hash is stripped from filename', + () async { + expect(cfg.tryStripHashFromFilename('logo--abc123.png'), 'logo.png'); + }); }); test( @@ -251,7 +262,7 @@ void main() { }); test( - 'Given a CacheBustingConfig serving a file starting wht the separator when calling assetPath then cache busting path is created correctly', + 'Given a CacheBustingConfig serving a file starting with the separator when calling assetPath then cache busting path is created correctly', () async { await d.dir('static', [d.file('@logo.png', 'at-logo')]).create(); final staticRoot = Directory(p.join(d.sandbox, 'static')); @@ -321,11 +332,54 @@ void main() { }); test( - 'when assetPath is called for file using fileSystemRoot instead of mountPrefix as base then it returns path unchanged', + 'when assetPath is called for file using fileSystemRoot instead of mountPrefix as base then it returns path unchanged', () async { const original = '/static/logo.png'; final busted = await cfg.assetPath(original); expect(busted, equals('/static/logo.png')); }); }); + + group('Given cache busting config', () { + late CacheBustingConfig cfg; + setUp(() async { + await d.dir('static', []).create(); + final staticRoot = Directory(p.join(d.sandbox, 'static')); + cfg = CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + separator: '@', + ); + }); + + test( + 'when tryStripHashFromFilename is filename equals to the separator then same path is returned', + () async { + expect(cfg.tryStripHashFromFilename('@'), '@'); + }); + + test( + 'when tryStripHashFromFilename is called with non busted filename then same filename is returned', + () async { + expect(cfg.tryStripHashFromFilename('logo.png'), 'logo.png'); + }); + + test( + 'when tryStripHashFromFilename is called with busted filename then hash is stripped from filename', + () async { + expect(cfg.tryStripHashFromFilename('logo@abc123.png'), 'logo.png'); + }); + + test( + 'when tryStripHashFromFilename is called with busted filename that has no extension then it strips the hash and keeps no extension', + () async { + expect(cfg.tryStripHashFromFilename('logo@abc123'), 'logo'); + }); + + test( + 'when tryStripHashFromFilename is called with busted filename starting with separator then only trailing hash is stripped', + () async { + expect(cfg.tryStripHashFromFilename('@logo@abc123.png'), '@logo.png'); + }); + }); } From b74b3f9ac39eb668658f289c2b7a3cd0d382d7c6 Mon Sep 17 00:00:00 2001 From: Alexander Sandor Date: Fri, 10 Oct 2025 11:19:25 +0200 Subject: [PATCH 29/37] feat: Add cache busting directly to static handler. --- lib/src/io/static/static_handler.dart | 24 +- ...=> cache_busting_static_handler_test.dart} | 217 ++++++++---------- 2 files changed, 114 insertions(+), 127 deletions(-) rename test/static/{cache_busting_middleware_test.dart => cache_busting_static_handler_test.dart} (62%) diff --git a/lib/src/io/static/static_handler.dart b/lib/src/io/static/static_handler.dart index 225d55cb..55168a0c 100644 --- a/lib/src/io/static/static_handler.dart +++ b/lib/src/io/static/static_handler.dart @@ -67,11 +67,14 @@ Future getStaticFileInfo( /// /// The [mimeResolver] can be provided to customize MIME type detection. /// The [cacheControl] header can be customized using [cacheControl] callback. +/// If [cacheBustingConfig] is provided, the handler will strip cache-busting +/// hashes from the last path segment before looking up the file. Handler createStaticHandler( final String fileSystemPath, { final Handler? defaultHandler, final MimeTypeResolver? mimeResolver, required final CacheControlFactory cacheControl, + final CacheBustingConfig? cacheBustingConfig, }) { final rootDir = Directory(fileSystemPath); if (!rootDir.existsSync()) { @@ -83,9 +86,28 @@ Handler createStaticHandler( final fallbackHandler = defaultHandler ?? respondWith((final _) => Response.notFound()); + final resolveFilePath = switch (cacheBustingConfig) { + null => + (final String resolvedRootPath, final List requestSegments) => + p.joinAll([resolvedRootPath, ...requestSegments]), + final cfg => + (final String resolvedRootPath, final List requestSegments) { + if (requestSegments.isEmpty) { + return resolvedRootPath; + } + + final fileName = cfg.tryStripHashFromFilename(requestSegments.last); + return p.joinAll([ + resolvedRootPath, + ...requestSegments.sublist(0, requestSegments.length - 1), + fileName, + ]); + } + }; + return (final NewContext ctx) async { final filePath = - p.joinAll([resolvedRootPath, ...ctx.remainingPath.segments]); + resolveFilePath(resolvedRootPath, ctx.remainingPath.segments); // Ensure file exists and is not a directory final entityType = FileSystemEntity.typeSync(filePath, followLinks: false); diff --git a/test/static/cache_busting_middleware_test.dart b/test/static/cache_busting_static_handler_test.dart similarity index 62% rename from test/static/cache_busting_middleware_test.dart rename to test/static/cache_busting_static_handler_test.dart index b7a09ec7..558b9721 100644 --- a/test/static/cache_busting_middleware_test.dart +++ b/test/static/cache_busting_static_handler_test.dart @@ -9,22 +9,21 @@ import 'test_util.dart'; void main() { group( - 'Given a static asset served through a root-mounted cache busting middleware', + 'Given a static asset served through a root-mounted cache busting static file handler', () { late Handler handler; setUp(() async { await d.dir('static', [d.file('logo.png', 'png-bytes')]).create(); final staticRoot = Directory(p.join(d.sandbox, 'static')); - handler = const Pipeline() - .addMiddleware(cacheBusting(CacheBustingConfig( - mountPrefix: '/static', - fileSystemRoot: staticRoot, - separator: '@', - ))) - .addHandler(createStaticHandler( - staticRoot.path, - cacheControl: (final _, final __) => null, - )); + handler = const Pipeline().addHandler(createStaticHandler( + staticRoot.path, + cacheControl: (final _, final __) => null, + cacheBustingConfig: CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + separator: '@', + ), + )); }); test('when requesting asset with a non-busted URL then it serves the asset', @@ -46,7 +45,7 @@ void main() { }); group( - 'Given a static asset served through a router-mounted static handler and pipeline mounted cache busting middleware', + 'Given a static asset served through a router-mounted cache busting static handler', () { late Handler handler; setUp(() async { @@ -58,14 +57,14 @@ void main() { createStaticHandler( staticRoot.path, cacheControl: (final _, final __) => null, + cacheBustingConfig: CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + separator: '@', + ), )); handler = const Pipeline() - .addMiddleware(cacheBusting(CacheBustingConfig( - mountPrefix: '/static', - fileSystemRoot: staticRoot, - separator: '@', - ))) .addMiddleware(routeWith(router)) .addHandler(respondWith((final _) => Response.notFound())); }); @@ -86,46 +85,21 @@ void main() { }); }); - test( - 'Given a cache busting middleware when the request is the mount root then middleware early-returns unchanged', - () async { - await d.dir('static').create(); - final staticRoot = Directory(p.join(d.sandbox, 'static')); - final cfg = CacheBustingConfig( - mountPrefix: '/static', - fileSystemRoot: staticRoot, - separator: '@', - ); - - // Echo handler to observe the requestedUri.path after middleware. - final handler = const Pipeline() - .addMiddleware(cacheBusting(cfg)) - .addHandler(respondWith((final ctx) => - Response.ok(body: Body.fromString(ctx.requestedUri.path)))); - - final response = await makeRequest(handler, '/static/@abs'); - expect(response.statusCode, HttpStatus.ok); - expect(await response.readAsString(), '/static/@abs'); - }); - group('Given static asset served outside of cache busting mountPrefix', () { late Handler handler; setUp(() async { await d.dir('other', [d.file('logo.png', 'png-bytes')]).create(); final staticRoot = Directory(p.join(d.sandbox, 'other')); - final cfg = CacheBustingConfig( - mountPrefix: '/static', - fileSystemRoot: staticRoot, - separator: '@', - ); - - handler = const Pipeline() - .addMiddleware(cacheBusting(cfg)) - .addHandler(createStaticHandler( - staticRoot.path, - cacheControl: (final _, final __) => null, - )); + handler = const Pipeline().addHandler(createStaticHandler( + staticRoot.path, + cacheControl: (final _, final __) => null, + cacheBustingConfig: CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + separator: '@', + ), + )); }); test('when requesting asset with a non-busted URL then it serves the asset', @@ -137,32 +111,31 @@ void main() { }); test( - 'when requesting asset with a busted URL then URL is not rewritten resulting in asset not being found', + 'when requesting asset with a busted URL then it still serves the asset (handler-level cache busting)', () async { final response = await makeRequest(handler, '/other/logo@abc.png', handlerPath: 'other'); - expect(response.statusCode, HttpStatus.notFound); + expect(response.statusCode, HttpStatus.ok); + expect(await response.readAsString(), 'png-bytes'); }); }); group( - 'Given a static asset with a filename starting with separator served through cache busting middleware', + 'Given a static asset with a filename starting with separator served through cache busting static file handler', () { late Handler handler; setUp(() async { await d.dir('static', [d.file('@logo.png', 'png-bytes')]).create(); final staticRoot = Directory(p.join(d.sandbox, 'static')); - final cfg = CacheBustingConfig( - mountPrefix: '/static', - fileSystemRoot: staticRoot, - separator: '@', - ); - handler = const Pipeline() - .addMiddleware(cacheBusting(cfg)) - .addHandler(createStaticHandler( - staticRoot.path, - cacheControl: (final _, final __) => null, - )); + handler = const Pipeline().addHandler(createStaticHandler( + staticRoot.path, + cacheControl: (final _, final __) => null, + cacheBustingConfig: CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + separator: '@', + ), + )); }); test('when requesting asset with a non-busted URL then it serves the asset', @@ -184,23 +157,21 @@ void main() { }); group( - 'Given a static asset without extension served through cache busting middleware', + 'Given a static asset without extension served through cache busting static file handler', () { late Handler handler; setUp(() async { await d.dir('static', [d.file('logo', 'file-contents')]).create(); final staticRoot = Directory(p.join(d.sandbox, 'static')); - final cfg = CacheBustingConfig( - mountPrefix: '/static', - fileSystemRoot: staticRoot, - separator: '@', - ); - handler = const Pipeline() - .addMiddleware(cacheBusting(cfg)) - .addHandler(createStaticHandler( - staticRoot.path, - cacheControl: (final _, final __) => null, - )); + handler = const Pipeline().addHandler(createStaticHandler( + staticRoot.path, + cacheControl: (final _, final __) => null, + cacheBustingConfig: CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + separator: '@', + ), + )); }); test('when requesting asset with a non-busted URL then it serves the asset', @@ -222,23 +193,21 @@ void main() { }); group( - 'Given a static asset served through cache busting middleware with a custom separator', + 'Given a static asset served through cache busting static file handler with a custom separator', () { late Handler handler; setUp(() async { await d.dir('static', [d.file('logo.png', 'png-bytes')]).create(); final staticRoot = Directory(p.join(d.sandbox, 'static')); - final cfg = CacheBustingConfig( - mountPrefix: '/static', - fileSystemRoot: staticRoot, - separator: '--', - ); - handler = const Pipeline() - .addMiddleware(cacheBusting(cfg)) - .addHandler(createStaticHandler( - staticRoot.path, - cacheControl: (final _, final __) => null, - )); + handler = const Pipeline().addHandler(createStaticHandler( + staticRoot.path, + cacheControl: (final _, final __) => null, + cacheBustingConfig: CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + separator: '--', + ), + )); }); test('when requesting asset with a non-busted URL then it serves the asset', @@ -267,7 +236,8 @@ void main() { }); }); - group('Given a static asset in a nested directory containing the separator', + group( + 'Given a static asset served through cache busting static handler in a nested directory containing the separator', () { late Handler handler; setUp(() async { @@ -275,17 +245,15 @@ void main() { d.dir('@images', [d.file('logo.png', 'nested-bytes')]) ]).create(); final staticRoot = Directory(p.join(d.sandbox, 'static')); - final cfg = CacheBustingConfig( - mountPrefix: '/static', - fileSystemRoot: staticRoot, - separator: '@', - ); - handler = const Pipeline() - .addMiddleware(cacheBusting(cfg)) - .addHandler(createStaticHandler( - staticRoot.path, - cacheControl: (final _, final __) => null, - )); + handler = const Pipeline().addHandler(createStaticHandler( + staticRoot.path, + cacheControl: (final _, final __) => null, + cacheBustingConfig: CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + separator: '@', + ), + )); }); test('when requesting asset with a non-busted URL then it serves the asset', @@ -308,45 +276,42 @@ void main() { }); group( - 'Given a static asset served through a router-mounted static handler and cache busting middleware', + 'Given a static handler configured with CacheBustingConfig that has different fileSystemRoot', () { late Handler handler; setUp(() async { - await d.dir('static', [d.file('logo.png', 'png-bytes')]).create(); + await d.dir('static', [ + d.dir('images', [d.file('logo.png', 'nested-bytes')]) + ]).create(); + await d.dir('cache', []).create(); final staticRoot = Directory(p.join(d.sandbox, 'static')); - final router = Router() - ..get( - '/static/**', - createStaticHandler( - staticRoot.path, - cacheControl: (final _, final __) => null, - )) - ..use( - '/static/**', - cacheBusting(CacheBustingConfig( - mountPrefix: '/static', - fileSystemRoot: staticRoot, - separator: '@', - ))); - - handler = const Pipeline() - .addMiddleware(routeWith(router)) - .addHandler(respondWith((final _) => Response.notFound())); + final cacheRoot = Directory(p.join(d.sandbox, 'cache')); + handler = const Pipeline().addHandler(createStaticHandler( + staticRoot.path, + cacheControl: (final _, final __) => null, + cacheBustingConfig: CacheBustingConfig( + mountPrefix: '/cache', + fileSystemRoot: cacheRoot, + separator: '@', + ), + )); }); test('when requesting asset with a non-busted URL then it serves the asset', () async { - final response = await makeRequest(handler, '/static/logo.png'); + final response = await makeRequest(handler, '/static/images/logo.png', + handlerPath: 'static'); expect(response.statusCode, HttpStatus.ok); - expect(await response.readAsString(), 'png-bytes'); + expect(await response.readAsString(), 'nested-bytes'); }); test( - 'when requesting asset with a cache busted URL then it serves the asset', + 'when requesting asset with a cache busted URL then it still serves the asset (handler-level cache busting)', () async { - final response = await makeRequest(handler, '/static/logo@abc.png'); + final response = await makeRequest(handler, '/static/images/logo@abc.png', + handlerPath: 'static'); expect(response.statusCode, HttpStatus.ok); - expect(await response.readAsString(), 'png-bytes'); + expect(await response.readAsString(), 'nested-bytes'); }); }); } From 1e421af167271e8deda58142e1c3fc3a14c145f0 Mon Sep 17 00:00:00 2001 From: Alexander Sandor Date: Fri, 10 Oct 2025 11:25:47 +0200 Subject: [PATCH 30/37] fix: Remove old middleware and update documentation and example. --- example/static_files_example.dart | 2 +- lib/relic.dart | 2 +- ...busting.dart => cache_busting_config.dart} | 53 ++++--------------- lib/src/io/static/static_handler.dart | 4 +- 4 files changed, 14 insertions(+), 47 deletions(-) rename lib/src/io/static/{middleware_cache_busting.dart => cache_busting_config.dart} (74%) diff --git a/example/static_files_example.dart b/example/static_files_example.dart index 64fe5a38..d99f9378 100644 --- a/example/static_files_example.dart +++ b/example/static_files_example.dart @@ -44,12 +44,12 @@ Future main() async { publicCache: true, immutable: true, ), + cacheBustingConfig: buster, )); // Setup a handler pipeline with logging, cache busting, and routing. final handler = const Pipeline() .addMiddleware(logRequests()) - .addMiddleware(cacheBusting(buster)) .addMiddleware(routeWith(router)) .addHandler(respondWith((final _) => Response.notFound())); diff --git a/lib/relic.dart b/lib/relic.dart index 97858e49..c332a2ce 100644 --- a/lib/relic.dart +++ b/lib/relic.dart @@ -15,7 +15,7 @@ export 'src/headers/header_accessor.dart'; export 'src/headers/headers.dart'; export 'src/headers/standard_headers_extensions.dart'; export 'src/headers/typed/typed_headers.dart'; -export 'src/io/static/middleware_cache_busting.dart'; +export 'src/io/static/cache_busting_config.dart'; export 'src/io/static/static_handler.dart'; export 'src/message/request.dart' show Request; export 'src/message/response.dart' show Response; diff --git a/lib/src/io/static/middleware_cache_busting.dart b/lib/src/io/static/cache_busting_config.dart similarity index 74% rename from lib/src/io/static/middleware_cache_busting.dart rename to lib/src/io/static/cache_busting_config.dart index 86aefb62..04bb1408 100644 --- a/lib/src/io/static/middleware_cache_busting.dart +++ b/lib/src/io/static/cache_busting_config.dart @@ -2,7 +2,6 @@ import 'dart:io'; import 'package:path/path.dart' as p; import '../../../relic.dart'; -import '../../adapter/context.dart'; /// Cache-busting for asset URLs that embed a content hash. /// @@ -10,51 +9,17 @@ import '../../adapter/context.dart'; /// - Outgoing URLs: call [CacheBustingConfig.assetPath] (or /// [CacheBustingConfig.tryAssetPath]) with a known mount prefix (e.g. "/static") /// to get "/static/name@hash.ext". -/// - Incoming requests: add this [cacheBusting] middleware so downstream -/// handlers (e.g., the static file handler) receive "/path/name.ext" without -/// the hash. +/// - Incoming requests: add this [CacheBustingConfig] to the static file handler +/// (see [createStaticHandler]). The handler will strip the hash so that static +/// asset requests can be served without the hash. /// -/// This middleware strips an inline cache-busting hash from the last path segment -/// for requests under [CacheBustingConfig.mountPrefix]. Example: +/// Once added to the `createStaticHandler`, the handler will strip the hash +/// from the last path segment before looking up the file. If no hash is found, +/// the path is used as-is. +/// +// Example: /// "/static/images/logo@abc123.png" → "/static/images/logo.png". -Middleware cacheBusting(final CacheBustingConfig config) { - return (final inner) { - return (final ctx) async { - final req = ctx.request; - final fullPath = req.requestedUri.path; - - if (!fullPath.startsWith(config.mountPrefix)) { - return await inner(ctx); - } - - // Extract the portion after mount prefix - final relative = fullPath.substring(config.mountPrefix.length); - if (relative.isEmpty) { - return await inner(ctx); - } - final last = p.url.basename(relative); - - final strippedLast = config.tryStripHashFromFilename(last); - if (strippedLast == last) { - return await inner(ctx); - } - - final directory = p.url.dirname(relative); - final rewrittenRelative = - directory == '.' ? strippedLast : p.url.join(directory, strippedLast); - - // Rebuild a new Request only by updating requestedUri path; do not touch - // handlerPath so that routers and mounts continue to work as configured. - final newRequested = req.requestedUri.replace( - path: '${config.mountPrefix}$rewrittenRelative', - ); - final rewrittenRequest = req.copyWith(requestedUri: newRequested); - return await inner(rewrittenRequest.toContext(ctx.token)); - }; - }; -} - -/// Configuration and helpers for generating cache-busted asset URLs. +/// "/static/images/logo.png" → "/static/images/logo.png". class CacheBustingConfig { /// The URL prefix under which static assets are served (e.g., "/static"). final String mountPrefix; diff --git a/lib/src/io/static/static_handler.dart b/lib/src/io/static/static_handler.dart index 55168a0c..747e7d21 100644 --- a/lib/src/io/static/static_handler.dart +++ b/lib/src/io/static/static_handler.dart @@ -67,8 +67,10 @@ Future getStaticFileInfo( /// /// The [mimeResolver] can be provided to customize MIME type detection. /// The [cacheControl] header can be customized using [cacheControl] callback. +/// /// If [cacheBustingConfig] is provided, the handler will strip cache-busting -/// hashes from the last path segment before looking up the file. +/// hashes from the last path segment before looking up any file. +/// See [CacheBustingConfig] for details. Handler createStaticHandler( final String fileSystemPath, { final Handler? defaultHandler, From 3a97f33d7a85feba87e9e13157ceb7cae1858e7e Mon Sep 17 00:00:00 2001 From: Alexander Sandor Date: Fri, 10 Oct 2025 13:45:25 +0200 Subject: [PATCH 31/37] refactor: Convert to proper validation style for separator and file system root. --- lib/src/io/static/cache_busting_config.dart | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/lib/src/io/static/cache_busting_config.dart b/lib/src/io/static/cache_busting_config.dart index 04bb1408..5f69abd9 100644 --- a/lib/src/io/static/cache_busting_config.dart +++ b/lib/src/io/static/cache_busting_config.dart @@ -33,10 +33,12 @@ class CacheBustingConfig { CacheBustingConfig({ required final String mountPrefix, required final Directory fileSystemRoot, - final String separator = '@', + this.separator = '@', }) : mountPrefix = _normalizeMount(mountPrefix), - fileSystemRoot = _validateFileSystemRoot(fileSystemRoot.absolute), - separator = _validateSeparator(separator); + fileSystemRoot = fileSystemRoot.absolute { + _validateFileSystemRoot(fileSystemRoot.absolute); + _validateSeparator(separator); + } /// Returns the cache-busted URL for the given [staticPath]. /// @@ -133,7 +135,7 @@ String _normalizeMount(final String mountPrefix) { return mountPrefix.endsWith('/') ? mountPrefix : '$mountPrefix/'; } -Directory _validateFileSystemRoot(final Directory dir) { +void _validateFileSystemRoot(final Directory dir) { if (!dir.existsSync()) { throw ArgumentError.value(dir.path, 'fileSystemRoot', 'does not exist'); } @@ -147,13 +149,10 @@ Directory _validateFileSystemRoot(final Directory dir) { 'is not a directory', ); } - - return dir; } -String _validateSeparator(final String separator) { +void _validateSeparator(final String separator) { if (separator.isEmpty) { throw ArgumentError('separator cannot be empty'); } - return separator; } From 003a5c1d784bf60cf39f2d3a2055fffc379d7732 Mon Sep 17 00:00:00 2001 From: Alexander Sandor Date: Fri, 10 Oct 2025 13:49:26 +0200 Subject: [PATCH 32/37] refactor: Convert tryStripHashFromFilename into static method to hide it from cache busting config interface. --- lib/src/io/static/cache_busting_config.dart | 5 +++-- lib/src/io/static/static_handler.dart | 5 ++++- test/static/cache_busting_config_test.dart | 20 ++++++++++++++------ 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/lib/src/io/static/cache_busting_config.dart b/lib/src/io/static/cache_busting_config.dart index 5f69abd9..e771774b 100644 --- a/lib/src/io/static/cache_busting_config.dart +++ b/lib/src/io/static/cache_busting_config.dart @@ -113,13 +113,14 @@ class CacheBustingConfig { /// `logo@abc.png` -> `logo.png` /// `logo@abc` -> `logo` /// `logo.png` -> `logo.png` (no change) - String tryStripHashFromFilename( + static String tryStripHashFromFilename( final String fileName, + final CacheBustingConfig config, ) { final ext = p.url.extension(fileName); final base = p.url.basenameWithoutExtension(fileName); - final at = base.lastIndexOf(separator); + final at = base.lastIndexOf(config.separator); if (at <= 0) return fileName; // no hash or starts with separator final cleanBase = base.substring(0, at); diff --git a/lib/src/io/static/static_handler.dart b/lib/src/io/static/static_handler.dart index 747e7d21..6f6bf8c9 100644 --- a/lib/src/io/static/static_handler.dart +++ b/lib/src/io/static/static_handler.dart @@ -98,7 +98,10 @@ Handler createStaticHandler( return resolvedRootPath; } - final fileName = cfg.tryStripHashFromFilename(requestSegments.last); + final fileName = CacheBustingConfig.tryStripHashFromFilename( + requestSegments.last, + cfg, + ); return p.joinAll([ resolvedRootPath, ...requestSegments.sublist(0, requestSegments.length - 1), diff --git a/test/static/cache_busting_config_test.dart b/test/static/cache_busting_config_test.dart index 3c13de61..d470efc6 100644 --- a/test/static/cache_busting_config_test.dart +++ b/test/static/cache_busting_config_test.dart @@ -238,7 +238,9 @@ void main() { test( 'when tryStripHashFromFilename is called with busted path then hash is stripped from filename', () async { - expect(cfg.tryStripHashFromFilename('logo--abc123.png'), 'logo.png'); + expect( + CacheBustingConfig.tryStripHashFromFilename('logo--abc123.png', cfg), + 'logo.png'); }); }); @@ -355,31 +357,37 @@ void main() { test( 'when tryStripHashFromFilename is filename equals to the separator then same path is returned', () async { - expect(cfg.tryStripHashFromFilename('@'), '@'); + expect(CacheBustingConfig.tryStripHashFromFilename('@', cfg), '@'); }); test( 'when tryStripHashFromFilename is called with non busted filename then same filename is returned', () async { - expect(cfg.tryStripHashFromFilename('logo.png'), 'logo.png'); + expect(CacheBustingConfig.tryStripHashFromFilename('logo.png', cfg), + 'logo.png'); }); test( 'when tryStripHashFromFilename is called with busted filename then hash is stripped from filename', () async { - expect(cfg.tryStripHashFromFilename('logo@abc123.png'), 'logo.png'); + expect( + CacheBustingConfig.tryStripHashFromFilename('logo@abc123.png', cfg), + 'logo.png'); }); test( 'when tryStripHashFromFilename is called with busted filename that has no extension then it strips the hash and keeps no extension', () async { - expect(cfg.tryStripHashFromFilename('logo@abc123'), 'logo'); + expect(CacheBustingConfig.tryStripHashFromFilename('logo@abc123', cfg), + 'logo'); }); test( 'when tryStripHashFromFilename is called with busted filename starting with separator then only trailing hash is stripped', () async { - expect(cfg.tryStripHashFromFilename('@logo@abc123.png'), '@logo.png'); + expect( + CacheBustingConfig.tryStripHashFromFilename('@logo@abc123.png', cfg), + '@logo.png'); }); }); } From 6477fe804269bbbf3c2377e022e141400784d4b1 Mon Sep 17 00:00:00 2001 From: Alexander Sandor Date: Fri, 10 Oct 2025 13:52:00 +0200 Subject: [PATCH 33/37] fix: Update example to allow head requests. --- example/static_files_example.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/example/static_files_example.dart b/example/static_files_example.dart index d99f9378..d6458c75 100644 --- a/example/static_files_example.dart +++ b/example/static_files_example.dart @@ -35,7 +35,11 @@ Future main() async { ''; return Response.ok(body: Body.fromString(html, mimeType: MimeType.html)); })) - ..get( + ..anyOf( + { + Method.get, + Method.head, + }, '/static/**', createStaticHandler( staticDir.path, From fb5df35ed1271eeb7b201d2ddcb8aa409d697869 Mon Sep 17 00:00:00 2001 From: Alexander Sandor Date: Mon, 13 Oct 2025 08:08:24 +0200 Subject: [PATCH 34/37] fix(review): Add check for "/" separator. --- lib/src/io/static/cache_busting_config.dart | 4 ++++ test/static/cache_busting_config_test.dart | 14 ++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/lib/src/io/static/cache_busting_config.dart b/lib/src/io/static/cache_busting_config.dart index e771774b..a5fbaa89 100644 --- a/lib/src/io/static/cache_busting_config.dart +++ b/lib/src/io/static/cache_busting_config.dart @@ -156,4 +156,8 @@ void _validateSeparator(final String separator) { if (separator.isEmpty) { throw ArgumentError('separator cannot be empty'); } + + if (separator.contains('/')) { + throw ArgumentError('separator cannot contain "/"'); + } } diff --git a/test/static/cache_busting_config_test.dart b/test/static/cache_busting_config_test.dart index d470efc6..f92bd796 100644 --- a/test/static/cache_busting_config_test.dart +++ b/test/static/cache_busting_config_test.dart @@ -33,6 +33,20 @@ void main() { ); }); + test( + 'Given "/" separator when creating CacheBustingConfig then it throws ArgumentError', + () async { + final staticRoot = Directory(p.join(d.sandbox, 'static')); + expect( + () => CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + separator: '/', + ), + throwsA(isA()), + ); + }); + test( 'Given no directory at staticRoot when creating CacheBustingConfig then it throws ArgumentError', () { From 9290bdfd9d9e54d6dabab876efa864181b501fd7 Mon Sep 17 00:00:00 2001 From: Alexander Sandor Date: Mon, 13 Oct 2025 08:08:44 +0200 Subject: [PATCH 35/37] test(review): Add valid separator to test right thing. --- test/static/cache_busting_config_test.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/static/cache_busting_config_test.dart b/test/static/cache_busting_config_test.dart index f92bd796..a93a0d1b 100644 --- a/test/static/cache_busting_config_test.dart +++ b/test/static/cache_busting_config_test.dart @@ -55,7 +55,7 @@ void main() { () => CacheBustingConfig( mountPrefix: '/static', fileSystemRoot: staticRoot, - separator: '', + separator: '@', ), throwsA(isA()), ); @@ -70,7 +70,7 @@ void main() { () => CacheBustingConfig( mountPrefix: '/static', fileSystemRoot: staticRoot, - separator: '', + separator: '@', ), throwsA(isA()), ); From ea63512b9e0dc52fe7faa0c61794e4e7cfdf572b Mon Sep 17 00:00:00 2001 From: Alexander Sandor Date: Mon, 13 Oct 2025 08:20:29 +0200 Subject: [PATCH 36/37] fix(review): Add a hidden extension for stripping hash from filename. --- lib/relic.dart | 2 +- lib/src/io/static/cache_busting_config.dart | 47 +++++++++++---------- lib/src/io/static/static_handler.dart | 4 +- test/static/cache_busting_config_test.dart | 22 +++------- 4 files changed, 34 insertions(+), 41 deletions(-) diff --git a/lib/relic.dart b/lib/relic.dart index c332a2ce..f513056c 100644 --- a/lib/relic.dart +++ b/lib/relic.dart @@ -15,7 +15,7 @@ export 'src/headers/header_accessor.dart'; export 'src/headers/headers.dart'; export 'src/headers/standard_headers_extensions.dart'; export 'src/headers/typed/typed_headers.dart'; -export 'src/io/static/cache_busting_config.dart'; +export 'src/io/static/cache_busting_config.dart' show CacheBustingConfig; export 'src/io/static/static_handler.dart'; export 'src/message/request.dart' show Request; export 'src/message/response.dart' show Response; diff --git a/lib/src/io/static/cache_busting_config.dart b/lib/src/io/static/cache_busting_config.dart index a5fbaa89..02bb9f5c 100644 --- a/lib/src/io/static/cache_busting_config.dart +++ b/lib/src/io/static/cache_busting_config.dart @@ -103,29 +103,6 @@ class CacheBustingConfig { return staticPath; } } - - /// Removes a trailing "``hash" segment from a [fileName], preserving any - /// extension. Matches both "`namehash`.ext" and "`namehash`". - /// - /// If no hash is found, returns [fileName] unchanged. - /// - /// Examples: - /// `logo@abc.png` -> `logo.png` - /// `logo@abc` -> `logo` - /// `logo.png` -> `logo.png` (no change) - static String tryStripHashFromFilename( - final String fileName, - final CacheBustingConfig config, - ) { - final ext = p.url.extension(fileName); - final base = p.url.basenameWithoutExtension(fileName); - - final at = base.lastIndexOf(config.separator); - if (at <= 0) return fileName; // no hash or starts with separator - - final cleanBase = base.substring(0, at); - return p.url.setExtension(cleanBase, ext); - } } /// Ensures [mountPrefix] starts with '/' and ends with '/'. @@ -161,3 +138,27 @@ void _validateSeparator(final String separator) { throw ArgumentError('separator cannot contain "/"'); } } + +extension CacheBustingFilenameExtension on CacheBustingConfig { + /// Removes a trailing "``hash" segment from a [fileName], preserving any + /// extension. Matches both "`namehash`.ext" and "`namehash`". + /// + /// If no hash is found, returns [fileName] unchanged. + /// + /// Examples: + /// `logo@abc.png` -> `logo.png` + /// `logo@abc` -> `logo` + /// `logo.png` -> `logo.png` (no change) + String tryStripHashFromFilename( + final String fileName, + ) { + final ext = p.url.extension(fileName); + final base = p.url.basenameWithoutExtension(fileName); + + final at = base.lastIndexOf(separator); + if (at <= 0) return fileName; // no hash or starts with separator + + final cleanBase = base.substring(0, at); + return p.url.setExtension(cleanBase, ext); + } +} diff --git a/lib/src/io/static/static_handler.dart b/lib/src/io/static/static_handler.dart index 6f6bf8c9..e1fbc21c 100644 --- a/lib/src/io/static/static_handler.dart +++ b/lib/src/io/static/static_handler.dart @@ -11,6 +11,7 @@ import 'package:path/path.dart' as p; import '../../../relic.dart'; import '../../router/lru_cache.dart'; +import 'cache_busting_config.dart'; /// The default resolver for MIME types based on file extensions. final _defaultMimeTypeResolver = MimeTypeResolver(); @@ -98,9 +99,8 @@ Handler createStaticHandler( return resolvedRootPath; } - final fileName = CacheBustingConfig.tryStripHashFromFilename( + final fileName = cfg.tryStripHashFromFilename( requestSegments.last, - cfg, ); return p.joinAll([ resolvedRootPath, diff --git a/test/static/cache_busting_config_test.dart b/test/static/cache_busting_config_test.dart index a93a0d1b..d49b8d24 100644 --- a/test/static/cache_busting_config_test.dart +++ b/test/static/cache_busting_config_test.dart @@ -1,7 +1,7 @@ import 'dart:io'; import 'package:path/path.dart' as p; -import 'package:relic/relic.dart'; +import 'package:relic/src/io/static/cache_busting_config.dart'; import 'package:test/test.dart'; import 'package:test_descriptor/test_descriptor.dart' as d; @@ -252,9 +252,7 @@ void main() { test( 'when tryStripHashFromFilename is called with busted path then hash is stripped from filename', () async { - expect( - CacheBustingConfig.tryStripHashFromFilename('logo--abc123.png', cfg), - 'logo.png'); + expect(cfg.tryStripHashFromFilename('logo--abc123.png'), 'logo.png'); }); }); @@ -371,37 +369,31 @@ void main() { test( 'when tryStripHashFromFilename is filename equals to the separator then same path is returned', () async { - expect(CacheBustingConfig.tryStripHashFromFilename('@', cfg), '@'); + expect(cfg.tryStripHashFromFilename('@'), '@'); }); test( 'when tryStripHashFromFilename is called with non busted filename then same filename is returned', () async { - expect(CacheBustingConfig.tryStripHashFromFilename('logo.png', cfg), - 'logo.png'); + expect(cfg.tryStripHashFromFilename('logo.png'), 'logo.png'); }); test( 'when tryStripHashFromFilename is called with busted filename then hash is stripped from filename', () async { - expect( - CacheBustingConfig.tryStripHashFromFilename('logo@abc123.png', cfg), - 'logo.png'); + expect(cfg.tryStripHashFromFilename('logo@abc123.png'), 'logo.png'); }); test( 'when tryStripHashFromFilename is called with busted filename that has no extension then it strips the hash and keeps no extension', () async { - expect(CacheBustingConfig.tryStripHashFromFilename('logo@abc123', cfg), - 'logo'); + expect(cfg.tryStripHashFromFilename('logo@abc123'), 'logo'); }); test( 'when tryStripHashFromFilename is called with busted filename starting with separator then only trailing hash is stripped', () async { - expect( - CacheBustingConfig.tryStripHashFromFilename('@logo@abc123.png', cfg), - '@logo.png'); + expect(cfg.tryStripHashFromFilename('@logo@abc123.png'), '@logo.png'); }); }); } From 9180ea06206414d8e3254628a56765ef3cfe12e8 Mon Sep 17 00:00:00 2001 From: Alexander Sandor Date: Mon, 13 Oct 2025 09:14:17 +0200 Subject: [PATCH 37/37] test(review): Add additional tests for "/" separator. --- test/static/cache_busting_config_test.dart | 42 ++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/test/static/cache_busting_config_test.dart b/test/static/cache_busting_config_test.dart index d49b8d24..cb6ba193 100644 --- a/test/static/cache_busting_config_test.dart +++ b/test/static/cache_busting_config_test.dart @@ -47,6 +47,48 @@ void main() { ); }); + test( + 'Given separator starting with "/" when creating CacheBustingConfig then it throws ArgumentError', + () async { + final staticRoot = Directory(p.join(d.sandbox, 'static')); + expect( + () => CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + separator: '/@', + ), + throwsA(isA()), + ); + }); + + test( + 'Given separator ending with "/" when creating CacheBustingConfig then it throws ArgumentError', + () async { + final staticRoot = Directory(p.join(d.sandbox, 'static')); + expect( + () => CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + separator: '@/', + ), + throwsA(isA()), + ); + }); + + test( + 'Given separator containing "/" when creating CacheBustingConfig then it throws ArgumentError', + () async { + final staticRoot = Directory(p.join(d.sandbox, 'static')); + expect( + () => CacheBustingConfig( + mountPrefix: '/static', + fileSystemRoot: staticRoot, + separator: '@/@', + ), + throwsA(isA()), + ); + }); + test( 'Given no directory at staticRoot when creating CacheBustingConfig then it throws ArgumentError', () {