Skip to main content

shuyi_cli/export/
disclosure.rs

1//! The last step of every export: the final package read back, after every
2//! copy its target makes, and written down in `exposure-report.json` beside
3//! it: each file with its kind, its source, its size and its SHA-256, each
4//! file of each pack, the debug file, whatever else is beside it, and what
5//! the package was built from.
6//!
7//! A game whose `shuyi.ron` has a `disclosure` part ([`Disclosure`]) is held
8//! to it: a file of the game or of a pack that the part doesn't allow, a
9//! path it forbids, a canary of it anywhere (as UTF-8 or UTF-16, in what a
10//! pack unpacks to as well), or a file beside the package that this export
11//! didn't make refuses the export. A refused export leaves the report and
12//! no package, and the web build's folder emptied.
13//!
14//! The report names a canary by its place in the part, never by its text,
15//! so it can be read by people who shouldn't read the canaries.
16//!
17//! It finds what is written plainly. Bytes a game compresses or encrypts
18//! itself, what a compiler makes of a rule, and a `pdc` image or sound aren't
19//! searched (a Playdate's program is, in the ELF `pdc` packs it from). A
20//! package that passes holds no declared text, not "nothing unreleased".
21
22mod sha256;
23
24use std::collections::{BTreeMap, BTreeSet};
25use std::fmt::Write as _;
26use std::fs;
27use std::path::{Path, PathBuf};
28use std::process::Command;
29
30use schemars::JsonSchema;
31use serde::{Deserialize, Serialize};
32use shuyi_assets::pack::{self, Pack, PackError};
33
34use super::{ExportCx, Exporter, archive};
35use crate::contract::codes::{self, Code};
36use crate::contract::export::REPORT as EXPORT_REPORT;
37use crate::project::{self, Disclosure};
38
39/// The report's file, beside the package.
40pub const REPORT: &str = "exposure-report.json";
41
42/// The shortest canary, in bytes: a shorter one is found by chance in any
43/// program.
44pub const MIN_CANARY: usize = 6;
45
46/// The longest canary, in bytes.
47pub const MAX_CANARY: usize = 256;
48
49/// The biggest file read, 2 GiB: a bigger one refuses the export, since it
50/// can't be said to hold nothing.
51pub const MAX_FILE_BYTES: u64 = 2 << 30;
52
53/// The source of a file this export didn't make.
54const NOT_MADE: &str = "not made by this export";
55
56/// The findings an error names; the report has them all.
57const NAMED: usize = 20;
58
59/// What a file is to the package.
60#[derive(
61    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
62)]
63#[serde(rename_all = "kebab-case")]
64pub enum FileKind {
65    /// A built program.
66    Program,
67    /// A file the exporter writes itself.
68    Written,
69    /// A pack the exporter bakes.
70    Pack,
71    /// A file of the game the exporter copies: `disclosure.allowed_files`
72    /// names it.
73    LooseAsset,
74    /// A file in the package no exporter made or `allowed_files` names.
75    Undeclared,
76    /// The archive players download: its files are those of the package.
77    Archive,
78    /// The program with its debug sections, beside the package.
79    DebugCompanion,
80    /// A file beside the package that this export didn't make.
81    Beside,
82    /// What a shipped file was made from by a tool that compresses it, read
83    /// for the canaries it would hide.
84    BuildInput,
85}
86
87/// What an export wrote and where, as its exporter says: the rest of a
88/// package's files are the inspector's to find.
89#[derive(Debug, Clone, PartialEq, Eq)]
90#[non_exhaustive]
91pub struct Shipped {
92    /// The package's folder.
93    pub tree: PathBuf,
94    /// The archive made of it, if any.
95    pub archive: Option<PathBuf>,
96    /// What starts the name of each file in the archive.
97    pub prefix: String,
98    /// The debug file beside it, if any.
99    pub debug: Option<PathBuf>,
100    /// Each file the exporter knows of, by its path in the package, with its
101    /// kind and source.
102    pub sources: BTreeMap<String, (FileKind, String)>,
103    /// What shipped files were made from, as [`FileKind::BuildInput`]: each
104    /// one's name, what it is, and the file.
105    pub inputs: Vec<(String, String, PathBuf)>,
106    /// Other folders this export published the same bytes in, emptied with
107    /// the package.
108    pub public: Vec<PathBuf>,
109    /// The files a tool writes differently each build, by path, with what
110    /// starts the line it changes: their hashes leave that line out, and the
111    /// archive's hash is of its files, so the same input gives the same
112    /// report.
113    pub varying: BTreeMap<String, String>,
114}
115
116impl Shipped {
117    /// The package in `tree`, with `archive` made of it under `prefix`.
118    pub fn new(tree: PathBuf, archive: Option<PathBuf>, prefix: impl Into<String>) -> Self {
119        Self {
120            tree,
121            archive,
122            prefix: prefix.into(),
123            debug: None,
124            sources: BTreeMap::new(),
125            inputs: Vec::new(),
126            public: Vec::new(),
127            varying: BTreeMap::new(),
128        }
129    }
130
131    /// Says what the file at `path` in the package is and where it is from.
132    pub fn source(&mut self, path: impl Into<String>, kind: FileKind, source: impl Into<String>) {
133        self.sources.insert(path.into(), (kind, source.into()));
134    }
135}
136
137/// What the package was built from.
138#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
139#[serde(deny_unknown_fields)]
140pub struct BuildInputs {
141    /// The target.
142    pub target: String,
143    /// The target profile its check ran with.
144    pub profile: String,
145    /// The game's package.
146    pub package: String,
147    /// The program, or the library on the Playdate.
148    pub bin: String,
149    /// The game's version.
150    pub version: String,
151    /// Whether it was a release build.
152    pub release: bool,
153    /// The `cargo` arguments of each build.
154    pub builds: Vec<Vec<String>>,
155    /// Whether the builds keep the package's default features.
156    pub default_features: bool,
157    /// The features of the game's package they turn on, with what those turn
158    /// on.
159    pub features: Vec<String>,
160    /// `rustc -V`.
161    pub toolchain: String,
162    /// The SHA-256 of `shuyi.ron`, which holds the `disclosure` part.
163    pub project_file: Option<String>,
164    /// The SHA-256 of the workspace's `Cargo.lock`.
165    pub cargo_lock: Option<String>,
166}
167
168/// A file of the export.
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
170#[serde(deny_unknown_fields)]
171pub struct ExposedFile {
172    /// Its path: from the package's root for a file of the package, else from
173    /// the export's folder.
174    pub path: String,
175    /// What it is.
176    pub kind: FileKind,
177    /// Where it is from.
178    pub source: String,
179    /// Its size in bytes.
180    pub bytes: u64,
181    /// Its SHA-256.
182    pub sha256: String,
183    /// What the hash leaves out, if a tool writes part of the file differently
184    /// each build. The hash is then of what is left.
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub varies: Option<String>,
187}
188
189/// A file of a pack.
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
191#[serde(deny_unknown_fields)]
192pub struct ExposedEntry {
193    /// The file holding the pack.
194    pub file: String,
195    /// Where the pack starts in that file: above 0, the file embeds it.
196    pub offset: u64,
197    /// Its path in the pack.
198    pub path: String,
199    /// The kinds it was baked as: none for a file kept as its own bytes.
200    pub kinds: Vec<String>,
201    /// Its size in bytes, unpacked.
202    pub bytes: u64,
203    /// The SHA-256 of what it unpacks to.
204    pub sha256: String,
205}
206
207/// What a finding is.
208#[derive(
209    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
210)]
211#[serde(rename_all = "kebab-case")]
212pub enum FindingKind {
213    /// A path of `disclosure.forbidden_paths`, as a path or as a program's
214    /// text.
215    ForbiddenPath,
216    /// A canary of `disclosure.canaries`.
217    Canary,
218    /// A file `disclosure.allowed_files` doesn't name.
219    UndeclaredFile,
220    /// A file of a pack `disclosure.allowed_pack_entries` doesn't name.
221    UndeclaredPackEntry,
222    /// A file beside the package that this export didn't make.
223    Beside,
224    /// A pack that doesn't read, so its files can't be listed.
225    UnreadablePack,
226    /// The archive and the package's folder differ.
227    ArchiveDiffers,
228    /// A file over [`MAX_FILE_BYTES`].
229    TooBig,
230}
231
232/// One thing that refuses the export.
233#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
234#[serde(deny_unknown_fields)]
235pub struct Finding {
236    /// The file: its [`ExposedFile::path`].
237    pub path: String,
238    /// The path in the pack, if it is in one.
239    pub entry: Option<String>,
240    /// What it is.
241    pub kind: FindingKind,
242    /// Where the file is from.
243    pub source: String,
244    /// Which place of `disclosure.canaries`, for a canary. The report never
245    /// holds its text.
246    pub canary: Option<usize>,
247}
248
249/// `exposure-report.json`.
250#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
251#[serde(deny_unknown_fields)]
252pub struct ExposureReport {
253    /// Whether the package stands: false when a finding removed it.
254    pub ok: bool,
255    /// Whether `shuyi.ron` has a `disclosure` part to hold it to. Without one
256    /// nothing is refused, and there are no findings.
257    pub declared: bool,
258    /// What it was built from.
259    pub inputs: BuildInputs,
260    /// Every file of the package, the archive and what is beside them, by
261    /// path.
262    pub files: Vec<ExposedFile>,
263    /// Every file of every pack.
264    pub pack_entries: Vec<ExposedEntry>,
265    /// What refused it.
266    pub findings: Vec<Finding>,
267}
268
269/// What the part says that `shuyi.ron`'s reader can't: each line that can't
270/// match anything, or can't be meant.
271pub fn problems(part: &Disclosure) -> Vec<String> {
272    let mut problems = Vec::new();
273    let lists = [
274        ("allowed_files", &part.allowed_files),
275        ("allowed_pack_entries", &part.allowed_pack_entries),
276        ("forbidden_paths", &part.forbidden_paths),
277    ];
278    for (name, list) in lists {
279        let mut seen = BTreeSet::new();
280        for (at, path) in list.iter().enumerate() {
281            if let Some(why) = path_problem(path) {
282                problems.push(format!("disclosure.{name}[{at}], `{path}`: {why}"));
283            }
284            if !seen.insert(path) {
285                problems.push(format!("disclosure.{name}[{at}], `{path}`: listed twice"));
286            }
287        }
288    }
289    for (name, list) in &lists[..2] {
290        for path in list
291            .iter()
292            .filter(|path| part.forbidden_paths.contains(path))
293        {
294            problems.push(format!(
295                "`{path}` is in both disclosure.{name} and disclosure.forbidden_paths"
296            ));
297        }
298    }
299    let mut seen = BTreeSet::new();
300    for (at, canary) in part.canaries.iter().enumerate() {
301        // The texts stay out of what is printed, as out of the report.
302        if canary.len() < MIN_CANARY {
303            problems.push(format!(
304                "disclosure.canaries[{at}]: under {MIN_CANARY} bytes, which any program holds by \
305                 chance"
306            ));
307        }
308        if canary.len() > MAX_CANARY {
309            problems.push(format!(
310                "disclosure.canaries[{at}]: over {MAX_CANARY} bytes"
311            ));
312        }
313        if !seen.insert(canary) {
314            problems.push(format!("disclosure.canaries[{at}]: listed twice"));
315        }
316    }
317    problems
318}
319
320/// Why `path` isn't a path a package or a pack can hold, if so.
321fn path_problem(path: &str) -> Option<&'static str> {
322    if path.contains('\\') {
323        return Some("write it with `/`");
324    }
325    if path.contains(['*', '?', '[', ']']) {
326        return Some("a path matches whole, with no pattern");
327    }
328    if path.starts_with('/') || path.contains(':') {
329        return Some("write it from the package's root or the pack's");
330    }
331    if path.split('/').any(|part| matches!(part, "" | "." | "..")) {
332        return Some("it has an empty part, `.` or `..`");
333    }
334    None
335}
336
337/// Writes the report of what `shipped` holds, and refuses the export with
338/// what `part` finds there: on a refusal, or a package that can't be read,
339/// no package is left. The error is the problem code and the message.
340pub fn gate(
341    cx: &ExportCx,
342    exporter: &dyn Exporter,
343    part: Option<&Disclosure>,
344    shipped: &Shipped,
345) -> Result<(), (Code, String)> {
346    let checked = build_inputs(cx, exporter)
347        .and_then(|inputs| inspect(&cx.out, part, shipped, MAX_FILE_BYTES, inputs));
348    let report = match checked {
349        Ok(report) => report,
350        Err(error) => {
351            remove_package(&cx.out, shipped);
352            return Err((codes::PACKAGE, format!("inspecting the package: {error}")));
353        }
354    };
355    let json = serde_json::to_string_pretty(&report).unwrap_or_default();
356    let file = cx.out.join(REPORT);
357    if let Err(e) = fs::write(&file, format!("{json}\n")) {
358        remove_package(&cx.out, shipped);
359        return Err((codes::FILE, format!("write {}: {e}", file.display())));
360    }
361    if report.ok {
362        return Ok(());
363    }
364    remove_package(&cx.out, shipped);
365    Err((codes::DISCLOSURE, refusal(&report.findings)))
366}
367
368/// What the error of a refused export says.
369fn refusal(findings: &[Finding]) -> String {
370    let mut text = format!(
371        "the package breaks shuyi.ron's `disclosure`, and was removed ({REPORT} lists all it held):"
372    );
373    for finding in findings.iter().take(NAMED) {
374        let _ = write!(text, "\n  {}", said(finding));
375    }
376    if findings.len() > NAMED {
377        let _ = write!(text, "\n  and {} more, in {REPORT}", findings.len() - NAMED);
378    }
379    text
380}
381
382/// One finding, for people.
383fn said(finding: &Finding) -> String {
384    let place = match &finding.entry {
385        Some(entry) => format!("{entry} in the pack in {}", finding.path),
386        None => finding.path.clone(),
387    };
388    let what = match finding.kind {
389        FindingKind::ForbiddenPath => "is or names a path of disclosure.forbidden_paths".to_owned(),
390        FindingKind::Canary => format!(
391            "holds disclosure.canaries[{}]",
392            finding.canary.unwrap_or_default()
393        ),
394        FindingKind::UndeclaredFile => "isn't in disclosure.allowed_files".to_owned(),
395        FindingKind::UndeclaredPackEntry => "isn't in disclosure.allowed_pack_entries".to_owned(),
396        FindingKind::Beside => {
397            "is beside the package, and this export didn't make it: remove it".to_owned()
398        }
399        FindingKind::UnreadablePack => {
400            "holds a pack that doesn't read, so what it holds can't be listed".to_owned()
401        }
402        FindingKind::ArchiveDiffers => "differs between the archive and its folder".to_owned(),
403        FindingKind::TooBig => format!("is over {MAX_FILE_BYTES} bytes, too big to read"),
404    };
405    format!("{place} ({}) {what}", finding.source)
406}
407
408/// Removes what a refused export made, and the report of the last one that
409/// wasn't: the folder, its archive, the debug file and the same bytes
410/// anywhere else it published them. The folder of those stays, as a server
411/// may be running in it.
412fn remove_package(out: &Path, shipped: &Shipped) {
413    let _ = fs::remove_dir_all(&shipped.tree);
414    for file in shipped.archive.iter().chain(&shipped.debug) {
415        let _ = fs::remove_file(file);
416    }
417    let _ = fs::remove_file(out.join(EXPORT_REPORT));
418    for folder in &shipped.public {
419        for entry in fs::read_dir(folder).into_iter().flatten().flatten() {
420            let path = entry.path();
421            let _ = if path.is_dir() {
422                fs::remove_dir_all(&path)
423            } else {
424                fs::remove_file(&path)
425            };
426        }
427    }
428}
429
430/// What `exporter`'s package of `cx` is built from.
431fn build_inputs(cx: &ExportCx, exporter: &dyn Exporter) -> Result<BuildInputs, String> {
432    let builds = exporter.cargo_args(cx);
433    let default_features = !builds
434        .iter()
435        .flatten()
436        .any(|arg| arg == "--no-default-features");
437    let asked: Vec<&str> = builds
438        .iter()
439        .flat_map(|args| args.windows(2))
440        .filter(|pair| pair[0] == "--features" || pair[0] == "-F")
441        .flat_map(|pair| pair[1].split([',', ' ']))
442        .collect();
443    let (_, _, map) = super::metadata(&cx.root, &cx.package).map_err(|e| e.message())?;
444    let toolchain = Command::new("rustc")
445        .arg("-V")
446        .current_dir(&cx.root)
447        .output()
448        .ok()
449        .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_owned())
450        .unwrap_or_default();
451    let hash = |file: PathBuf| fs::read(file).ok().map(|bytes| sha256::hex(&bytes));
452    Ok(BuildInputs {
453        target: cx.target.clone(),
454        profile: cx.profile.clone(),
455        package: cx.package.clone(),
456        bin: cx.bin.clone(),
457        version: cx.version.clone(),
458        release: cx.release,
459        features: resolved_features(&map, default_features, &asked),
460        builds,
461        default_features,
462        toolchain,
463        project_file: hash(cx.root.join(project::FILE)),
464        cargo_lock: hash(cx.workspace.join("Cargo.lock")),
465    })
466}
467
468/// The features `asked`, and `default` if `default_features`, with what they
469/// turn on by `map` (a package's `[features]`), sorted. A dependency's
470/// (`dep:x`, `x/y`) is listed as it is written.
471fn resolved_features(
472    map: &BTreeMap<String, Vec<String>>,
473    default_features: bool,
474    asked: &[&str],
475) -> Vec<String> {
476    let mut found = BTreeSet::new();
477    let mut waiting: Vec<&str> = asked.to_vec();
478    if default_features && map.contains_key("default") {
479        waiting.push("default");
480    }
481    while let Some(feature) = waiting.pop() {
482        if found.insert(feature.to_owned()) {
483            let on = map.get(feature).into_iter().flatten();
484            waiting.extend(on.map(String::as_str));
485        }
486    }
487    found.into_iter().collect()
488}
489
490/// One text looked for in a file's bytes.
491struct Needle {
492    bytes: Vec<u8>,
493    kind: FindingKind,
494    canary: Option<usize>,
495    /// Whether it is looked for in programs alone: a path, which any text may
496    /// mention.
497    programs_only: bool,
498}
499
500/// The texts of `part`: each canary as UTF-8 and UTF-16, and each forbidden
501/// path with either slash, since a Windows build of `include_dir!` keeps a
502/// `\`.
503fn needles(part: Option<&Disclosure>) -> Vec<Needle> {
504    let Some(part) = part else {
505        return Vec::new();
506    };
507    let mut needles = Vec::new();
508    for (at, canary) in part.canaries.iter().enumerate() {
509        let wide = canary.encode_utf16().flat_map(u16::to_le_bytes).collect();
510        for bytes in [canary.as_bytes().to_vec(), wide] {
511            needles.push(Needle {
512                bytes,
513                kind: FindingKind::Canary,
514                canary: Some(at),
515                programs_only: false,
516            });
517        }
518    }
519    for path in &part.forbidden_paths {
520        for form in [path.clone(), path.replace('/', "\\")] {
521            needles.push(Needle {
522                bytes: form.into_bytes(),
523                kind: FindingKind::ForbiddenPath,
524                canary: None,
525                programs_only: true,
526            });
527        }
528    }
529    needles
530}
531
532/// Where `needle` starts in `bytes` at `from` or after.
533fn find(bytes: &[u8], needle: &[u8], from: usize) -> Option<usize> {
534    let (first, rest) = needle.split_first()?;
535    let mut at = from;
536    while let Some(tail) = bytes.get(at..) {
537        at += tail.iter().position(|byte| byte == first)?;
538        if bytes.get(at + 1..at + needle.len()) == Some(rest) {
539            return Some(at);
540        }
541        at += 1;
542    }
543    None
544}
545
546/// Where the report is being made.
547struct Inspector<'a> {
548    part: Option<&'a Disclosure>,
549    needles: Vec<Needle>,
550    files: Vec<ExposedFile>,
551    entries: Vec<ExposedEntry>,
552    findings: BTreeSet<Finding>,
553}
554
555/// What a file being read is.
556struct Seen<'a> {
557    path: &'a str,
558    kind: FileKind,
559    source: &'a str,
560    /// What starts the line its hash leaves out, if any.
561    varying: Option<&'a str>,
562}
563
564/// The SHA-256 of `bytes`, less the lines that start with `varying`, and what
565/// that leaves out.
566fn hash_of(bytes: &[u8], varying: Option<&str>) -> (String, Option<String>) {
567    let Some(start) = varying else {
568        return (sha256::hex(bytes), None);
569    };
570    let kept: Vec<&[u8]> = bytes
571        .split_inclusive(|&byte| byte == b'\n')
572        .filter(|line| !line.starts_with(start.as_bytes()))
573        .collect();
574    let said = format!("the line that starts `{start}`");
575    (sha256::hex(&kept.concat()), Some(said))
576}
577
578impl Inspector<'_> {
579    /// Adds a finding, if the game has a part to hold it to.
580    fn found(
581        &mut self,
582        seen: &Seen,
583        entry: Option<&str>,
584        kind: FindingKind,
585        canary: Option<usize>,
586    ) {
587        if self.part.is_some() {
588            self.findings.insert(Finding {
589                path: seen.path.to_owned(),
590                entry: entry.map(str::to_owned),
591                kind,
592                source: seen.source.to_owned(),
593                canary,
594            });
595        }
596    }
597
598    /// Looks for every needle in `bytes`, of `seen` or of its `entry`.
599    fn scan(&mut self, seen: &Seen, entry: Option<&str>, bytes: &[u8]) {
600        let program = entry.is_none()
601            && matches!(
602                seen.kind,
603                FileKind::Program
604                    | FileKind::DebugCompanion
605                    | FileKind::BuildInput
606                    | FileKind::Undeclared
607            );
608        let hits: Vec<(FindingKind, Option<usize>)> = self
609            .needles
610            .iter()
611            .filter(|needle| program || !needle.programs_only)
612            .filter(|needle| find(bytes, &needle.bytes, 0).is_some())
613            .map(|needle| (needle.kind, needle.canary))
614            .collect();
615        for (kind, canary) in hits {
616            self.found(seen, entry, kind, canary);
617        }
618    }
619
620    /// Lists the pack `pack`, at `offset` in `seen`, and reads its files.
621    fn pack(&mut self, seen: &Seen, offset: usize, pack: &Pack) {
622        for file in pack.files() {
623            self.entries.push(ExposedEntry {
624                file: seen.path.to_owned(),
625                offset: offset as u64,
626                path: file.path.clone(),
627                kinds: file.kinds.clone(),
628                bytes: file.bytes.len() as u64,
629                sha256: sha256::hex(&file.bytes),
630            });
631            let entry = Some(file.path.as_str());
632            if let Some(part) = self.part {
633                if part.forbidden_paths.contains(&file.path) {
634                    self.found(seen, entry, FindingKind::ForbiddenPath, None);
635                }
636                if !part.allowed_pack_entries.contains(&file.path) {
637                    self.found(seen, entry, FindingKind::UndeclaredPackEntry, None);
638                }
639            }
640            self.scan(seen, entry, &file.bytes);
641        }
642    }
643
644    /// Finds the packs in `bytes`, whole or embedded. An embedded pack of
645    /// another version can't be loaded, and the magic bytes alone are in any
646    /// program that reads packs: neither counts.
647    fn packs(&mut self, seen: &Seen, bytes: &[u8]) {
648        let mut from = 0;
649        while let Some(at) = find(bytes, &pack::MAGIC, from) {
650            from = at + pack::MAGIC.len();
651            let here = &bytes[at..];
652            let ours = here.get(8..12) == Some(&pack::VERSION.to_le_bytes()[..]);
653            if at > 0 && !ours {
654                continue;
655            }
656            match open_pack(here) {
657                Ok((length, pack)) => {
658                    self.pack(seen, at, &pack);
659                    from = at + length;
660                }
661                Err(_) => self.found(seen, None, FindingKind::UnreadablePack, None),
662            }
663        }
664    }
665
666    /// Reads one file.
667    fn file(&mut self, seen: &Seen, bytes: &[u8]) {
668        let (sha256, varies) = hash_of(bytes, seen.varying);
669        self.files.push(ExposedFile {
670            path: seen.path.to_owned(),
671            kind: seen.kind,
672            source: seen.source.to_owned(),
673            bytes: bytes.len() as u64,
674            sha256,
675            varies,
676        });
677        if let Some(part) = self.part {
678            if part.forbidden_paths.iter().any(|path| path == seen.path) {
679                self.found(seen, None, FindingKind::ForbiddenPath, None);
680            }
681            if seen.kind == FileKind::Undeclared {
682                self.found(seen, None, FindingKind::UndeclaredFile, None);
683            }
684            if seen.kind == FileKind::LooseAsset
685                && !part.allowed_files.iter().any(|f| f == seen.path)
686            {
687                self.found(seen, None, FindingKind::UndeclaredFile, None);
688            }
689            if seen.kind == FileKind::Beside {
690                self.found(seen, None, FindingKind::Beside, None);
691            }
692        }
693        self.scan(seen, None, bytes);
694        self.packs(seen, bytes);
695    }
696
697    /// Reads the file at `disk`, unless it is over `limit` bytes.
698    fn file_at(&mut self, seen: &Seen, disk: &Path, limit: u64) -> Result<Option<Vec<u8>>, String> {
699        let size = fs::metadata(disk)
700            .map_err(|e| format!("stat {}: {e}", disk.display()))?
701            .len();
702        if size > limit {
703            self.found(seen, None, FindingKind::TooBig, None);
704            return Ok(None);
705        }
706        let bytes = fs::read(disk).map_err(|e| format!("read {}: {e}", disk.display()))?;
707        self.file(seen, &bytes);
708        Ok(Some(bytes))
709    }
710
711    /// The kind and source of the package's file at `path`.
712    fn known(&self, shipped: &Shipped, path: &str) -> (FileKind, String) {
713        shipped.sources.get(path).cloned().unwrap_or_else(|| {
714            let allowed = self
715                .part
716                .is_some_and(|part| part.allowed_files.iter().any(|file| file == path));
717            let kind = if allowed {
718                FileKind::LooseAsset
719            } else {
720                FileKind::Undeclared
721            };
722            (kind, NOT_MADE.to_owned())
723        })
724    }
725}
726
727/// The pack at the start of `bytes` and how many bytes it takes.
728fn open_pack(bytes: &[u8]) -> Result<(usize, Pack<'_>), PackError> {
729    let length = pack::length(bytes)?;
730    let whole = &bytes[..length];
731    let stamp = pack::info(whole)?.stamp;
732    Ok((length, Pack::open(whole, &stamp)?))
733}
734
735/// The report of what `shipped`, in the export's folder `out`, holds.
736pub(crate) fn inspect(
737    out: &Path,
738    part: Option<&Disclosure>,
739    shipped: &Shipped,
740    limit: u64,
741    inputs: BuildInputs,
742) -> Result<ExposureReport, String> {
743    let mut inspector = Inspector {
744        part,
745        needles: needles(part),
746        files: Vec::new(),
747        entries: Vec::new(),
748        findings: BTreeSet::new(),
749    };
750    // The folder's files, and their hashes for the archive's.
751    let mut hashes = BTreeMap::new();
752    for entry in archive::entries(&shipped.tree, "", &|_| false)? {
753        let (kind, source) = inspector.known(shipped, &entry.name);
754        let seen = Seen {
755            path: &entry.name,
756            kind,
757            source: &source,
758            varying: shipped.varying.get(&entry.name).map(String::as_str),
759        };
760        if let Some(bytes) = inspector.file_at(&seen, &entry.path, limit)? {
761            hashes.insert(entry.name.clone(), sha256::hex(&bytes));
762        }
763    }
764    let mut known: BTreeSet<PathBuf> = [out.join(EXPORT_REPORT), out.join(REPORT)].into();
765    if let Some(file) = &shipped.archive {
766        known.insert(file.clone());
767        read_archive(&mut inspector, shipped, file, &hashes, limit)?;
768    }
769    if let Some(file) = &shipped.debug {
770        known.insert(file.clone());
771        let name = beside_name(out, file);
772        let seen = Seen {
773            path: &name,
774            kind: FileKind::DebugCompanion,
775            source: "the built program, with its debug sections",
776            varying: None,
777        };
778        inspector.file_at(&seen, file, limit)?;
779    }
780    for (name, source, file) in &shipped.inputs {
781        // Not every build has them all: what a build leaves out, it can't ship.
782        if file.is_file() {
783            let seen = Seen {
784                path: name,
785                kind: FileKind::BuildInput,
786                source,
787                varying: None,
788            };
789            inspector.file_at(&seen, file, limit)?;
790        }
791    }
792    for entry in archive::entries(out, "", &|_| false)? {
793        if !entry.path.starts_with(&shipped.tree) && !known.contains(&entry.path) {
794            let seen = Seen {
795                path: &entry.name,
796                kind: FileKind::Beside,
797                source: NOT_MADE,
798                varying: None,
799            };
800            inspector.file_at(&seen, &entry.path, limit)?;
801        }
802    }
803    let Inspector {
804        mut files,
805        mut entries,
806        findings,
807        ..
808    } = inspector;
809    files.sort_by(|a, b| (&a.path, a.kind).cmp(&(&b.path, b.kind)));
810    entries.sort_by(|a, b| (&a.file, a.offset, &a.path).cmp(&(&b.file, b.offset, &b.path)));
811    Ok(ExposureReport {
812        ok: findings.is_empty(),
813        declared: part.is_some(),
814        inputs,
815        files,
816        pack_entries: entries,
817        findings: findings.into_iter().collect(),
818    })
819}
820
821/// `file`'s name from the export's folder `out`, with `/`.
822fn beside_name(out: &Path, file: &Path) -> String {
823    file.strip_prefix(out)
824        .unwrap_or(file)
825        .to_string_lossy()
826        .replace('\\', "/")
827}
828
829/// Reads the archive at `file` against the folder's `hashes`: what players
830/// download is this, and a file only here gets all a file gets.
831fn read_archive(
832    inspector: &mut Inspector,
833    shipped: &Shipped,
834    file: &Path,
835    hashes: &BTreeMap<String, String>,
836    limit: u64,
837) -> Result<(), String> {
838    let name = file.file_name().unwrap_or_default().to_string_lossy();
839    let bytes = fs::read(file).map_err(|e| format!("read {}: {e}", file.display()))?;
840    let mut left: BTreeSet<&str> = hashes.keys().map(String::as_str).collect();
841    // What the archive's hash is of when a file in it varies.
842    let mut listing = String::new();
843    let mut unpacked = 0;
844    for (full, inner) in archive::read(file, limit)? {
845        let path = full.strip_prefix(&shipped.prefix).unwrap_or(&full);
846        left.remove(path);
847        let (kind, from) = inspector.known(shipped, path);
848        let inner_seen = Seen {
849            path,
850            kind,
851            source: &from,
852            varying: shipped.varying.get(path).map(String::as_str),
853        };
854        let _ = writeln!(listing, "{}  {path}", hash_of(&inner, inner_seen.varying).0);
855        unpacked += inner.len() as u64;
856        match hashes.get(path) {
857            Some(hash) if *hash == sha256::hex(&inner) => {}
858            Some(_) => {
859                inspector.found(&inner_seen, None, FindingKind::ArchiveDiffers, None);
860                inspector.scan(&inner_seen, None, &inner);
861                inspector.packs(&inner_seen, &inner);
862            }
863            None => {
864                inspector.found(&inner_seen, None, FindingKind::ArchiveDiffers, None);
865                inspector.file(&inner_seen, &inner);
866            }
867        }
868    }
869    for path in left {
870        let (kind, from) = inspector.known(shipped, path);
871        let seen = Seen {
872            path,
873            kind,
874            source: &from,
875            varying: None,
876        };
877        inspector.found(&seen, None, FindingKind::ArchiveDiffers, None);
878    }
879    // Its own bytes are compressed: only its size and hash are of use here.
880    let (size, sha256, varies) = if shipped.varying.is_empty() {
881        (bytes.len() as u64, sha256::hex(&bytes), None)
882    } else {
883        // Compressed, a changed line changes the size as well as the bytes.
884        let said = "its own bytes: the size is of its files unpacked, and the hash of a                     `<hash>  <path>` line for each";
885        let hash = sha256::hex(listing.as_bytes());
886        (unpacked, hash, Some(said.to_owned()))
887    };
888    inspector.files.push(ExposedFile {
889        path: name.into_owned(),
890        kind: FileKind::Archive,
891        source: "the package's folder, zipped".to_owned(),
892        bytes: size,
893        sha256,
894        varies,
895    });
896    Ok(())
897}
898
899#[cfg(test)]
900mod tests;