Skip to main content

bynk_check/
embeds.rs

1//! #2387 (embedded text): the checker-side rules for `embed name = "path"`
2//! declarations — the path rules, the name rule, the composition of a unit's
3//! visible embeds through `uses` — mirroring [`crate::assets`] for the part an
4//! embed shares with an asset.
5//!
6//! An embed is a project file's text compiled into the code as a `String`
7//! constant. Unlike an asset it is never served, so it is private, and it
8//! comes from the project's `embeds/` folder instead of `assets/`.
9//!
10//! The checker stays disk-free: the folder reaches it from the caller as a
11//! [`bynk_project::assets::EmbedFolder`], and [`read_declared_embeds`] reads
12//! only the files a declaration names, applying the content rules
13//! ([`embedded_text`]) and the size caps.
14
15use std::collections::{BTreeMap, BTreeSet, HashMap};
16
17use bynk_syntax::ast::{CommonsItem, EmbedDecl, FnName, Ident};
18use bynk_syntax::error::CompileError;
19
20use crate::project_model::{ErrorSink, UnitInfo};
21use bynk_project::ParsedFile;
22
23/// `bynk.embed.unsafe_path` for `e`, when its path is empty, absolute or
24/// contains a `..` segment. The same rule as an asset's path
25/// ([`crate::assets::unsafe_asset_path_reason`]): the path is joined onto the
26/// project root to find the file, so nothing outside the project may be named.
27pub fn unsafe_path_error(e: &EmbedDecl) -> Option<CompileError> {
28    let reason = crate::assets::unsafe_asset_path_reason(&e.path)?;
29    Some(
30        CompileError::new(
31            "bynk.embed.unsafe_path",
32            e.path_span,
33            format!("embed `{}` has an unsafe path: {reason}", e.name.name),
34        )
35        .with_note("an embed path is relative to the project root and must stay inside it"),
36    )
37}
38
39/// `bynk.embed.outside_embeds_dir` for `e`, when its path is safe but does not
40/// lie inside the project's embeds folder
41/// ([`bynk_project::assets::PROJECT_EMBEDS_DIR`]). An embed is compiled into
42/// the deployed code, so it comes only from that folder, and a declaration can
43/// never pull `.env` or a source file into a bundle by mistake.
44pub fn outside_embeds_dir_error(e: &EmbedDecl) -> Option<CompileError> {
45    if crate::assets::unsafe_asset_path_reason(&e.path).is_some()
46        || bynk_project::assets::is_in_embeds_dir(&e.path)
47    {
48        return None;
49    }
50    let dir = bynk_project::assets::PROJECT_EMBEDS_DIR;
51    Some(
52        CompileError::new(
53            "bynk.embed.outside_embeds_dir",
54            e.path_span,
55            format!(
56                "embed `{}` must name a file in the project's `{dir}/` folder",
57                e.name.name
58            ),
59        )
60        .with_note(format!(
61            "an embed is compiled into the deployed code, so it comes only from `{dir}/`; move the file there and name it `{dir}/…`"
62        )),
63    )
64}
65
66/// The path rules for `e`, in order: [`unsafe_path_error`], then
67/// [`outside_embeds_dir_error`] (at most one of the two applies).
68pub fn path_errors(e: &EmbedDecl) -> Option<CompileError> {
69    unsafe_path_error(e).or_else(|| outside_embeds_dir_error(e))
70}
71
72/// `bynk.embed.outside_context_or_commons` for `e`.
73pub fn placement_error(e: &EmbedDecl) -> CompileError {
74    CompileError::new(
75        "bynk.embed.outside_context_or_commons",
76        e.span,
77        "`embed` declarations are only allowed inside a context or a commons",
78    )
79    .with_note(
80        "an embed is compiled into the Worker of each context that declares it or `uses` a commons that does",
81    )
82}
83
84/// The largest embedded file, in bytes. The text lands in the Worker bundle
85/// and counts against the platform's script size limits, so an embed is for
86/// markup and prose, not data.
87pub const MAX_EMBED_BYTES: usize = 256 * 1024;
88
89/// The most bytes a project's declared embeds may total (each file counted
90/// once, however many declarations name it).
91pub const MAX_EMBEDS_TOTAL_BYTES: usize = 1024 * 1024;
92
93/// `bynk.embed.needs_project` for `e`: a single-file build emits one module
94/// and has no project root to find `embeds/` under, so it cannot read the
95/// file. Checking a single file is unaffected; building one that declares an
96/// embed is refused. Build the project instead.
97pub fn needs_project_error(e: &EmbedDecl) -> CompileError {
98    CompileError::new(
99        "bynk.embed.needs_project",
100        e.span,
101        format!(
102            "embed `{}` cannot be built from a single file",
103            e.name.name
104        ),
105    )
106    .with_note(
107        "an embed reads a file from the project's `embeds/` folder, so it needs a project build (`bynk dev`, `bynkc compile <project dir>`)",
108    )
109}
110
111/// The text a file's bytes embed as, or why they cannot be: valid UTF-8
112/// (`bynk.embed.invalid_encoding`) with no NUL byte (`bynk.embed.binary_content`).
113/// A leading byte-order mark is dropped and `\r\n` and a bare `\r` become
114/// `\n`, so the same source builds to the same constant whatever a checkout's
115/// line-ending settings do. Everything else, including a trailing newline, is
116/// kept as is.
117pub fn embedded_text(bytes: &[u8]) -> Result<String, &'static str> {
118    let text = std::str::from_utf8(bytes).map_err(|_| "bynk.embed.invalid_encoding")?;
119    if text.contains('\0') {
120        return Err("bynk.embed.binary_content");
121    }
122    let text = text.strip_prefix('\u{feff}').unwrap_or(text);
123    Ok(text.replace("\r\n", "\n").replace('\r', "\n"))
124}
125
126/// `bynk.embed.missing_file` for each embed whose (otherwise valid) path names
127/// no file in `available` — the project's `embeds/` folder as the caller read
128/// it. `None` skips the check: nothing on disk was consulted (an in-memory
129/// check). A build passes `Some`, an empty set when it had no filesystem, so
130/// it never invents text for a file nobody read.
131///
132/// A path that already fails [`path_errors`] is not also reported missing.
133pub fn phase_embed_files(
134    parsed: &[ParsedFile],
135    available: Option<&BTreeSet<String>>,
136    errors: &mut ErrorSink,
137) {
138    let Some(available) = available else { return };
139    for pf in parsed {
140        for item in pf.items() {
141            let CommonsItem::Embed(e) = item else {
142                continue;
143            };
144            if path_errors(e).is_some() {
145                continue;
146            }
147            let key = bynk_project::assets::normalise_asset_path(&e.path);
148            if !available.contains(&key) {
149                errors.push_for(
150                    Some(&pf.identity_path()),
151                    CompileError::new(
152                        "bynk.embed.missing_file",
153                        e.path_span,
154                        format!("embed `{}` names `{key}`, which does not exist", e.name.name),
155                    )
156                    .with_note(
157                        "an embed must be a regular file in the project's `embeds/` folder; symbolic links are not followed",
158                    ),
159                );
160            }
161        }
162    }
163}
164
165/// The text of every declared embed file in `folder`, keyed by normalised
166/// path — read through the folder's reader once each, and only for a file a
167/// declaration names. A declaration whose path fails [`path_errors`] or names
168/// no listed file is skipped ([`phase_embed_files`] reports the latter). A
169/// listed file the reader fails on is `bynk.embed.unreadable_file`; one that is
170/// not text is `bynk.embed.invalid_encoding` or `bynk.embed.binary_content`; one over
171/// [`MAX_EMBED_BYTES`], or that takes the declared files past
172/// [`MAX_EMBEDS_TOTAL_BYTES`], is `bynk.embed.too_large`. Each is reported on
173/// every declaration that names the file, and the file is left out.
174pub fn read_declared_embeds(
175    parsed: &[ParsedFile],
176    folder: &bynk_project::assets::EmbedFolder,
177    errors: &mut ErrorSink,
178) -> BTreeMap<String, String> {
179    let mut out: BTreeMap<String, String> = BTreeMap::new();
180    // A file already judged and refused: its error, rebuilt for each further
181    // declaration that names it.
182    let mut refused: BTreeMap<String, (&'static str, String)> = BTreeMap::new();
183    let mut total = 0usize;
184    for pf in parsed {
185        for item in pf.items() {
186            let CommonsItem::Embed(e) = item else {
187                continue;
188            };
189            if path_errors(e).is_some() {
190                continue;
191            }
192            let key = bynk_project::assets::normalise_asset_path(&e.path);
193            if !folder.files().contains(&key) || out.contains_key(&key) {
194                continue;
195            }
196            if !refused.contains_key(&key) {
197                match read_one(folder, &key, total) {
198                    Ok((text, raw_len)) => {
199                        total += raw_len;
200                        out.insert(key.clone(), text);
201                        continue;
202                    }
203                    Err(refusal) => {
204                        refused.insert(key.clone(), refusal);
205                    }
206                }
207            }
208            let (code, detail) = &refused[&key];
209            errors.push_for(
210                Some(&pf.identity_path()),
211                refusal_error(code, e, &key, detail),
212            );
213        }
214    }
215    out
216}
217
218/// One file's text, or the diagnostic code and detail that refuse it.
219/// `total` is what the project's embeds already come to.
220fn read_one(
221    folder: &bynk_project::assets::EmbedFolder,
222    key: &str,
223    total: usize,
224) -> Result<(String, usize), (&'static str, String)> {
225    let bytes = folder.read(key).map_err(|e| {
226        (
227            "bynk.embed.unreadable_file",
228            format!("the read failed: {e}"),
229        )
230    })?;
231    if bytes.len() > MAX_EMBED_BYTES {
232        return Err((
233            "bynk.embed.too_large",
234            format!(
235                "it is {} bytes, over the {MAX_EMBED_BYTES}-byte limit for one embedded file",
236                bytes.len()
237            ),
238        ));
239    }
240    if total + bytes.len() > MAX_EMBEDS_TOTAL_BYTES {
241        return Err((
242            "bynk.embed.too_large",
243            format!(
244                "with it the project's embedded files would pass {MAX_EMBEDS_TOTAL_BYTES} bytes in all"
245            ),
246        ));
247    }
248    embedded_text(&bytes)
249        .map(|text| (text, bytes.len()))
250        .map_err(|code| {
251            let detail = if code == "bynk.embed.invalid_encoding" {
252                "it is not valid UTF-8"
253            } else {
254                "it contains a NUL byte, so it is a binary file"
255            };
256            (code, detail.to_string())
257        })
258}
259
260fn refusal_error(code: &'static str, e: &EmbedDecl, key: &str, detail: &str) -> CompileError {
261    let (what, note) = match code {
262        "bynk.embed.unreadable_file" => ("could not be read", None),
263        "bynk.embed.invalid_encoding" => (
264            "is not UTF-8 text",
265            Some(
266                "an embed is compiled into the code as a `String`; serve a binary file with `asset` instead",
267            ),
268        ),
269        "bynk.embed.binary_content" => (
270            "is not text",
271            Some(
272                "an embed is compiled into the code as a `String`; serve a binary file with `asset` instead",
273            ),
274        ),
275        _ => (
276            "is too large to embed",
277            Some(
278                "an embed is for markup and prose; the text lands in the Worker bundle, so keep large data out of it",
279            ),
280        ),
281    };
282    let mut err = CompileError::new(
283        code,
284        e.path_span,
285        format!("embed `{}` names `{key}`, which {what}", e.name.name),
286    )
287    .with_note(detail.to_string());
288    if let Some(note) = note {
289        err = err.with_note(note);
290    }
291    err
292}
293
294/// The name rule for embed `e`, given what its unit already declares under
295/// that name: an earlier embed is `bynk.embed.duplicate`; an asset, a free
296/// function or a type is `bynk.resolve.name_conflict` (an embed is a value, in
297/// the function namespace, and a type name in value position is an error).
298/// `None` when the name is free. One rule for the project pipeline
299/// (`symbols::build_unit_table`) and the single-file compile, which has no
300/// unit table.
301pub fn name_error(
302    e: &EmbedDecl,
303    prev_embed: Option<&Ident>,
304    prev_asset: Option<&Ident>,
305    prev_fn: Option<&Ident>,
306    prev_type: Option<&Ident>,
307) -> Option<CompileError> {
308    let name = &e.name.name;
309    if let Some(prev) = prev_embed {
310        return Some(
311            CompileError::new(
312                "bynk.embed.duplicate",
313                e.name.span,
314                format!("embed `{name}` is already declared"),
315            )
316            .with_label(prev.span, "previously declared here"),
317        );
318    }
319    let (kind, prev) = prev_asset
320        .map(|p| ("an asset", p))
321        .or_else(|| prev_fn.map(|p| ("a function", p)))
322        .or_else(|| prev_type.map(|p| ("a type", p)))?;
323    Some(
324        CompileError::new(
325            "bynk.resolve.name_conflict",
326            e.name.span,
327            format!("embed `{name}` conflicts with {kind} of the same name"),
328        )
329        .with_label(prev.span, "declared here"),
330    )
331}
332
333/// The single-file compile's embed diagnostics, in declaration order: each
334/// embed's path check and name rule ([`name_error`], against the file's own
335/// assets, functions, types and earlier embeds), and, when `build` is set, its
336/// refusal ([`needs_project_error`]). Placement needs no check here: a
337/// single file is always a commons (an `adapter` is a parse error).
338pub fn single_file_embed_errors(items: &[CommonsItem], build: bool) -> Vec<CompileError> {
339    let mut fns: HashMap<&str, &Ident> = HashMap::new();
340    let mut types: HashMap<&str, &Ident> = HashMap::new();
341    let mut assets: HashMap<&str, &Ident> = HashMap::new();
342    for item in items {
343        match item {
344            CommonsItem::Fn(f) => {
345                if let FnName::Free(id) = &f.name {
346                    fns.entry(id.name.as_str()).or_insert(id);
347                }
348            }
349            CommonsItem::Type(t) => {
350                types.entry(t.name.name.as_str()).or_insert(&t.name);
351            }
352            CommonsItem::Event(ev) => {
353                types.entry(ev.name.name.as_str()).or_insert(&ev.name);
354            }
355            CommonsItem::Asset(a) => {
356                assets.entry(a.name.name.as_str()).or_insert(&a.name);
357            }
358            _ => {}
359        }
360    }
361    let mut embeds: HashMap<&str, &Ident> = HashMap::new();
362    let mut errors = Vec::new();
363    for item in items {
364        let CommonsItem::Embed(e) = item else {
365            continue;
366        };
367        errors.extend(path_errors(e));
368        let key = e.name.name.as_str();
369        match name_error(
370            e,
371            embeds.get(key).copied(),
372            assets.get(key).copied(),
373            fns.get(key).copied(),
374            types.get(key).copied(),
375        ) {
376            Some(err) => errors.push(err),
377            None => {
378                embeds.insert(key, &e.name);
379            }
380        }
381        if build {
382            errors.push(needs_project_error(e));
383        }
384    }
385    errors
386}
387
388/// Every embed visible in unit `name`: its own, plus those of each commons it
389/// `uses` (one level, the same mixin rule [`crate::assets::compose_unit_assets`]
390/// applies to assets). A local declaration of any kind wins over an imported
391/// embed of the same name; two used commons declaring the same name is
392/// `bynk.uses.name_conflict` (`phase_uses_name_conflicts`), so which of them is
393/// kept here is never observable in a program that checks.
394pub fn compose_unit_embeds(
395    name: &str,
396    unit_info: &BTreeMap<String, UnitInfo>,
397) -> HashMap<String, EmbedDecl> {
398    let Some(info) = unit_info.get(name) else {
399        return HashMap::new();
400    };
401    let mut combined = info.table.embeds.clone();
402    for used in &info.uses {
403        let Some(used_info) = unit_info.get(used) else {
404            continue;
405        };
406        for (embed_name, decl) in &used_info.table.embeds {
407            if crate::symbols::local_value_kind(&info.table, embed_name).is_some() {
408                continue;
409            }
410            combined
411                .entry(embed_name.clone())
412                .or_insert_with(|| decl.clone());
413        }
414    }
415    combined
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    #[test]
423    fn text_is_utf8_without_a_bom_or_carriage_returns() {
424        assert_eq!(embedded_text(b"a\nb\n").unwrap(), "a\nb\n");
425        // A leading byte-order mark is dropped; one anywhere else is text.
426        assert_eq!(embedded_text(b"\xef\xbb\xbfhi").unwrap(), "hi");
427        assert_eq!(
428            embedded_text("a\u{feff}b".as_bytes()).unwrap(),
429            "a\u{feff}b"
430        );
431        // CRLF and a bare CR both become LF; a trailing newline is kept.
432        assert_eq!(embedded_text(b"a\r\nb\rc\r\n").unwrap(), "a\nb\nc\n");
433        // An empty file is the empty string.
434        assert_eq!(embedded_text(b"").unwrap(), "");
435        assert_eq!(embedded_text("é\u{2028}".as_bytes()).unwrap(), "é\u{2028}");
436    }
437
438    #[test]
439    fn non_text_is_refused_with_its_code() {
440        assert_eq!(
441            embedded_text(b"\xff\xfe"),
442            Err("bynk.embed.invalid_encoding")
443        );
444        // Truncated multi-byte sequence.
445        assert_eq!(
446            embedded_text(b"\xe2\x82"),
447            Err("bynk.embed.invalid_encoding")
448        );
449        assert_eq!(embedded_text(b"a\0b"), Err("bynk.embed.binary_content"));
450        // Invalid UTF-8 is judged before NUL.
451        assert_eq!(embedded_text(b"\xff\0"), Err("bynk.embed.invalid_encoding"));
452    }
453}