Skip to main content

bynk_check/
assets.rs

1//! #1883 (static assets): the checker-side rules for `asset name = "path"`
2//! declarations — the path rules, the name rule, the composition of a unit's
3//! visible assets through `uses` ([DECISION G]) — and (slice 1b) the build
4//! side's shared definitions: the existence check, the served URL path, and
5//! the files each Worker ships.
6//!
7//! The checker stays disk-free (`bynk-wasm` compiles it with no filesystem):
8//! the project's `assets/` folder reaches it from the caller
9//! (`bynk_project::assets`), as a path set or as an
10//! [`AssetFolder`](bynk_project::assets::AssetFolder) whose reader it calls
11//! for the declared files alone ([`read_declared_files`]).
12
13use std::collections::{BTreeMap, BTreeSet, HashMap};
14
15use bynk_syntax::ast::{AssetDecl, CommonsItem, FnName, Ident};
16use bynk_syntax::error::CompileError;
17
18use crate::project_model::{ErrorSink, UnitInfo};
19use bynk_project::ParsedFile;
20
21/// Why `path` is not a safe asset path, or `None` when it is.
22///
23/// An asset is public by construction, and the path is joined onto the
24/// project root to find the file, so a path that can name something outside
25/// the project is rejected outright: an absolute path (`/etc/x`, `\x`,
26/// `C:/x`), and any `..` segment. Both separators count, so a Windows-style
27/// path is judged the same on every host. An empty path names no file.
28pub fn unsafe_asset_path_reason(path: &str) -> Option<&'static str> {
29    if path.is_empty() {
30        return Some("the path is empty");
31    }
32    let bytes = path.as_bytes();
33    let has_drive = bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':';
34    if path.starts_with('/') || path.starts_with('\\') || has_drive {
35        return Some("the path is absolute");
36    }
37    if path.split(['/', '\\']).any(|seg| seg == "..") {
38        return Some("the path contains a `..` segment");
39    }
40    None
41}
42
43/// `bynk.asset.unsafe_path` for `a`, when [`unsafe_asset_path_reason`]
44/// rejects its path. One constructor for the project pipeline
45/// (`symbols::build_unit_table`) and the single-file compile
46/// (`bynk-emit`'s `compile`), so the two report it identically.
47pub fn unsafe_path_error(a: &AssetDecl) -> Option<CompileError> {
48    let reason = unsafe_asset_path_reason(&a.path)?;
49    Some(
50        CompileError::new(
51            "bynk.asset.unsafe_path",
52            a.path_span,
53            format!("asset `{}` has an unsafe path: {reason}", a.name.name),
54        )
55        .with_note(
56            "an asset path is relative to the project root and must stay inside it; assets are public, so nothing outside the project may be named",
57        ),
58    )
59}
60
61/// `bynk.asset.outside_assets_dir` for `a`, when its path is safe but does
62/// not lie inside the project's assets folder
63/// ([`bynk_project::assets::PROJECT_ASSETS_DIR`]). Assets are public, so they
64/// come only from that folder, and a declaration can never publish `.env` or
65/// a source file by mistake (#1883, slice 1b).
66pub fn outside_assets_dir_error(a: &AssetDecl) -> Option<CompileError> {
67    if unsafe_asset_path_reason(&a.path).is_some()
68        || bynk_project::assets::is_in_assets_dir(&a.path)
69    {
70        return None;
71    }
72    let dir = bynk_project::assets::PROJECT_ASSETS_DIR;
73    Some(
74        CompileError::new(
75            "bynk.asset.outside_assets_dir",
76            a.path_span,
77            format!(
78                "asset `{}` must name a file in the project's `{dir}/` folder",
79                a.name.name
80            ),
81        )
82        .with_note(format!(
83            "assets are public, so they come only from `{dir}/`; move the file there and name it `{dir}/…`"
84        )),
85    )
86}
87
88/// The path rules for `a`, in order: [`unsafe_path_error`], then
89/// [`outside_assets_dir_error`] (at most one of the two applies).
90pub fn path_errors(a: &AssetDecl) -> Option<CompileError> {
91    unsafe_path_error(a).or_else(|| outside_assets_dir_error(a))
92}
93
94/// The URL path prefix every asset is served under. A handler route under it
95/// is reserved for assets (slice 2's check), and `_headers` gives everything
96/// under it an immutable cache lifetime.
97pub const SERVED_PREFIX: &str = "/_assets/";
98
99/// `bynk.asset.not_served` (a warning) for each asset among `items`, for a
100/// deployable Bundle build: a Bundle ships no Worker and so no files, yet each
101/// module still carries its asset's `.url`, which would 404 wherever the
102/// output is served. A warning, not an error, because a Bundle build is also
103/// how `bynkc test` compiles; the caller raises it only for a build without
104/// tests (review of #1904). A helper here keeps `bynk-emit`'s `project.rs`
105/// free of syntax-tree types (the `ast_importers` probe).
106pub fn bundle_not_served_warnings(items: &[CommonsItem]) -> Vec<CompileError> {
107    items
108        .iter()
109        .filter_map(|item| match item {
110            CommonsItem::Asset(a) => Some(
111                CompileError::new(
112                    "bynk.asset.not_served",
113                    a.span,
114                    format!(
115                        "asset `{}` is not served by a Bundle build: its `.url` names a file this build does not ship",
116                        a.name.name
117                    ),
118                )
119                .with_note(
120                    "assets are served beside a Worker; build with `--target workers` (as `bynk dev` and `bynk deploy` do)",
121                ),
122            ),
123            _ => None,
124        })
125        .collect()
126}
127
128/// The URL path the platform serves the asset file `source` (a normalised
129/// `assets/…` key) with content `bytes` at:
130/// `/_assets/<stem>.<hash>.<ext>`, where `<hash>` is the content's FNV-1a 64
131/// ([`crate::contract::fnv1a64_hex`]) and `<stem>`/`<ext>` are the file
132/// name's, split at its last `.` (no `.<ext>` when it has none).
133///
134/// Only the file name is kept, not its folders: the hash already tells two
135/// files apart, and the same bytes under the same name are one served file,
136/// however many declarations name them. Any character outside
137/// `[A-Za-z0-9._-]` becomes `-`, so `.url` never needs percent-encoding.
138/// Reproducible: it depends only on the name and the bytes.
139pub fn served_path(source: &str, bytes: &[u8]) -> String {
140    served_path_with_hash(source, &crate::contract::fnv1a64_hex(bytes))
141}
142
143/// [`served_path`] with the content hash supplied rather than computed — the
144/// one definition of the served name's shape, shared with the editor, which
145/// lists the `assets/` folder but never reads its bytes and so shows
146/// `served_path_with_hash(source, "<hash>")` (#1883, editor tooling).
147pub fn served_path_with_hash(source: &str, hash: &str) -> String {
148    let name = source.rsplit('/').next().unwrap_or(source);
149    let clean: String = name
150        .chars()
151        .map(|c| {
152            if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
153                c
154            } else {
155                '-'
156            }
157        })
158        .collect();
159    match clean.rfind('.') {
160        Some(dot) if dot > 0 => {
161            format!(
162                "{SERVED_PREFIX}{}.{hash}.{}",
163                &clean[..dot],
164                &clean[dot + 1..]
165            )
166        }
167        _ => format!("{SERVED_PREFIX}{clean}.{hash}"),
168    }
169}
170
171/// The `Content-Type` the platform serves `served` (a URL path) with, by its
172/// extension — the table measured against `wrangler dev` 4.107.0 (#1883,
173/// slice 3), so a suite fetching an asset (`svc.GET(style)`) sees what
174/// production sends. Text types carry `; charset=utf-8`. A file with no
175/// extension is served with no content type, here `""`; an extension the
176/// table does not know is `application/octet-stream`.
177pub fn content_type_for(served: &str) -> &'static str {
178    let name = served.rsplit('/').next().unwrap_or(served);
179    let Some((_, ext)) = name.rsplit_once('.').filter(|(stem, _)| !stem.is_empty()) else {
180        return "";
181    };
182    match ext.to_ascii_lowercase().as_str() {
183        "css" => "text/css; charset=utf-8",
184        "js" | "mjs" => "text/javascript; charset=utf-8",
185        "html" | "htm" => "text/html; charset=utf-8",
186        "txt" => "text/plain; charset=utf-8",
187        "csv" => "text/csv; charset=utf-8",
188        "md" => "text/markdown; charset=utf-8",
189        "json" | "map" => "application/json",
190        "webmanifest" => "application/manifest+json",
191        "xml" => "application/xml",
192        "svg" => "image/svg+xml",
193        "png" => "image/png",
194        "jpg" | "jpeg" => "image/jpeg",
195        "gif" => "image/gif",
196        "webp" => "image/webp",
197        "avif" => "image/avif",
198        "ico" => "image/vnd.microsoft.icon",
199        "woff" => "font/woff",
200        "woff2" => "font/woff2",
201        "ttf" => "font/ttf",
202        "otf" => "font/otf",
203        "pdf" => "application/pdf",
204        "wasm" => "application/wasm",
205        "mp4" => "video/mp4",
206        "webm" => "video/webm",
207        "mp3" => "audio/mpeg",
208        "wav" => "audio/wav",
209        "zip" => "application/zip",
210        _ => "application/octet-stream",
211    }
212}
213
214/// The key a declaration's served path is filed under
215/// ([`declared_served_paths`]): its normalised source path, plus its fixed
216/// URL path when it has one — so one file may be served both content-hashed
217/// and at a fixed path.
218pub fn asset_key(a: &AssetDecl) -> String {
219    let source = bynk_project::assets::normalise_asset_path(&a.path);
220    match &a.at {
221        Some((at, _)) => format!("{source} at {at}"),
222        None => source,
223    }
224}
225
226/// Every declared asset's served path, keyed by [`asset_key`] — what the
227/// emitter writes as each `.url`: its fixed path (`at "/favicon.ico"`,
228/// slice 2) or its content-hashed one ([`served_path`]). Only declarations
229/// whose file is in `files` appear (a missing one is
230/// `bynk.asset.missing_file`).
231pub fn declared_served_paths(
232    parsed: &[ParsedFile],
233    files: &BTreeMap<String, Vec<u8>>,
234) -> BTreeMap<String, String> {
235    let mut out = BTreeMap::new();
236    for pf in parsed {
237        for item in pf.items() {
238            let CommonsItem::Asset(a) = item else {
239                continue;
240            };
241            let source = bynk_project::assets::normalise_asset_path(&a.path);
242            if let Some(bytes) = files.get(&source) {
243                let served = match &a.at {
244                    Some((at, _)) => at.clone(),
245                    None => served_path(&source, bytes),
246                };
247                out.insert(asset_key(a), served);
248            }
249        }
250    }
251    out
252}
253
254/// The units reachable from `start` over `edges` (transitively), `start`
255/// included, in sorted order.
256fn closure<'a>(start: &'a str, edges: &[&'a HashMap<String, Vec<String>>]) -> BTreeSet<&'a str> {
257    let mut seen: BTreeSet<&str> = BTreeSet::new();
258    let mut stack: Vec<&str> = vec![start];
259    while let Some(unit) = stack.pop() {
260        if !seen.insert(unit) {
261            continue;
262        }
263        for map in edges {
264            for next in map.get(unit).into_iter().flatten() {
265                stack.push(next);
266            }
267        }
268    }
269    seen
270}
271
272/// The files a context's Worker ships, keyed by served path, each mapped to
273/// its normalised source path. Sorted and deduplicated by served path, so the
274/// output is reproducible.
275///
276/// - **Content-hashed assets** come from every unit the context reaches over
277///   `uses` **and** `consumes`, transitively. Through `uses`, a commons may
278///   return another commons' `.url`; through `consumes`, another context may
279///   return one of its own in a fragment this Worker serves (#1883, slice 2).
280///   Either way the URL resolves against this Worker, so it ships the file.
281///   Hashed names never collide, so the duplication costs only bytes.
282/// - **Fixed-path assets** (`at "/favicon.ico"`) come over `uses` only: they
283///   belong to the root of the Worker that declares them, and shipping a
284///   consumed context's fixed paths would collide with this one's own.
285pub fn deployed_assets(
286    context: &str,
287    unit_uses: &HashMap<String, Vec<String>>,
288    unit_consumes: &HashMap<String, Vec<String>>,
289    unit_tables: &HashMap<String, crate::symbols::UnitTable>,
290    served: &BTreeMap<String, String>,
291) -> BTreeMap<String, String> {
292    let hashed_units = closure(context, &[unit_uses, unit_consumes]);
293    let fixed_units = closure(context, &[unit_uses]);
294    let mut out = BTreeMap::new();
295    for unit in &hashed_units {
296        let Some(table) = unit_tables.get(*unit) else {
297            continue;
298        };
299        for decl in table.assets.values() {
300            if decl.at.is_some() && !fixed_units.contains(unit) {
301                continue;
302            }
303            if let Some(path) = served.get(&asset_key(decl)) {
304                out.insert(
305                    path.clone(),
306                    bynk_project::assets::normalise_asset_path(&decl.path),
307                );
308            }
309        }
310    }
311    out
312}
313
314/// Why `at` is not a valid fixed URL path for an asset, or `None` when it is.
315/// It must be an absolute URL path naming a file: starting with `/`, not
316/// ending in `/`, with no empty, `.` or `..` segment, and only
317/// `[A-Za-z0-9._~-]` between the slashes (so it needs no encoding). It may not
318/// lie under [`SERVED_PREFIX`] (the content-hashed files' space) or `/_bynk/`
319/// (Bynk's own dispatch), nor be one of the root files the platform reads as
320/// configuration (`/_headers`, `/_redirects`, `/.assetsignore`).
321pub fn at_path_reason(at: &str) -> Option<&'static str> {
322    let Some(rest) = at.strip_prefix('/') else {
323        return Some("it does not start with `/`");
324    };
325    if rest.is_empty() || rest.ends_with('/') {
326        return Some("it does not name a file (it ends with `/`)");
327    }
328    if rest
329        .split('/')
330        .any(|seg| seg.is_empty() || seg == "." || seg == "..")
331    {
332        return Some("it has an empty, `.` or `..` segment");
333    }
334    if !rest
335        .chars()
336        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '.' | '_' | '~' | '-'))
337    {
338        return Some("it has a character outside `[A-Za-z0-9._~-]`");
339    }
340    let first = rest.split('/').next().unwrap_or("");
341    if first == SERVED_PREFIX.trim_matches('/') {
342        return Some("it is under `/_assets/`, which content-hashed assets use");
343    }
344    if first == "_bynk" {
345        return Some("it is under `/_bynk/`, which Bynk's own dispatch uses");
346    }
347    // Review of #1906: at the root of a Worker's assets directory these names
348    // are configuration, not files — the platform reads `_headers` and
349    // `_redirects`, wrangler reads `.assetsignore` — and the compiler writes
350    // its own `_headers` there.
351    if matches!(rest, "_headers" | "_redirects" | ".assetsignore") {
352        return Some("the platform reads that file as configuration, not as an asset");
353    }
354    None
355}
356
357/// `bynk.asset.invalid_at_path` for `a`, when its `at` path fails
358/// [`at_path_reason`].
359pub fn at_path_error(a: &AssetDecl) -> Option<CompileError> {
360    let (at, span) = a.at.as_ref()?;
361    let reason = at_path_reason(at)?;
362    Some(
363        CompileError::new(
364            "bynk.asset.invalid_at_path",
365            *span,
366            format!("asset `{}` cannot be served at `{at}`: {reason}", a.name.name),
367        )
368        .with_note(
369            "a fixed asset path is a URL path naming one file, such as `/favicon.ico` or `/robots.txt`",
370        ),
371    )
372}
373
374/// Whether the HTTP route `pattern` (`/a/:id/b`) matches the URL path `path`:
375/// the same number of segments, each literal equal or a `:param`. Routes have
376/// no wildcards, so this is the whole matching rule.
377fn route_matches(pattern: &str, path: &str) -> bool {
378    let p: Vec<&str> = pattern.split('/').filter(|s| !s.is_empty()).collect();
379    let u: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
380    p.len() == u.len()
381        && p.iter()
382            .zip(&u)
383            .all(|(ps, us)| ps.starts_with(':') || ps == us)
384}
385
386/// #1883, slice 2 ([DECISION C]): fixed asset paths against each Worker's
387/// routes. For every context, over the assets its `uses` closure declares
388/// with an `at` path:
389///
390/// - two different files at one fixed path is `bynk.asset.path_conflict`;
391/// - a fixed path equal to one of the context's literal HTTP routes is
392///   `bynk.asset.shadows_route`: the platform answers that path before the
393///   Worker runs, for **every** method (a `POST` gets the platform's `405`,
394///   checked against `wrangler dev` 4.107.0), so the handler is unreachable;
395/// - a fixed path a parameterised route matches (`/:name`) is the warning
396///   `bynk.asset.may_shadow_route`: the route still serves every other path.
397///
398/// Content-hashed paths need no check here: handlers may not use the
399/// `/_assets/` prefix at all (`bynk.http.reserved_prefix`).
400pub fn phase_asset_routes(
401    parsed: &[ParsedFile],
402    groups: &BTreeMap<String, Vec<usize>>,
403    kinds: &BTreeMap<String, bynk_project::UnitKind>,
404    unit_uses: &HashMap<String, Vec<String>>,
405    errors: &mut ErrorSink,
406) {
407    use bynk_syntax::ast::HandlerKind;
408    // A commons used by two contexts would otherwise report its conflict
409    // twice; report each (file, span, code) once.
410    let mut reported: BTreeSet<(std::path::PathBuf, usize, &'static str)> = BTreeSet::new();
411    let mut push = |file: std::path::PathBuf, err: CompileError, errors: &mut ErrorSink| {
412        if reported.insert((file.clone(), err.span.start, err.category)) {
413            errors.push_for(Some(&file), err);
414        }
415    };
416    for (ctx, kind) in kinds {
417        if *kind != bynk_project::UnitKind::Context {
418            continue;
419        }
420        // Fixed paths this Worker serves: at -> source file.
421        let mut fixed: BTreeMap<String, String> = BTreeMap::new();
422        for unit in closure(ctx, &[unit_uses]) {
423            for &i in groups.get(unit).into_iter().flatten() {
424                for item in parsed[i].items() {
425                    let CommonsItem::Asset(a) = item else {
426                        continue;
427                    };
428                    let Some((at, span)) = &a.at else { continue };
429                    if path_errors(a).is_some() || at_path_reason(at).is_some() {
430                        continue;
431                    }
432                    let source = bynk_project::assets::normalise_asset_path(&a.path);
433                    // Review of #1906: a path that is a folder of another
434                    // (`/logo` and `/logo/icon.png`) cannot be written, since
435                    // one name would be both a file and a directory.
436                    let nested = fixed.keys().find(|other| {
437                        other.starts_with(&format!("{at}/")) || at.starts_with(&format!("{other}/"))
438                    });
439                    if let Some(other) = nested {
440                        push(
441                            parsed[i].identity_path(),
442                            CompileError::new(
443                                "bynk.asset.path_conflict",
444                                *span,
445                                format!(
446                                    "the Worker for `{ctx}` cannot serve files at both `{other}` and `{at}`: one would be a folder of the other"
447                                ),
448                            )
449                            .with_note("a fixed asset path names one file, so it cannot also be a folder; give one of them another path"),
450                            errors,
451                        );
452                        continue;
453                    }
454                    match fixed.get(at) {
455                        Some(prev) if *prev != source => push(
456                            parsed[i].identity_path(),
457                            CompileError::new(
458                                "bynk.asset.path_conflict",
459                                *span,
460                                format!(
461                                    "the Worker for `{ctx}` would serve two files at `{at}`: `{prev}` and `{source}`"
462                                ),
463                            )
464                            .with_note("each fixed asset path names one file; give one of them another path"),
465                            errors,
466                        ),
467                        Some(_) => {}
468                        None => {
469                            fixed.insert(at.clone(), source);
470                        }
471                    }
472                }
473            }
474        }
475        if fixed.is_empty() {
476            continue;
477        }
478        for &i in groups.get(ctx.as_str()).into_iter().flatten() {
479            for item in parsed[i].items() {
480                let CommonsItem::Service(svc) = item else {
481                    continue;
482                };
483                for h in &svc.handlers {
484                    let HandlerKind::Http { method, path } = &h.kind else {
485                        continue;
486                    };
487                    for (at, source) in &fixed {
488                        if !route_matches(path, at) {
489                            continue;
490                        }
491                        let literal = !path.split('/').any(|s| s.starts_with(':'));
492                        let err = if literal {
493                            CompileError::new(
494                                "bynk.asset.shadows_route",
495                                h.span,
496                                format!(
497                                    "`{} {path}` is unreachable: the asset `{source}` is served at `{at}`",
498                                    method.as_str()
499                                ),
500                            )
501                            .with_note(
502                                "the platform answers an asset's path before the Worker runs, for every method; move the asset or the route",
503                            )
504                        } else {
505                            CompileError::new(
506                                "bynk.asset.may_shadow_route",
507                                h.span,
508                                format!(
509                                    "`{} {path}` will not see requests for `{at}`: the asset `{source}` is served there",
510                                    method.as_str()
511                                ),
512                            )
513                            .with_note(
514                                "the platform answers an asset's path before the Worker runs; every other path still reaches this route",
515                            )
516                        };
517                        push(parsed[i].identity_path(), err, errors);
518                    }
519                }
520            }
521        }
522    }
523}
524
525/// `bynk.asset.missing_file` for each asset whose (otherwise valid) path names
526/// no file in `available` — the project's `assets/` folder as the caller read
527/// it (`bynk_project::assets`). `None` skips the check: nothing on disk was
528/// consulted (an in-memory check). A build passes `Some`, an empty set when it
529/// had no filesystem, so it never invents a URL for a file nobody read.
530///
531/// A path that already fails [`path_errors`] is not also reported missing.
532pub fn phase_asset_files(
533    parsed: &[ParsedFile],
534    available: Option<&BTreeSet<String>>,
535    errors: &mut ErrorSink,
536) {
537    let Some(available) = available else { return };
538    for pf in parsed {
539        for item in pf.items() {
540            let CommonsItem::Asset(a) = item else {
541                continue;
542            };
543            if path_errors(a).is_some() {
544                continue;
545            }
546            let key = bynk_project::assets::normalise_asset_path(&a.path);
547            if !available.contains(&key) {
548                errors.push_for(
549                    Some(&pf.identity_path()),
550                    CompileError::new(
551                        "bynk.asset.missing_file",
552                        a.path_span,
553                        format!("asset `{}` names `{key}`, which does not exist", a.name.name),
554                    )
555                    .with_note(
556                        "an asset must be a regular file in the project's `assets/` folder; symbolic links are not followed",
557                    ),
558                );
559            }
560        }
561    }
562}
563
564/// The bytes of every declared asset file in `folder`, keyed by normalised
565/// path — read through the folder's reader once each, and only for a file a
566/// declaration names, so a large file nothing declares is never read. A
567/// declaration whose path fails [`path_errors`] or names no listed file is
568/// skipped ([`phase_asset_files`] reports the latter); a listed file the
569/// reader fails on is `bynk.asset.unreadable_file`, and is left out.
570pub fn read_declared_files(
571    parsed: &[ParsedFile],
572    folder: &bynk_project::assets::AssetFolder,
573    errors: &mut ErrorSink,
574) -> BTreeMap<String, Vec<u8>> {
575    let mut out = BTreeMap::new();
576    let mut failed: BTreeSet<String> = BTreeSet::new();
577    for pf in parsed {
578        for item in pf.items() {
579            let CommonsItem::Asset(a) = item else {
580                continue;
581            };
582            if path_errors(a).is_some() {
583                continue;
584            }
585            let key = bynk_project::assets::normalise_asset_path(&a.path);
586            if !folder.files().contains(&key) || out.contains_key(&key) {
587                continue;
588            }
589            if failed.contains(&key) {
590                // Reported once per declaration, read once per file.
591                errors.push_for(Some(&pf.identity_path()), unreadable_file_error(a, &key));
592                continue;
593            }
594            match folder.read(&key) {
595                Ok(bytes) => {
596                    out.insert(key, bytes);
597                }
598                Err(e) => {
599                    errors.push_for(
600                        Some(&pf.identity_path()),
601                        unreadable_file_error(a, &key).with_note(format!("the read failed: {e}")),
602                    );
603                    failed.insert(key);
604                }
605            }
606        }
607    }
608    out
609}
610
611fn unreadable_file_error(a: &AssetDecl, key: &str) -> CompileError {
612    CompileError::new(
613        "bynk.asset.unreadable_file",
614        a.path_span,
615        format!(
616            "asset `{}` names `{key}`, which could not be read",
617            a.name.name
618        ),
619    )
620}
621
622/// `bynk.asset.needs_project` for `a`: a single-file build emits one module
623/// and no Worker, and has no project root to find `assets/` under, so it
624/// cannot ship a file. Checking a single file (`bynk check page.bynk`) is
625/// unaffected; building one that declares an asset is refused. Build the
626/// project instead.
627pub fn needs_project_error(a: &AssetDecl) -> CompileError {
628    CompileError::new(
629        "bynk.asset.needs_project",
630        a.span,
631        format!(
632            "asset `{}` cannot be built from a single file",
633            a.name.name
634        ),
635    )
636    .with_note(
637        "an asset is served beside a Worker, so it needs a project build (`bynk dev`, `bynkc compile <project dir>`) with the file in the project's `assets/` folder",
638    )
639}
640
641/// The name rule for asset `a`, given what its unit already declares under
642/// that name: an earlier asset is `bynk.asset.duplicate`; a free function or a
643/// type is `bynk.resolve.name_conflict` (an asset is a value, in the
644/// function namespace, and a type name in value position is an error). `None`
645/// when the name is free. One rule for the project pipeline
646/// (`symbols::build_unit_table`) and the single-file compile, which has no
647/// unit table (review of #1903).
648pub fn name_error(
649    a: &AssetDecl,
650    prev_asset: Option<&Ident>,
651    prev_fn: Option<&Ident>,
652    prev_type: Option<&Ident>,
653) -> Option<CompileError> {
654    let name = &a.name.name;
655    if let Some(prev) = prev_asset {
656        Some(
657            CompileError::new(
658                "bynk.asset.duplicate",
659                a.name.span,
660                format!("asset `{name}` is already declared"),
661            )
662            .with_label(prev.span, "previously declared here"),
663        )
664    } else if let Some(prev) = prev_fn {
665        Some(
666            CompileError::new(
667                "bynk.resolve.name_conflict",
668                a.name.span,
669                format!("asset `{name}` conflicts with a function of the same name"),
670            )
671            .with_label(prev.span, "function declared here"),
672        )
673    } else {
674        prev_type.map(|prev| {
675            CompileError::new(
676                "bynk.resolve.name_conflict",
677                a.name.span,
678                format!("asset `{name}` conflicts with a type of the same name"),
679            )
680            .with_label(prev.span, "type declared here")
681        })
682    }
683}
684
685/// The single-file compile's asset diagnostics, in declaration order: each
686/// asset's path check and name rule ([`name_error`], against the file's own
687/// functions, types and earlier assets), and, when `build` is set, its
688/// refusal ([`needs_project_error`]). Placement needs no check here: a
689/// single file is always a commons (an `adapter` is a parse error).
690pub fn single_file_asset_errors(items: &[CommonsItem], build: bool) -> Vec<CompileError> {
691    let mut fns: HashMap<&str, &Ident> = HashMap::new();
692    let mut types: HashMap<&str, &Ident> = HashMap::new();
693    for item in items {
694        match item {
695            CommonsItem::Fn(f) => {
696                if let FnName::Free(id) = &f.name {
697                    fns.entry(id.name.as_str()).or_insert(id);
698                }
699            }
700            CommonsItem::Type(t) => {
701                types.entry(t.name.name.as_str()).or_insert(&t.name);
702            }
703            CommonsItem::Event(e) => {
704                types.entry(e.name.name.as_str()).or_insert(&e.name);
705            }
706            _ => {}
707        }
708    }
709    let mut assets: HashMap<&str, &Ident> = HashMap::new();
710    let mut errors = Vec::new();
711    for item in items {
712        let CommonsItem::Asset(a) = item else {
713            continue;
714        };
715        errors.extend(path_errors(a));
716        errors.extend(at_path_error(a));
717        let key = a.name.name.as_str();
718        match name_error(
719            a,
720            assets.get(key).copied(),
721            fns.get(key).copied(),
722            types.get(key).copied(),
723        ) {
724            Some(err) => errors.push(err),
725            None => {
726                assets.insert(key, &a.name);
727            }
728        }
729        if build {
730            errors.push(needs_project_error(a));
731        }
732    }
733    errors
734}
735
736/// Every asset visible in unit `name`: its own, plus those of each commons it
737/// `uses` (one level, the same mixin rule `compose_unit_symbols` applies to
738/// types and functions). A local asset wins over an imported one of the same
739/// name; two used commons declaring the same name is
740/// `bynk.uses.name_conflict` (`phase_uses_name_conflicts`), so which of them
741/// is kept here is never observable in a program that checks.
742pub fn compose_unit_assets(
743    name: &str,
744    unit_info: &BTreeMap<String, UnitInfo>,
745) -> HashMap<String, AssetDecl> {
746    let Some(info) = unit_info.get(name) else {
747        return HashMap::new();
748    };
749    let mut combined = info.table.assets.clone();
750    for used in &info.uses {
751        let Some(used_info) = unit_info.get(used) else {
752            continue;
753        };
754        for (asset_name, decl) in &used_info.table.assets {
755            // #1925: a local declaration of any kind shadows the import.
756            if crate::symbols::local_value_kind(&info.table, asset_name).is_some() {
757                continue;
758            }
759            combined
760                .entry(asset_name.clone())
761                .or_insert_with(|| decl.clone());
762        }
763    }
764    combined
765}
766
767#[cfg(test)]
768mod tests {
769    use super::*;
770
771    /// The asset files these tests' declarations name, written into each
772    /// project beside its sources so the existence check passes (it reads
773    /// the `assets/` folder from disk, as the editor does).
774    const ASSET_FILES: &[(&str, &str)] = &[
775        ("assets/a.css", "a{}"),
776        ("assets/b.css", "b{}"),
777        ("assets/board.css", "body{}"),
778        ("assets/favicon.ico", "ico"),
779        ("assets/other.ico", "other"),
780    ];
781
782    fn analyse(files: &[(&str, &str)]) -> crate::testkit::Analysed {
783        let mut all = files.to_vec();
784        all.extend_from_slice(ASSET_FILES);
785        crate::testkit::analyse(&all)
786    }
787
788    /// #1883, slice 1b: a declared file must exist in `assets/`; a symlink
789    /// there does not count.
790    #[test]
791    fn a_declared_file_must_exist_in_the_assets_folder() {
792        let a = crate::testkit::analyse(&[(
793            "c.bynk",
794            "context c\n\nasset style = \"assets/missing.css\"\n",
795        )]);
796        a.assert_reports("bynk.asset.missing_file");
797
798        let a = analyse(&[(
799            "c.bynk",
800            "context c\n\nasset style = \"./assets/board.css\"\n",
801        )]);
802        a.assert_clean();
803    }
804
805    /// [DECISION G]: a commons owns its stylesheet, and a context that `uses`
806    /// it reads `.url` without naming the asset itself — in a free function
807    /// and in a handler body, which is checked against its own per-handler
808    /// view of the unit (`context_checks`).
809    #[test]
810    fn a_used_commons_asset_is_a_value_of_type_asset_with_a_string_url() {
811        let a = analyse(&[
812            (
813                "compat/page.bynk",
814                "commons compat.page\n\n---\nThe board's stylesheet.\n---\nasset style = \"assets/board.css\"\n\nfn link(a: Asset) -> String {\n  a.url\n}\n\nfn sheet() -> String {\n  style.url\n}\n",
815            ),
816            (
817                "compat/board.bynk",
818                "context compat.board\n\nuses compat.page\n\nfn head() -> String {\n  link(style)\n}\n\nservice web from http {\n  on GET(\"/\") () -> Effect[HttpResult[String]] by Visitor {\n    Ok(style.url)\n  }\n}\n",
819            ),
820        ]);
821        a.assert_clean();
822        assert_eq!(a.type_at("compat/page.bynk", "style.url"), "String");
823        assert_eq!(a.type_at("compat/page.bynk", "a.url"), "String");
824        assert_eq!(a.type_at("compat/board.bynk", "link(style)"), "String");
825        assert_eq!(a.type_at("compat/board.bynk", "style.url"), "String");
826    }
827
828    /// A local binding shadows an asset of the same name, as it shadows a
829    /// function; `asset` stays an ordinary identifier outside its
830    /// declaration position (a contextual keyword).
831    #[test]
832    fn a_local_shadows_an_asset_and_asset_is_still_an_identifier() {
833        let a = analyse(&[(
834            "c.bynk",
835            "context c\n\nasset style = \"assets/a.css\"\n\nfn f(style: Int) -> Int {\n  style + 1\n}\n\nfn g(asset: Int) -> Int {\n  asset\n}\n",
836        )]);
837        a.assert_clean();
838        assert_eq!(a.type_at("c.bynk", "style + 1"), "Int");
839    }
840
841    /// A unit's own asset wins over a used commons' asset of the same name;
842    /// only two *used* commons colliding is an error.
843    #[test]
844    fn a_local_asset_wins_over_a_used_one() {
845        let a = analyse(&[
846            (
847                "lib/a.bynk",
848                "commons lib.a\n\nasset style = \"assets/a.css\"\n",
849            ),
850            (
851                "app/web.bynk",
852                "context app.web\n\nuses lib.a\n\nasset style = \"assets/b.css\"\n\nfn f() -> String {\n  style.url\n}\n",
853            ),
854        ]);
855        a.assert_clean();
856    }
857
858    /// #1930: a method on an asset's `url`, written on the chain, is a method
859    /// on a `String` in a context too, not a call into a context named
860    /// `style.url`. A commons never reaches the cross-context check, since it
861    /// has no `consumes`.
862    #[test]
863    fn a_method_on_an_assets_url_is_not_a_cross_context_call() {
864        let a = analyse(&[(
865            "app/web.bynk",
866            "context app.web\n\nasset style = \"assets/a.css\"\n\nfn hashed() -> Bool {\n  style.url.startsWith(\"/_assets/\")\n}\n",
867        )]);
868        a.assert_clean();
869    }
870
871    /// Review of #1937: an asset named like a consumed context's root segment
872    /// does not hide that context, so a real cross-context call through it
873    /// still resolves.
874    #[test]
875    fn an_asset_named_like_a_consumed_prefix_keeps_the_call() {
876        let a = analyse(&[
877            (
878                "shop/payment.bynk",
879                "context shop.payment\n\nservice charge {\n  on call(n: Int) -> Effect[Int] {\n    Effect.pure(n)\n  }\n}\n",
880            ),
881            (
882                "shop/orders.bynk",
883                "context shop.orders\n\nconsumes shop.payment\n\nasset shop = \"assets/a.css\"\n\nservice place {\n  on call() -> Effect[Int] {\n    let n <- shop.payment.charge(1)\n    Effect.pure(n)\n  }\n}\n",
884            ),
885        ]);
886        a.assert_clean();
887    }
888
889    #[test]
890    fn two_used_commons_declaring_one_asset_name_conflict() {
891        let a = analyse(&[
892            (
893                "lib/a.bynk",
894                "commons lib.a\n\nasset style = \"assets/a.css\"\n",
895            ),
896            (
897                "lib/b.bynk",
898                "commons lib.b\n\nasset style = \"assets/b.css\"\n",
899            ),
900            (
901                "app/web.bynk",
902                "context app.web\n\nuses lib.a\nuses lib.b\n\nfn f() -> String {\n  style.url\n}\n",
903            ),
904        ]);
905        a.assert_reports("bynk.uses.name_conflict");
906    }
907
908    /// A unit split across files has one asset namespace: a second
909    /// declaration in a sibling file is a duplicate, reported once, and a
910    /// sibling's asset is visible without any `uses`.
911    #[test]
912    fn a_multi_file_unit_shares_one_asset_namespace() {
913        let dup = analyse(&[
914            ("c/a.bynk", "context c\n\nasset style = \"assets/a.css\"\n"),
915            ("c/b.bynk", "context c\n\nasset style = \"assets/b.css\"\n"),
916        ]);
917        assert_eq!(
918            dup.categories()
919                .iter()
920                .filter(|c| **c == "bynk.asset.duplicate")
921                .count(),
922            1,
923            "{}",
924            dup.render()
925        );
926        let shared = analyse(&[
927            ("c/a.bynk", "context c\n\nasset style = \"assets/a.css\"\n"),
928            (
929                "c/b.bynk",
930                "context c\n\nfn f() -> String {\n  style.url\n}\n",
931            ),
932        ]);
933        shared.assert_clean();
934    }
935
936    /// Review of #1903: a local asset must not hide a real conflict between
937    /// two used commons over a *type* (or function) name. An asset only wins
938    /// in value position; a type position still resolves through the
939    /// imported types, so the ambiguity remains and must be reported.
940    #[test]
941    fn a_local_asset_does_not_hide_a_two_import_type_conflict() {
942        let a = analyse(&[
943            ("lib/a.bynk", "commons lib.a\n\ntype Style = Int\n"),
944            ("lib/b.bynk", "commons lib.b\n\ntype Style = String\n"),
945            (
946                "app/web.bynk",
947                "context app.web\n\nuses lib.a\nuses lib.b\n\nasset Style = \"assets/a.css\"\n\ntype Page = { s: Style }\n",
948            ),
949        ]);
950        a.assert_reports("bynk.uses.name_conflict");
951    }
952
953    #[test]
954    fn an_asset_has_only_a_url_field() {
955        let a = analyse(&[(
956            "c.bynk",
957            "context c\n\nasset style = \"assets/a.css\"\n\nfn f() -> String {\n  style.path\n}\n",
958        )]);
959        a.assert_reports("bynk.types.unknown_field");
960    }
961
962    /// Placement, duplicates, name conflicts, path safety, the boundary rule
963    /// and the reserved type name, one project each.
964    #[test]
965    fn each_declaration_rule_reports_its_own_code() {
966        for (src, code) in [
967            (
968                "adapter c\n\nbinding \"./c.js\"\n\nasset style = \"assets/a.css\"\n",
969                "bynk.asset.outside_context_or_commons",
970            ),
971            (
972                "context c\n\nasset style = \"assets/a.css\"\nasset style = \"assets/b.css\"\n",
973                "bynk.asset.duplicate",
974            ),
975            (
976                "context c\n\nfn style() -> Int {\n  1\n}\n\nasset style = \"assets/a.css\"\n",
977                "bynk.resolve.name_conflict",
978            ),
979            (
980                "context c\n\ntype Style = Int\n\nasset Style = \"assets/a.css\"\n",
981                "bynk.resolve.name_conflict",
982            ),
983            (
984                "context c\n\nasset style = \"/etc/passwd\"\n",
985                "bynk.asset.unsafe_path",
986            ),
987            (
988                "context c\n\nasset style = \"../x.css\"\n",
989                "bynk.asset.unsafe_path",
990            ),
991            (
992                "context c\n\ntype Page = { style: Asset }\n",
993                "bynk.types.asset_at_boundary",
994            ),
995            (
996                "context c\n\ntype Asset = Int\n",
997                "bynk.resolve.reserved_builtin_type",
998            ),
999            (
1000                "context c\n\nasset env = \".env\"\n",
1001                "bynk.asset.outside_assets_dir",
1002            ),
1003            (
1004                "context c\n\nasset page = \"src/c.bynk\"\n",
1005                "bynk.asset.outside_assets_dir",
1006            ),
1007            (
1008                "context c\n\nagent Board {\n  key id: String\n\n  store style: Cell[Asset]\n\n  on call get() -> Effect[Int] {\n    Effect.pure(1)\n  }\n}\n",
1009                "bynk.types.asset_at_boundary",
1010            ),
1011        ] {
1012            let a = analyse(&[("c.bynk", src)]);
1013            a.assert_reports(code);
1014        }
1015    }
1016
1017    #[test]
1018    fn served_path_is_hashed_flat_and_url_safe() {
1019        let h = crate::contract::fnv1a64_hex(b"body{}");
1020        assert_eq!(
1021            served_path("assets/board.css", b"body{}"),
1022            format!("/_assets/board.{h}.css")
1023        );
1024        assert_eq!(
1025            served_path("assets/css/board.min.css", b"body{}"),
1026            format!("/_assets/board.min.{h}.css")
1027        );
1028        assert_eq!(
1029            served_path("assets/LICENSE", b"body{}"),
1030            format!("/_assets/LICENSE.{h}")
1031        );
1032        assert_eq!(
1033            served_path("assets/.nojekyll", b"body{}"),
1034            format!("/_assets/.nojekyll.{h}")
1035        );
1036        assert_eq!(
1037            served_path("assets/my file é.css", b"body{}"),
1038            format!("/_assets/my-file--.{h}.css")
1039        );
1040        assert_ne!(
1041            served_path("assets/board.css", b"a"),
1042            served_path("assets/board.css", b"b"),
1043            "a content change changes the URL"
1044        );
1045    }
1046
1047    /// A Worker ships the files of its whole `uses` closure, not just the
1048    /// first level: `ctx -> a -> b -> c`, and only `c` declares an asset.
1049    #[test]
1050    fn deployed_assets_follow_the_transitive_uses_closure() {
1051        fn decl(name: &str, path: &str) -> AssetDecl {
1052            AssetDecl {
1053                name: Ident {
1054                    name: name.to_string(),
1055                    span: Default::default(),
1056                },
1057                path: path.to_string(),
1058                path_span: Default::default(),
1059                at: None,
1060                documentation: None,
1061                span: Default::default(),
1062                trivia: Default::default(),
1063            }
1064        }
1065        let mut tables: HashMap<String, crate::symbols::UnitTable> = HashMap::new();
1066        for unit in ["ctx", "a", "b", "c", "unused"] {
1067            tables.insert(unit.to_string(), Default::default());
1068        }
1069        tables
1070            .get_mut("c")
1071            .unwrap()
1072            .assets
1073            .insert("style".into(), decl("style", "./assets/board.css"));
1074        tables
1075            .get_mut("unused")
1076            .unwrap()
1077            .assets
1078            .insert("logo".into(), decl("logo", "assets/logo.png"));
1079        let uses: HashMap<String, Vec<String>> =
1080            [("ctx", vec!["a"]), ("a", vec!["b"]), ("b", vec!["c", "a"])]
1081                .into_iter()
1082                .map(|(k, v)| (k.to_string(), v.into_iter().map(String::from).collect()))
1083                .collect();
1084        let served: BTreeMap<String, String> = [
1085            (
1086                "assets/board.css".to_string(),
1087                "/_assets/board.1.css".to_string(),
1088            ),
1089            (
1090                "assets/logo.png".to_string(),
1091                "/_assets/logo.2.png".to_string(),
1092            ),
1093        ]
1094        .into_iter()
1095        .collect();
1096        let deployed = deployed_assets("ctx", &uses, &HashMap::new(), &tables, &served);
1097        assert_eq!(
1098            deployed.into_iter().collect::<Vec<_>>(),
1099            vec![(
1100                "/_assets/board.1.css".to_string(),
1101                "assets/board.css".to_string()
1102            )]
1103        );
1104    }
1105
1106    #[test]
1107    fn at_path_reason_accepts_a_file_path_and_rejects_the_rest() {
1108        for ok in [
1109            "/favicon.ico",
1110            "/robots.txt",
1111            "/.well-known/security.txt",
1112            "/a/b-c_d~e.f",
1113        ] {
1114            assert_eq!(at_path_reason(ok), None, "{ok} is valid");
1115        }
1116        for bad in [
1117            "favicon.ico",
1118            "/",
1119            "/dir/",
1120            "//x",
1121            "/a/../b",
1122            "/./a",
1123            "/a b.txt",
1124            "/a?b",
1125            "/_assets/x.css",
1126            "/_assets",
1127            "/_bynk/x",
1128            // Review of #1906: files the platform reads as configuration.
1129            "/_headers",
1130            "/_redirects",
1131            "/.assetsignore",
1132        ] {
1133            assert!(at_path_reason(bad).is_some(), "{bad} is rejected");
1134        }
1135    }
1136
1137    #[test]
1138    fn route_matching_is_segment_wise_with_params() {
1139        assert!(route_matches("/favicon.ico", "/favicon.ico"));
1140        assert!(route_matches("/:page", "/favicon.ico"));
1141        assert!(route_matches("/a/:id", "/a/b"));
1142        assert!(!route_matches("/a/:id", "/a"));
1143        assert!(!route_matches("/:page", "/a/b"));
1144        assert!(!route_matches("/favicon.png", "/favicon.ico"));
1145    }
1146
1147    const HANDLER: &str = "service web from http {\n  on GET(\"ROUTE\") () -> Effect[HttpResult[String]] by Visitor {\n    Ok(\"x\")\n  }\n}\n";
1148
1149    fn context_with(asset: &str, route: &str) -> String {
1150        format!(
1151            "context c\n\n{asset}\n\n{}",
1152            HANDLER.replace("ROUTE", route)
1153        )
1154    }
1155
1156    /// #1883, slice 2: a fixed path against the Worker's own routes — an
1157    /// exact literal route is unreachable (an error, for every method); a
1158    /// parameterised one only loses that path (a warning).
1159    #[test]
1160    fn a_fixed_path_is_checked_against_the_workers_routes() {
1161        let fav = "asset favicon = \"assets/favicon.ico\" at \"/favicon.ico\"";
1162        analyse(&[("c.bynk", &context_with(fav, "/favicon.ico"))])
1163            .assert_reports("bynk.asset.shadows_route");
1164        let param = context_with(fav, "/:page").replace("() ->", "(page: String) ->");
1165        let a = analyse(&[("c.bynk", &param)]);
1166        a.assert_reports("bynk.asset.may_shadow_route");
1167        assert!(
1168            a.error_categories().is_empty(),
1169            "a warning only: {}",
1170            a.render()
1171        );
1172        analyse(&[("c.bynk", &context_with(fav, "/home"))]).assert_clean();
1173        // Content-hashed assets never meet a route: `/_assets/` is reserved.
1174        analyse(&[(
1175            "c.bynk",
1176            &context_with("asset style = \"assets/board.css\"", "/_assets/x.css"),
1177        )])
1178        .assert_reports("bynk.http.reserved_prefix");
1179    }
1180
1181    /// Two different files at one fixed path in one Worker conflict, whether
1182    /// declared together or reached through `uses`; the same file twice is
1183    /// fine. And an invalid `at` path is reported at the declaration.
1184    #[test]
1185    fn fixed_paths_conflict_and_validate() {
1186        let a = analyse(&[
1187            (
1188                "lib/a.bynk",
1189                "commons lib.a\n\nasset icon = \"assets/favicon.ico\" at \"/favicon.ico\"\n",
1190            ),
1191            (
1192                "app/web.bynk",
1193                "context app.web\n\nuses lib.a\n\nasset other = \"assets/other.ico\" at \"/favicon.ico\"\n",
1194            ),
1195        ]);
1196        a.assert_reports("bynk.asset.path_conflict");
1197        analyse(&[(
1198            "c.bynk",
1199            "context c\n\nasset a = \"assets/favicon.ico\" at \"/favicon.ico\"\nasset b = \"assets/favicon.ico\" at \"/favicon.ico\"\n",
1200        )])
1201        .assert_clean();
1202        analyse(&[(
1203            "c.bynk",
1204            "context c\n\nasset a = \"assets/favicon.ico\" at \"/_assets/favicon.ico\"\n",
1205        )])
1206        .assert_reports("bynk.asset.invalid_at_path");
1207        // Review of #1906: one path cannot be both a file and a folder.
1208        analyse(&[(
1209            "c.bynk",
1210            "context c\n\nasset logo = \"assets/favicon.ico\" at \"/logo\"\nasset icon = \"assets/other.ico\" at \"/logo/icon.ico\"\n",
1211        )])
1212        .assert_reports("bynk.asset.path_conflict");
1213    }
1214
1215    /// Hashed assets ship over `uses` and `consumes`; fixed ones over `uses`
1216    /// only, so a consumed context's root files stay its own.
1217    #[test]
1218    fn consumes_ships_hashed_assets_but_not_fixed_ones() {
1219        fn decl(name: &str, path: &str, at: Option<&str>) -> AssetDecl {
1220            AssetDecl {
1221                name: Ident {
1222                    name: name.to_string(),
1223                    span: Default::default(),
1224                },
1225                path: path.to_string(),
1226                path_span: Default::default(),
1227                at: at.map(|a| (a.to_string(), Default::default())),
1228                documentation: None,
1229                span: Default::default(),
1230                trivia: Default::default(),
1231            }
1232        }
1233        let mut tables: HashMap<String, crate::symbols::UnitTable> = HashMap::new();
1234        for unit in ["a", "b"] {
1235            tables.insert(unit.to_string(), Default::default());
1236        }
1237        let b = tables.get_mut("b").unwrap();
1238        b.assets
1239            .insert("badge".into(), decl("badge", "assets/badge.svg", None));
1240        b.assets.insert(
1241            "robots".into(),
1242            decl("robots", "assets/robots.txt", Some("/robots.txt")),
1243        );
1244        let consumes: HashMap<String, Vec<String>> = [("a".to_string(), vec!["b".to_string()])]
1245            .into_iter()
1246            .collect();
1247        let served: BTreeMap<String, String> = [
1248            (
1249                "assets/badge.svg".to_string(),
1250                "/_assets/badge.1.svg".to_string(),
1251            ),
1252            (
1253                "assets/robots.txt at /robots.txt".to_string(),
1254                "/robots.txt".to_string(),
1255            ),
1256        ]
1257        .into_iter()
1258        .collect();
1259        let a = deployed_assets("a", &HashMap::new(), &consumes, &tables, &served);
1260        assert_eq!(
1261            a.keys().cloned().collect::<Vec<_>>(),
1262            vec!["/_assets/badge.1.svg"]
1263        );
1264        let b = deployed_assets("b", &HashMap::new(), &consumes, &tables, &served);
1265        assert_eq!(
1266            b.keys().cloned().collect::<Vec<_>>(),
1267            vec!["/_assets/badge.1.svg", "/robots.txt"]
1268        );
1269    }
1270
1271    /// #1883, slice 3: a suite names its target's assets and fetches one with
1272    /// `svc.GET(asset)` — `GET` only, the asset alone, and no `by` (an asset
1273    /// is public). Anything else that is not a route string is still a bad
1274    /// address.
1275    #[test]
1276    fn a_suite_fetches_an_asset_by_get_alone() {
1277        let web = "context t.web\n\nasset style = \"assets/board.css\"\n\nservice api from http {\n  on GET(\"/\") () -> Effect[HttpResult[String]] by Visitor {\n    Ok(style.url)\n  }\n}\n";
1278        let suite = |body: &str| format!("suite t.web {{\n  case \"c\" {{\n{body}\n  }}\n}}\n");
1279        let run = |body: &str| analyse(&[("t/web.bynk", web), ("tests/web.bynk", &suite(body))]);
1280        run("    expect style.url == style.url\n    let r <- api.GET(style)\n    expect r is Raw(_, _)")
1281            .assert_clean();
1282        run("    let r <- api.POST(style)\n    expect r is Raw(_, _)")
1283            .assert_reports("bynk.test.asset_address_method");
1284        run("    let r <- api.GET(style, 1)\n    expect r is Raw(_, _)")
1285            .assert_reports("bynk.test.service_call_arity");
1286        run("    let r <- api.GET(style) by Visitor\n    expect r is Raw(_, _)")
1287            .assert_reports("bynk.test.principal_on_asset");
1288        run("    let s = \"/\"\n    let r <- api.GET(s)\n    expect r is Ok(_)")
1289            .assert_reports("bynk.test.service_bad_address");
1290    }
1291
1292    /// #1883, slice 3 (review of #1922): the content types measured against
1293    /// `wrangler dev` 4.107.0, pinned so the next wrangler bump has something
1294    /// to diff against, plus the edges the doc comment promises.
1295    #[test]
1296    fn content_type_for_matches_the_platform() {
1297        for (path, ct) in [
1298            ("/_assets/board.3f9c2a.css", "text/css; charset=utf-8"),
1299            ("/_assets/app.1.js", "text/javascript; charset=utf-8"),
1300            ("/_assets/app.1.mjs", "text/javascript; charset=utf-8"),
1301            ("/_assets/d.1.json", "application/json"),
1302            ("/index.html", "text/html; charset=utf-8"),
1303            ("/index.htm", "text/html; charset=utf-8"),
1304            ("/robots.txt", "text/plain; charset=utf-8"),
1305            ("/feed.xml", "application/xml"),
1306            ("/_assets/i.1.svg", "image/svg+xml"),
1307            ("/_assets/i.1.png", "image/png"),
1308            ("/_assets/i.1.jpg", "image/jpeg"),
1309            ("/_assets/i.1.jpeg", "image/jpeg"),
1310            ("/_assets/i.1.gif", "image/gif"),
1311            ("/_assets/i.1.webp", "image/webp"),
1312            ("/_assets/i.1.avif", "image/avif"),
1313            ("/favicon.ico", "image/vnd.microsoft.icon"),
1314            ("/_assets/f.1.woff", "font/woff"),
1315            ("/_assets/f.1.woff2", "font/woff2"),
1316            ("/_assets/f.1.ttf", "font/ttf"),
1317            ("/_assets/f.1.otf", "font/otf"),
1318            ("/doc.pdf", "application/pdf"),
1319            ("/_assets/m.1.wasm", "application/wasm"),
1320            ("/_assets/app.1.map", "application/json"),
1321            ("/data.csv", "text/csv; charset=utf-8"),
1322            ("/notes.md", "text/markdown; charset=utf-8"),
1323            ("/site.webmanifest", "application/manifest+json"),
1324            ("/v.mp4", "video/mp4"),
1325            ("/v.webm", "video/webm"),
1326            ("/a.mp3", "audio/mpeg"),
1327            ("/a.wav", "audio/wav"),
1328            ("/x.zip", "application/zip"),
1329            ("/x.bin", "application/octet-stream"),
1330            // Edges: an unknown extension, an uppercase one, no extension
1331            // (served with no content type), and a dotfile (no extension).
1332            ("/x.unknownext", "application/octet-stream"),
1333            ("/Logo.PNG", "image/png"),
1334            ("/_assets/README.1", "application/octet-stream"),
1335            ("/README", ""),
1336            ("/.hidden", ""),
1337        ] {
1338            assert_eq!(content_type_for(path), ct, "{path}");
1339        }
1340    }
1341
1342    /// Review of #1922: an asset is fetched through an HTTP service only, and a
1343    /// `system` suite names only its target's assets — the files the target's
1344    /// Worker ships — so nothing nameable can answer `NotFound`.
1345    #[test]
1346    fn asset_fetches_are_http_only_and_target_scoped() {
1347        let web = "context t.web\n\nasset style = \"assets/board.css\"\n\nservice jobs from queue(\"q\") {\n  on message(m: String) -> Effect[QueueResult] {\n    Ack\n  }\n}\n\nservice api from http {\n  on GET(\"/\") () -> Effect[HttpResult[String]] by Visitor {\n    Ok(style.url)\n  }\n}\n";
1348        let a = analyse(&[
1349            ("t/web.bynk", web),
1350            (
1351                "tests/web.bynk",
1352                "suite t.web {\n  case \"c\" {\n    let r <- jobs.GET(style)\n    expect r is Raw(_, _)\n  }\n}\n",
1353            ),
1354        ]);
1355        a.assert_reports("bynk.test.service_bad_address");
1356
1357        let lib = "commons t.lib\n\nasset other = \"assets/a.css\"\n";
1358        let a = analyse(&[
1359            ("t/web.bynk", web),
1360            ("t/lib.bynk", lib),
1361            (
1362                "tests/web_system.bynk",
1363                "suite t.web as system {\n  uses t.lib\n\n  case \"c\" {\n    let r <- api.GET(other)\n    expect r is Raw(_, _)\n  }\n}\n",
1364            ),
1365        ]);
1366        a.assert_reports("bynk.resolve.unknown_name");
1367    }
1368
1369    /// Review of #1923: a suite names exactly what the target's Worker ships
1370    /// (`deployed_assets`) — so a consumed context's content-hashed asset is
1371    /// nameable and fetchable, at both tiers, while its fixed path (served
1372    /// only by its own Worker) is not.
1373    #[test]
1374    fn a_consumed_contexts_hashed_asset_is_nameable_but_its_fixed_one_is_not() {
1375        let other = "context t.other\n\nasset badge = \"assets/a.css\"\nasset robots = \"assets/b.css\" at \"/robots.txt\"\n\nservice fragment {\n  on call() -> Effect[String] {\n    Effect.pure(badge.url)\n  }\n}\n";
1376        let web = "context t.web\n\nconsumes t.other\n\nservice api from http {\n  on GET(\"/\") () -> Effect[HttpResult[String]] by Visitor {\n    let f <- t.other.fragment()\n    Ok(f)\n  }\n}\n";
1377        for header in ["suite t.web", "suite t.web as system"] {
1378            let suite = |body: &str| format!("{header} {{\n  case \"c\" {{\n{body}\n  }}\n}}\n");
1379            analyse(&[
1380                ("t/other.bynk", other),
1381                ("t/web.bynk", web),
1382                (
1383                    "tests/web.bynk",
1384                    &suite("    let r <- api.GET(badge)\n    expect r is Raw(_, _)"),
1385                ),
1386            ])
1387            .assert_clean();
1388            analyse(&[
1389                ("t/other.bynk", other),
1390                ("t/web.bynk", web),
1391                (
1392                    "tests/web.bynk",
1393                    &suite("    let r <- api.GET(robots)\n    expect r is Raw(_, _)"),
1394                ),
1395            ])
1396            .assert_reports("bynk.resolve.unknown_name");
1397        }
1398    }
1399
1400    #[test]
1401    fn unsafe_asset_path_reason_accepts_relative_paths_and_rejects_escapes() {
1402        for ok in [
1403            "assets/board.css",
1404            "board.css",
1405            "assets/.well-known/security.txt",
1406            "a..b/c.css",
1407            "./assets/x.css",
1408        ] {
1409            assert_eq!(unsafe_asset_path_reason(ok), None, "{ok} is safe");
1410        }
1411        for (bad, why) in [
1412            ("", "the path is empty"),
1413            ("/etc/passwd", "the path is absolute"),
1414            ("\\x.css", "the path is absolute"),
1415            ("C:/x.css", "the path is absolute"),
1416            ("../secret.txt", "the path contains a `..` segment"),
1417            ("assets/../../x", "the path contains a `..` segment"),
1418            ("assets\\..\\x", "the path contains a `..` segment"),
1419            ("assets/..", "the path contains a `..` segment"),
1420        ] {
1421            assert_eq!(unsafe_asset_path_reason(bad), Some(why), "{bad}");
1422        }
1423    }
1424}