1use std::borrow::Cow;
2use std::collections::BTreeMap;
3use std::fmt::{self, Debug};
4use std::io::{self, Read, Write};
5use std::sync::Arc;
6
7use zip::result::{ZipError, ZipResult};
8use zip::write::SimpleFileOptions;
9use zip::{CompressionMethod, ZipArchive, ZipWriter, read::ZipFile};
10
11pub use self::path::{VendoredPath, VendoredPathBuf};
12use crate::file_revision::FileRevision;
13
14mod path;
15
16type Result<T> = io::Result<T>;
17
18#[derive(Clone)]
37pub struct VendoredFileSystem {
38 inner: Arc<VendoredZipArchive>,
39}
40
41impl VendoredFileSystem {
42 pub fn new_static(raw_bytes: &'static [u8]) -> Result<Self> {
43 Self::new_impl(ArchiveData::Static(raw_bytes))
44 }
45
46 pub fn new(raw_bytes: Vec<u8>) -> Result<Self> {
47 Self::new_impl(ArchiveData::Owned(raw_bytes.into()))
48 }
49
50 fn new_impl(data: ArchiveData) -> Result<Self> {
51 Ok(Self {
52 inner: Arc::new(VendoredZipArchive::new(data)?),
53 })
54 }
55
56 pub fn exists(&self, path: impl AsRef<VendoredPath>) -> bool {
57 fn exists(fs: &VendoredFileSystem, path: &VendoredPath) -> bool {
58 let normalized = NormalizedVendoredPath::from(path);
59 let archive = &fs.inner;
60
61 archive.index_for_path(&normalized).is_some()
66 || archive
67 .index_for_path(&normalized.with_trailing_slash())
68 .is_some()
69 }
70
71 exists(self, path.as_ref())
72 }
73
74 pub fn metadata(&self, path: impl AsRef<VendoredPath>) -> Result<Metadata> {
75 fn metadata(fs: &VendoredFileSystem, path: &VendoredPath) -> Result<Metadata> {
76 let normalized = NormalizedVendoredPath::from(path);
77 let mut archive = fs.archive_reader();
78
79 if let Ok(metadata) = archive.metadata_for_path(&normalized) {
84 return Ok(metadata);
85 }
86 archive.metadata_for_path(&normalized.with_trailing_slash())
87 }
88
89 metadata(self, path.as_ref())
90 }
91
92 pub fn is_directory(&self, path: impl AsRef<VendoredPath>) -> bool {
93 self.metadata(path)
94 .is_ok_and(|metadata| metadata.kind().is_directory())
95 }
96
97 pub fn is_file(&self, path: impl AsRef<VendoredPath>) -> bool {
98 self.metadata(path)
99 .is_ok_and(|metadata| metadata.kind().is_file())
100 }
101
102 pub fn read_to_string(&self, path: impl AsRef<VendoredPath>) -> Result<String> {
109 fn read_to_string(fs: &VendoredFileSystem, path: &VendoredPath) -> Result<String> {
110 let mut archive = fs.archive_reader();
111 let mut zip_file = archive.lookup_path(&NormalizedVendoredPath::from(path))?;
112
113 let mut buffer = String::with_capacity(
118 usize::try_from(zip_file.size())
119 .unwrap_or(usize::MAX)
120 .min(10_000_000),
121 );
122 zip_file.read_to_string(&mut buffer)?;
123 Ok(buffer)
124 }
125
126 read_to_string(self, path.as_ref())
127 }
128
129 pub fn read_directory(
135 &self,
136 dir: impl AsRef<VendoredPath>,
137 ) -> impl Iterator<Item = DirectoryEntry> + '_ {
138 let directory_prefix = NormalizedVendoredPath::from(dir.as_ref())
139 .with_trailing_slash()
140 .0
141 .into_owned();
142
143 self.inner.0.file_names().filter_map(move |name| {
144 let without_dir_prefix = name.strip_prefix(&directory_prefix)?;
148 if without_dir_prefix.is_empty() {
151 return None;
152 }
153 let file_type = FileType::from_zip_file_name(without_dir_prefix);
163 let slash_count = without_dir_prefix.matches('/').count();
164 match file_type {
165 FileType::File if slash_count > 0 => return None,
166 FileType::Directory if slash_count > 1 => return None,
167 _ => {}
168 }
169
170 Some(DirectoryEntry {
171 path: VendoredPathBuf::from(name),
172 file_type,
173 })
174 })
175 }
176
177 fn archive_reader(&self) -> VendoredZipArchive {
187 self.inner.as_ref().clone()
188 }
189}
190
191impl fmt::Debug for VendoredFileSystem {
192 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
193 if f.alternate() {
194 let mut archive = self.archive_reader();
195 let mut paths: Vec<String> = archive.0.file_names().map(String::from).collect();
196 paths.sort();
197 let debug_info: BTreeMap<String, ZipFileDebugInfo> = paths
198 .iter()
199 .map(|path| {
200 (
201 path.to_owned(),
202 ZipFileDebugInfo::from(archive.0.by_name(path).unwrap()),
203 )
204 })
205 .collect();
206 f.debug_struct("VendoredFileSystem")
207 .field("paths", &paths)
208 .field("data_by_path", &debug_info)
209 .finish()
210 } else {
211 write!(f, "VendoredFileSystem(<{} paths>)", self.inner.len())
212 }
213 }
214}
215
216impl Default for VendoredFileSystem {
217 fn default() -> Self {
218 let mut bytes: Vec<u8> = Vec::new();
219 let mut cursor = io::Cursor::new(&mut bytes);
220
221 {
222 let writer = ZipWriter::new(&mut cursor);
223 writer.finish().unwrap();
224 }
225
226 VendoredFileSystem::new(bytes).unwrap()
227 }
228}
229
230#[expect(unused)]
238#[derive(Debug)]
239struct ZipFileDebugInfo {
240 crc32_hash: u32,
241 compressed_size: u64,
242 uncompressed_size: u64,
243 kind: FileType,
244}
245
246impl<'a, R: Read> From<ZipFile<'a, R>> for ZipFileDebugInfo {
247 fn from(value: ZipFile<'a, R>) -> Self {
248 Self {
249 crc32_hash: value.crc32(),
250 compressed_size: value.compressed_size(),
251 uncompressed_size: value.size(),
252 kind: if value.is_dir() {
253 FileType::Directory
254 } else {
255 FileType::File
256 },
257 }
258 }
259}
260
261#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
262pub enum FileType {
263 File,
265
266 Directory,
268}
269
270impl FileType {
271 fn from_zip_file_name(name: &str) -> FileType {
272 if name.ends_with('/') {
273 FileType::Directory
274 } else {
275 FileType::File
276 }
277 }
278
279 pub const fn is_file(self) -> bool {
280 matches!(self, Self::File)
281 }
282
283 pub const fn is_directory(self) -> bool {
284 matches!(self, Self::Directory)
285 }
286}
287
288#[derive(Clone, Copy, Debug, PartialEq, Eq)]
289pub struct Metadata {
290 kind: FileType,
291 revision: FileRevision,
292}
293
294impl Metadata {
295 fn from_zip_file<R: Read>(zip_file: ZipFile<'_, R>) -> Self {
296 let kind = if zip_file.is_dir() {
297 FileType::Directory
298 } else {
299 FileType::File
300 };
301
302 Self {
303 kind,
304 revision: FileRevision::new(u128::from(zip_file.crc32())),
305 }
306 }
307
308 pub fn kind(&self) -> FileType {
309 self.kind
310 }
311
312 pub fn revision(&self) -> FileRevision {
313 self.revision
314 }
315}
316
317#[derive(Debug, PartialEq, Eq)]
318pub struct DirectoryEntry {
319 path: VendoredPathBuf,
320 file_type: FileType,
321}
322
323impl DirectoryEntry {
324 pub fn new(path: VendoredPathBuf, file_type: FileType) -> Self {
325 Self { path, file_type }
326 }
327
328 pub fn into_path(self) -> VendoredPathBuf {
329 self.path
330 }
331
332 pub fn path(&self) -> &VendoredPath {
333 &self.path
334 }
335
336 pub fn file_type(&self) -> FileType {
337 self.file_type
338 }
339}
340
341#[derive(Clone, Debug)]
343enum ArchiveData {
344 Static(&'static [u8]),
345 Owned(Arc<[u8]>),
346}
347
348impl AsRef<[u8]> for ArchiveData {
349 fn as_ref(&self) -> &[u8] {
350 match self {
351 Self::Static(data) => data,
352 Self::Owned(data) => data,
353 }
354 }
355}
356
357#[derive(Clone, Debug)]
359struct VendoredZipArchive(ZipArchive<io::Cursor<ArchiveData>>);
360
361impl VendoredZipArchive {
362 fn new(data: ArchiveData) -> Result<Self> {
363 Ok(Self(ZipArchive::new(io::Cursor::new(data))?))
364 }
365
366 fn index_for_path(&self, path: &NormalizedVendoredPath) -> Option<usize> {
367 self.0.index_for_name(path.as_str())
368 }
369
370 fn lookup_path(
371 &mut self,
372 path: &NormalizedVendoredPath,
373 ) -> Result<ZipFile<'_, io::Cursor<ArchiveData>>> {
374 Ok(self.0.by_name(path.as_str())?)
375 }
376
377 fn metadata_for_path(&mut self, path: &NormalizedVendoredPath) -> Result<Metadata> {
378 let index = self.index_for_path(path).ok_or(ZipError::FileNotFound)?;
379 Ok(Metadata::from_zip_file(self.0.by_index_raw(index)?))
380 }
381
382 fn len(&self) -> usize {
383 self.0.len()
384 }
385}
386
387#[derive(Debug, Clone, PartialEq, Eq)]
393struct NormalizedVendoredPath<'a>(Cow<'a, str>);
394
395impl NormalizedVendoredPath<'_> {
396 fn with_trailing_slash(self) -> Self {
397 debug_assert!(!self.0.ends_with('/'));
398 let mut data = self.0.into_owned();
399 data.push('/');
400 Self(Cow::Owned(data))
401 }
402
403 fn as_str(&self) -> &str {
404 &self.0
405 }
406}
407
408impl<'a> From<&'a VendoredPath> for NormalizedVendoredPath<'a> {
409 fn from(path: &'a VendoredPath) -> Self {
421 fn normalize_unnormalized_path(path: &VendoredPath) -> String {
428 let mut normalized_parts = Vec::new();
429 for component in path.components() {
430 match component {
431 camino::Utf8Component::Normal(part) => normalized_parts.push(part),
432 camino::Utf8Component::CurDir => continue,
433 camino::Utf8Component::ParentDir => {
434 normalized_parts.pop();
438 }
439 unsupported => {
440 panic!("Unsupported component in a vendored path: {unsupported}")
441 }
442 }
443 }
444 normalized_parts.join("/")
445 }
446
447 let path_str = path.as_str();
448
449 if std::path::MAIN_SEPARATOR == '\\' && path_str.contains('\\') {
450 NormalizedVendoredPath(Cow::Owned(normalize_unnormalized_path(path)))
452 } else if !path
453 .components()
454 .all(|component| matches!(component, camino::Utf8Component::Normal(_)))
455 {
456 NormalizedVendoredPath(Cow::Owned(normalize_unnormalized_path(path)))
458 } else {
459 NormalizedVendoredPath(Cow::Borrowed(path_str.trim_end_matches('/')))
461 }
462 }
463}
464
465pub struct VendoredFileSystemBuilder {
466 writer: ZipWriter<io::Cursor<Vec<u8>>>,
467 compression_method: CompressionMethod,
468}
469
470impl VendoredFileSystemBuilder {
471 pub fn new(compression_method: CompressionMethod) -> Self {
472 let buffer = io::Cursor::new(Vec::new());
473
474 Self {
475 writer: ZipWriter::new(buffer),
476 compression_method,
477 }
478 }
479
480 pub fn add_file(
481 &mut self,
482 path: impl AsRef<VendoredPath>,
483 content: &str,
484 ) -> std::io::Result<()> {
485 self.writer
486 .start_file(path.as_ref().as_str(), self.options())?;
487 self.writer.write_all(content.as_bytes())
488 }
489
490 pub fn add_directory(&mut self, path: impl AsRef<VendoredPath>) -> ZipResult<()> {
491 self.writer
492 .add_directory(path.as_ref().as_str(), self.options())
493 }
494
495 pub fn finish(self) -> Result<VendoredFileSystem> {
496 let buffer = self.writer.finish()?;
497
498 VendoredFileSystem::new(buffer.into_inner())
499 }
500
501 fn options(&self) -> SimpleFileOptions {
502 SimpleFileOptions::default()
503 .compression_method(self.compression_method)
504 .unix_permissions(0o644)
505 }
506}
507
508#[cfg(test)]
509pub(crate) mod tests {
510
511 use insta::assert_snapshot;
512
513 use super::*;
514
515 const FUNCTOOLS_CONTENTS: &str = "def update_wrapper(): ...";
516 const ASYNCIO_TASKS_CONTENTS: &str = "class Task: ...";
517
518 fn mock_typeshed() -> VendoredFileSystem {
519 let mut builder = VendoredFileSystemBuilder::new(CompressionMethod::Stored);
520
521 builder.add_directory("stdlib/").unwrap();
522 builder
523 .add_file("stdlib/functools.pyi", FUNCTOOLS_CONTENTS)
524 .unwrap();
525 builder.add_directory("stdlib/asyncio/").unwrap();
526 builder
527 .add_file("stdlib/asyncio/tasks.pyi", ASYNCIO_TASKS_CONTENTS)
528 .unwrap();
529
530 builder.finish().unwrap()
531 }
532
533 #[test]
534 fn filesystem_debug_implementation() {
535 assert_snapshot!(
536 format!("{:?}", mock_typeshed()),
537 @"VendoredFileSystem(<4 paths>)"
538 );
539 }
540
541 #[test]
542 fn filesystem_debug_implementation_alternate() {
543 assert_snapshot!(format!("{:#?}", mock_typeshed()), @r#"
544 VendoredFileSystem {
545 paths: [
546 "stdlib/",
547 "stdlib/asyncio/",
548 "stdlib/asyncio/tasks.pyi",
549 "stdlib/functools.pyi",
550 ],
551 data_by_path: {
552 "stdlib/": ZipFileDebugInfo {
553 crc32_hash: 0,
554 compressed_size: 0,
555 uncompressed_size: 0,
556 kind: Directory,
557 },
558 "stdlib/asyncio/": ZipFileDebugInfo {
559 crc32_hash: 0,
560 compressed_size: 0,
561 uncompressed_size: 0,
562 kind: Directory,
563 },
564 "stdlib/asyncio/tasks.pyi": ZipFileDebugInfo {
565 crc32_hash: 2826547428,
566 compressed_size: 15,
567 uncompressed_size: 15,
568 kind: File,
569 },
570 "stdlib/functools.pyi": ZipFileDebugInfo {
571 crc32_hash: 1099005079,
572 compressed_size: 25,
573 uncompressed_size: 25,
574 kind: File,
575 },
576 },
577 }
578 "#);
579 }
580
581 fn test_directory(dirname: &str) {
582 let mock_typeshed = mock_typeshed();
583
584 let path = VendoredPath::new(dirname);
585
586 assert!(mock_typeshed.exists(path));
587 assert!(mock_typeshed.read_to_string(path).is_err());
588 let metadata = mock_typeshed.metadata(path).unwrap();
589 assert!(metadata.kind().is_directory());
590 }
591
592 #[test]
593 fn stdlib_dir_no_trailing_slash() {
594 test_directory("stdlib")
595 }
596
597 #[test]
598 fn stdlib_dir_trailing_slash() {
599 test_directory("stdlib/")
600 }
601
602 #[test]
603 fn asyncio_dir_no_trailing_slash() {
604 test_directory("stdlib/asyncio")
605 }
606
607 #[test]
608 fn asyncio_dir_trailing_slash() {
609 test_directory("stdlib/asyncio/")
610 }
611
612 #[test]
613 fn stdlib_dir_parent_components() {
614 test_directory("stdlib/asyncio/../../stdlib")
615 }
616
617 #[test]
618 fn asyncio_dir_odd_components() {
619 test_directory("./stdlib/asyncio/../asyncio/")
620 }
621
622 fn readdir_snapshot(fs: &VendoredFileSystem, path: &str) -> String {
623 let mut paths = fs
624 .read_directory(VendoredPath::new(path))
625 .map(|entry| entry.path().to_string())
626 .collect::<Vec<String>>();
627 paths.sort();
628 paths.join("\n")
629 }
630
631 #[test]
632 fn read_directory_stdlib() {
633 let mock_typeshed = mock_typeshed();
634
635 assert_snapshot!(readdir_snapshot(&mock_typeshed, "stdlib"), @"
636 vendored://stdlib/asyncio/
637 vendored://stdlib/functools.pyi
638 ");
639 assert_snapshot!(readdir_snapshot(&mock_typeshed, "stdlib/"), @"
640 vendored://stdlib/asyncio/
641 vendored://stdlib/functools.pyi
642 ");
643 assert_snapshot!(readdir_snapshot(&mock_typeshed, "./stdlib"), @"
644 vendored://stdlib/asyncio/
645 vendored://stdlib/functools.pyi
646 ");
647 assert_snapshot!(readdir_snapshot(&mock_typeshed, "./stdlib/"), @"
648 vendored://stdlib/asyncio/
649 vendored://stdlib/functools.pyi
650 ");
651 }
652
653 #[test]
654 fn read_directory_asyncio() {
655 let mock_typeshed = mock_typeshed();
656
657 assert_snapshot!(
658 readdir_snapshot(&mock_typeshed, "stdlib/asyncio"),
659 @"vendored://stdlib/asyncio/tasks.pyi",
660 );
661 assert_snapshot!(
662 readdir_snapshot(&mock_typeshed, "./stdlib/asyncio"),
663 @"vendored://stdlib/asyncio/tasks.pyi",
664 );
665 assert_snapshot!(
666 readdir_snapshot(&mock_typeshed, "stdlib/asyncio/"),
667 @"vendored://stdlib/asyncio/tasks.pyi",
668 );
669 assert_snapshot!(
670 readdir_snapshot(&mock_typeshed, "./stdlib/asyncio/"),
671 @"vendored://stdlib/asyncio/tasks.pyi",
672 );
673 }
674
675 fn test_nonexistent_path(path: &str) {
676 let mock_typeshed = mock_typeshed();
677 let path = VendoredPath::new(path);
678 assert!(!mock_typeshed.exists(path));
679 assert!(mock_typeshed.metadata(path).is_err());
680 assert!(
681 mock_typeshed
682 .read_to_string(path)
683 .is_err_and(|err| err.to_string().contains("file not found"))
684 );
685 }
686
687 #[test]
688 fn simple_nonexistent_path() {
689 test_nonexistent_path("foo")
690 }
691
692 #[test]
693 fn nonexistent_path_with_extension() {
694 test_nonexistent_path("foo.pyi")
695 }
696
697 #[test]
698 fn nonexistent_path_with_trailing_slash() {
699 test_nonexistent_path("foo/")
700 }
701
702 #[test]
703 fn nonexistent_path_with_fancy_components() {
704 test_nonexistent_path("./foo/../../../foo")
705 }
706
707 fn test_file(mock_typeshed: &VendoredFileSystem, path: &VendoredPath) {
708 assert!(mock_typeshed.exists(path));
709 let metadata = mock_typeshed.metadata(path).unwrap();
710 assert!(metadata.kind().is_file());
711 }
712
713 #[test]
714 fn functools_file_contents() {
715 let mock_typeshed = mock_typeshed();
716 let path = VendoredPath::new("stdlib/functools.pyi");
717 test_file(&mock_typeshed, path);
718 let functools_stub = mock_typeshed.read_to_string(path).unwrap();
719 assert_eq!(functools_stub.as_str(), FUNCTOOLS_CONTENTS);
720 let functools_stub_again = mock_typeshed.read_to_string(path).unwrap();
722 assert_eq!(functools_stub_again.as_str(), FUNCTOOLS_CONTENTS);
723 }
724
725 #[test]
726 fn functools_file_other_path() {
727 test_file(
728 &mock_typeshed(),
729 VendoredPath::new("stdlib/../stdlib/../stdlib/functools.pyi"),
730 )
731 }
732
733 #[test]
734 fn asyncio_file_contents() {
735 let mock_typeshed = mock_typeshed();
736 let path = VendoredPath::new("stdlib/asyncio/tasks.pyi");
737 test_file(&mock_typeshed, path);
738 let asyncio_stub = mock_typeshed.read_to_string(path).unwrap();
739 assert_eq!(asyncio_stub.as_str(), ASYNCIO_TASKS_CONTENTS);
740 }
741
742 #[test]
743 fn asyncio_file_other_path() {
744 test_file(
745 &mock_typeshed(),
746 VendoredPath::new("./stdlib/asyncio/../asyncio/tasks.pyi"),
747 )
748 }
749}