1use 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
21pub 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
43pub 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
61pub 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
88pub fn path_errors(a: &AssetDecl) -> Option<CompileError> {
91 unsafe_path_error(a).or_else(|| outside_assets_dir_error(a))
92}
93
94pub const SERVED_PREFIX: &str = "/_assets/";
98
99pub 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
128pub fn served_path(source: &str, bytes: &[u8]) -> String {
140 served_path_with_hash(source, &crate::contract::fnv1a64_hex(bytes))
141}
142
143pub 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
171pub 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
214pub 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
226pub 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
254fn 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
272pub 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
314pub 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 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
357pub 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
374fn 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
386pub 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 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 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 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
525pub 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
564pub 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 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
622pub 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
641pub 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
685pub 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
736pub 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 "/_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 #[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", ¶m)]);
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 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 #[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 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 #[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 #[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 #[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 ("/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 #[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 #[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}