@@ -21,11 +21,24 @@ pub fn StaticAssetMeta::new(
2121}
2222
2323///|
24+ /// A backend for `Mocket::static_assets`.
25+ ///
26+ /// Asset ids handed to the provider are virtual absolute paths rooted at the
27+ /// mount point: they always start with "/" and are already normalized, so
28+ /// "." / ".." segments can never escape the provider's root. Providers should
29+ /// join the id onto their root directly.
2430pub (open) trait ServeStaticProvider {
25- // This function should resolve asset meta
26- fn get_meta(Self , id : StringView ) -> StaticAssetMeta?
27- // This function should resolve asset content
28- fn get_contents(Self , id : StringView ) -> &Responder
31+ // Resolve metadata for a candidate asset id.
32+ //
33+ // Return `None` only when the candidate does not exist (or is not a
34+ // servable asset, e.g. a directory); the middleware then keeps probing the
35+ // remaining candidates. I/O failures other than absence should be raised
36+ // so they surface as server errors instead of a misleading 404.
37+ async fn get_meta(Self , id : StringView ) -> StaticAssetMeta?
38+ // Resolve asset content. Called only after `get_meta` returned `Some` for
39+ // the same id; a missing file at this point should still yield a 404
40+ // responder, while other I/O failures should yield a 5xx responder.
41+ async fn get_contents(Self , id : StringView ) -> &Responder
2942 // Custom MIME type resolver function
3043 fn get_type(Self , ext : String ) -> String?
3144 // Encodings map
@@ -56,14 +69,54 @@ test "normalize_path" {
5669 inspect(@posix.Path ::normalize("/foo//bar"), content="/foo/bar")
5770}
5871
72+ ///|
73+ /// Join a candidate suffix (usually an index file name) onto a resolved
74+ /// asset id, inserting exactly one path separator between them.
75+ fn join_asset_id(id : String , suffix : String ) -> String {
76+ if suffix == "" {
77+ return id
78+ }
79+ let suffix = if suffix.has_prefix("/") { suffix[1:] } else { suffix.view() }
80+ if id.has_suffix("/") {
81+ "\{id}\{suffix}"
82+ } else {
83+ "\{id}/\{suffix}"
84+ }
85+ }
86+
87+ ///|
88+ test "join_asset_id" {
89+ inspect(join_asset_id("/", "index.html"), content="/index.html")
90+ inspect(join_asset_id("/app.txt", ""), content="/app.txt")
91+ inspect(join_asset_id("/sub", "index.html"), content="/sub/index.html")
92+ inspect(join_asset_id("/sub/", "/index.html"), content="/sub/index.html")
93+ }
94+
5995///|
6096pub fn Mocket ::static_assets(
6197 self : Mocket ,
6298 path : String ,
6399 provider : &ServeStaticProvider ,
64100) -> Unit {
101+ // Normalize the mount point: strip a trailing "/" (except for the root
102+ // mount "/") so matching and slicing have a single canonical form.
103+ let mount = if path.length() > 1 && path.has_suffix("/") {
104+ path[:path.length() - 1].to_owned()
105+ } else {
106+ path
107+ }
65108 self.use_middleware(async fn(event, next) {
66- if !(match_path(path, event.req.url) is None ) {
109+ let url = event.req.url
110+ // Match the mount as a path prefix on a segment boundary, before any
111+ // slicing happens: "/assets" matches "/assets" and everything under
112+ // "/assets/...", but not "/assetsx" or shorter, unrelated URLs. Those
113+ // fall through to the next middleware or route untouched.
114+ let in_mount = if mount == "/" {
115+ url.has_prefix("/")
116+ } else {
117+ url == mount || url.has_prefix("\{mount}/")
118+ }
119+ if !in_mount {
67120 return next()
68121 }
69122
@@ -76,8 +129,15 @@ pub fn Mocket::static_assets(
76129 return HttpResponse ::new(MethodNotAllowed )
77130 }
78131
79- let raw_id = event.req.url[path.length():]
80- let original_id = Show ::to_string(@posix.Path ::normalize(raw_id.to_owned()))
132+ // Safe to slice now: `url` equals the mount or starts with "mount/".
133+ let raw_id = if mount == "/" { url.view() } else { url[mount.length():] }
134+ // Resolve under a virtual root so ".." segments can never escape it;
135+ // the normalized id is an absolute path confined to the mount root.
136+ let resolved_id = Show ::to_string(
137+ @posix.Path ::normalize(
138+ (if raw_id == "" { "/".view() } else { raw_id }).to_owned(),
139+ ),
140+ )
81141 // Parse Accept-Encoding
82142 // Headers are Map[StringView, StringView]
83143 let accept_encoding = event.req.headers.get("Accept -Encoding ").unwrap_or("")
@@ -98,11 +158,15 @@ pub fn Mocket::static_assets(
98158 }
99159
100160 // Search paths
101- let mut id = original_id
161+ let mut id = resolved_id
102162 let mut meta : StaticAssetMeta? = None
103- let index_names = provider.get_index_names()
104- if index_names.length() == 0 {
105- ignore(index_names.push("/index.html"))
163+ let index_names = {
164+ let names = provider.get_index_names()
165+ if names.is_empty() {
166+ ["index.html"]
167+ } else {
168+ names
169+ }
106170 }
107171
108172 // Search logic: suffix -> encoding
@@ -116,7 +180,7 @@ pub fn Mocket::static_assets(
116180 break
117181 }
118182 for encoding in try_encodings {
119- let try_id = id + suffix + encoding
183+ let try_id = join_asset_id(id, suffix) + encoding
120184 match provider.get_meta(try_id) {
121185 Some (m) => {
122186 meta = Some (m)
@@ -186,7 +250,7 @@ pub fn Mocket::static_assets(
186250 // Content-Length
187251 match meta.size {
188252 Some (size) =>
189- if size > 0L && !event.res.headers.contains("Content -Length ") {
253+ if size >= 0L && !event.res.headers.contains("Content -Length ") {
190254 event.res.headers.set("Content -Length ", size.to_string())
191255 }
192256 None => ()
0 commit comments