1mod 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
39pub const REPORT: &str = "exposure-report.json";
41
42pub const MIN_CANARY: usize = 6;
45
46pub const MAX_CANARY: usize = 256;
48
49pub const MAX_FILE_BYTES: u64 = 2 << 30;
52
53const NOT_MADE: &str = "not made by this export";
55
56const NAMED: usize = 20;
58
59#[derive(
61 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
62)]
63#[serde(rename_all = "kebab-case")]
64pub enum FileKind {
65 Program,
67 Written,
69 Pack,
71 LooseAsset,
74 Undeclared,
76 Archive,
78 DebugCompanion,
80 Beside,
82 BuildInput,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
90#[non_exhaustive]
91pub struct Shipped {
92 pub tree: PathBuf,
94 pub archive: Option<PathBuf>,
96 pub prefix: String,
98 pub debug: Option<PathBuf>,
100 pub sources: BTreeMap<String, (FileKind, String)>,
103 pub inputs: Vec<(String, String, PathBuf)>,
106 pub public: Vec<PathBuf>,
109 pub varying: BTreeMap<String, String>,
114}
115
116impl Shipped {
117 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 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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
139#[serde(deny_unknown_fields)]
140pub struct BuildInputs {
141 pub target: String,
143 pub profile: String,
145 pub package: String,
147 pub bin: String,
149 pub version: String,
151 pub release: bool,
153 pub builds: Vec<Vec<String>>,
155 pub default_features: bool,
157 pub features: Vec<String>,
160 pub toolchain: String,
162 pub project_file: Option<String>,
164 pub cargo_lock: Option<String>,
166}
167
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
170#[serde(deny_unknown_fields)]
171pub struct ExposedFile {
172 pub path: String,
175 pub kind: FileKind,
177 pub source: String,
179 pub bytes: u64,
181 pub sha256: String,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub varies: Option<String>,
187}
188
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
191#[serde(deny_unknown_fields)]
192pub struct ExposedEntry {
193 pub file: String,
195 pub offset: u64,
197 pub path: String,
199 pub kinds: Vec<String>,
201 pub bytes: u64,
203 pub sha256: String,
205}
206
207#[derive(
209 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
210)]
211#[serde(rename_all = "kebab-case")]
212pub enum FindingKind {
213 ForbiddenPath,
216 Canary,
218 UndeclaredFile,
220 UndeclaredPackEntry,
222 Beside,
224 UnreadablePack,
226 ArchiveDiffers,
228 TooBig,
230}
231
232#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
234#[serde(deny_unknown_fields)]
235pub struct Finding {
236 pub path: String,
238 pub entry: Option<String>,
240 pub kind: FindingKind,
242 pub source: String,
244 pub canary: Option<usize>,
247}
248
249#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
251#[serde(deny_unknown_fields)]
252pub struct ExposureReport {
253 pub ok: bool,
255 pub declared: bool,
258 pub inputs: BuildInputs,
260 pub files: Vec<ExposedFile>,
263 pub pack_entries: Vec<ExposedEntry>,
265 pub findings: Vec<Finding>,
267}
268
269pub 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 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
320fn 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
337pub 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
368fn 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
382fn 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
408fn 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
430fn 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
468fn 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
490struct Needle {
492 bytes: Vec<u8>,
493 kind: FindingKind,
494 canary: Option<usize>,
495 programs_only: bool,
498}
499
500fn 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
532fn 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
546struct 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
555struct Seen<'a> {
557 path: &'a str,
558 kind: FileKind,
559 source: &'a str,
560 varying: Option<&'a str>,
562}
563
564fn 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 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 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 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 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 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 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 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
727fn 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
735pub(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 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 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
821fn beside_name(out: &Path, file: &Path) -> String {
823 file.strip_prefix(out)
824 .unwrap_or(file)
825 .to_string_lossy()
826 .replace('\\', "/")
827}
828
829fn 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 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 let (size, sha256, varies) = if shipped.varying.is_empty() {
881 (bytes.len() as u64, sha256::hex(&bytes), None)
882 } else {
883 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;