Skip to main content

pytorch_reader/
reader.rs

1//! Format detection and container loading behind [`PytorchReader`].
2//!
3//! The containers and their layouts are described in the crate docs.
4
5use crate::nested::{adapter::DefaultAdapter, data::NestedValue, de::Deserializer};
6use crate::{DType, Tensor};
7use serde::de::DeserializeOwned;
8use std::collections::HashMap;
9use std::fs::File;
10use std::io;
11use std::io::{BufRead, BufReader, Cursor, Read, Seek, SeekFrom};
12use std::path::{Path, PathBuf};
13use std::sync::Arc;
14
15use crate::pickle_reader::{
16    Object, PersistentIds, PickleError, StorageRef, build_tensor, extract_tensors, key_string,
17    non_negative, read_pickle, storage_type_to_dtype,
18};
19use crate::storage::{Backing, LegacySource, StorageSource, TarSource, ZipSource};
20use byteorder::{LittleEndian, ReadBytesExt};
21use thiserror::Error;
22
23/// Error type for PyTorch file operations
24#[derive(Debug, Error)]
25#[non_exhaustive]
26pub enum PytorchError {
27    /// IO error
28    #[error("IO error: {0}")]
29    Io(#[from] std::io::Error),
30    /// Pickle parsing error
31    #[error("pickle: {0}")]
32    Pickle(#[from] PickleError),
33    /// Zip archive error
34    #[error("zip archive error: {0}")]
35    Zip(#[from] zip::result::ZipError),
36    /// TAR archive error
37    #[error("TAR archive error: {0}")]
38    Tar(#[source] std::io::Error),
39    /// Invalid file format
40    #[error("invalid PyTorch file format: {0}")]
41    InvalidFormat(String),
42    /// Key not found
43    #[error("key not found in PyTorch file: {0}")]
44    KeyNotFound(String),
45    /// Serde deserialization error
46    #[error("serde deserialization error: {0}")]
47    Serde(#[from] crate::nested::error::Error),
48}
49
50type Result<T> = std::result::Result<T, PytorchError>;
51
52/// Metadata about a PyTorch file
53///
54/// Contains information about the file format, version, and other properties
55/// that can be useful for debugging or compatibility checking.
56#[derive(Debug, Clone)]
57pub struct PytorchMetadata {
58    /// Contents of the `.format_version` entry (e.g. `"1"`), if the archive has one
59    pub format_version: Option<String>,
60    /// File format type (ZIP, TAR, Legacy, or Pickle)
61    pub format_type: FileFormat,
62    /// Byte order. Always `LittleEndian`: big-endian files are rejected at load.
63    pub byte_order: ByteOrder,
64    /// Whether the file has storage alignment information
65    pub has_storage_alignment: bool,
66    /// Contents of the `version` entry (e.g. `"3"`): the serialized-file format version,
67    /// not the PyTorch release that wrote the file
68    pub pytorch_version: Option<String>,
69    /// Number of tensors in the file
70    pub tensor_count: usize,
71    /// Approximate size of the storage section in bytes (if available)
72    pub total_data_size: Option<usize>,
73}
74
75impl PytorchMetadata {
76    /// Check if this is a modern format file (ZIP-based, PyTorch 1.6+)
77    pub fn is_modern_format(&self) -> bool {
78        matches!(self.format_type, FileFormat::Zip)
79    }
80
81    /// Check if this is a legacy format file (PyTorch 0.1.10 - 1.5)
82    pub fn is_legacy_format(&self) -> bool {
83        matches!(self.format_type, FileFormat::Legacy)
84    }
85
86    fn for_format(format_type: FileFormat) -> Self {
87        Self {
88            format_version: None,
89            format_type,
90            byte_order: ByteOrder::LittleEndian,
91            has_storage_alignment: false,
92            pytorch_version: None,
93            tensor_count: 0,
94            total_data_size: None,
95        }
96    }
97}
98
99/// File format type
100#[derive(Debug, Clone, PartialEq)]
101pub enum FileFormat {
102    /// ZIP-based format (PyTorch 1.6+)
103    Zip,
104    /// TAR-based format (PyTorch before 0.1.10)
105    Tar,
106    /// Legacy format (PyTorch 0.1.10 - 1.5)
107    Legacy,
108    /// Simple pickle file
109    Pickle,
110}
111
112/// Byte order (endianness)
113#[derive(Debug, Clone, PartialEq)]
114pub enum ByteOrder {
115    LittleEndian,
116    BigEndian,
117}
118
119/// PyTorch checkpoint reader
120///
121/// This is the main interface for reading PyTorch checkpoint files (.pt/.pth).
122/// It supports multiple PyTorch formats including modern ZIP-based format (1.6+),
123/// legacy format (0.1.10-1.5), the early TAR format, and simple pickle files.
124///
125/// # Example
126/// ```rust,no_run
127/// # use pytorch_reader::PytorchReader;
128/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
129/// // Load a checkpoint file
130/// let reader = PytorchReader::new("model.pt")?;
131///
132/// // Get tensor names
133/// let keys = reader.keys();
134///
135/// // Access a specific tensor
136/// if let Some(tensor) = reader.get("conv1.weight") {
137///     let bytes = tensor.read()?; // Reads the tensor's bytes from the file
138/// }
139///
140/// // Check file metadata
141/// println!("Format: {:?}", reader.metadata().format_type);
142/// println!("Tensor count: {}", reader.metadata().tensor_count);
143/// # Ok(())
144/// # }
145/// ```
146#[derive(Debug)]
147pub struct PytorchReader {
148    tensors: HashMap<String, Tensor>,
149    metadata: PytorchMetadata,
150}
151
152impl PytorchReader {
153    /// Load a PyTorch checkpoint file
154    ///
155    /// # Arguments
156    /// * `path` - Path to the PyTorch file (.pt or .pth)
157    ///
158    /// # Returns
159    /// A `PytorchReader` with lazy-loaded tensors and metadata
160    pub fn new<P: AsRef<Path>>(path: P) -> Result<Self> {
161        Self::open(&Container::File(path.as_ref().to_path_buf()), None)
162    }
163
164    /// Load a PyTorch checkpoint with a specific top-level key
165    ///
166    /// Many PyTorch checkpoints store the model weights under a specific key
167    /// like "state_dict", "model", or "model_state_dict".
168    ///
169    /// # Arguments
170    /// * `path` - Path to the PyTorch file
171    /// * `key` - Top-level key to extract (e.g., "state_dict")
172    ///
173    /// # Example
174    /// ```rust,no_run
175    /// # use pytorch_reader::PytorchReader;
176    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
177    /// let reader = PytorchReader::with_top_level_key("checkpoint.pt", "state_dict")?;
178    /// # Ok(())
179    /// # }
180    /// ```
181    pub fn with_top_level_key<P: AsRef<Path>>(path: P, key: &str) -> Result<Self> {
182        Self::open(&Container::File(path.as_ref().to_path_buf()), Some(key))
183    }
184
185    /// Load a PyTorch checkpoint from an in-memory buffer
186    ///
187    /// Reads every container [`PytorchReader::new`] does (ZIP, legacy, TAR and plain
188    /// pickle) without writing a temporary file first. The buffer is moved in, not copied:
189    /// a ZIP or legacy checkpoint is read lazily from it, and it stays alive until the
190    /// reader and every tensor taken from it are dropped.
191    ///
192    /// # Arguments
193    /// * `bytes` - The bytes of a PyTorch file (.pt or .pth)
194    /// * `top_level_key` - Top-level key to extract (e.g., "state_dict"), or `None` to
195    ///   use the whole file
196    ///
197    /// # Example
198    /// ```rust,no_run
199    /// # use pytorch_reader::PytorchReader;
200    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
201    /// let bytes = std::fs::read("checkpoint.pt")?;
202    /// let reader = PytorchReader::from_bytes(bytes, Some("state_dict"))?;
203    /// # Ok(())
204    /// # }
205    /// ```
206    pub fn from_bytes(bytes: Vec<u8>, top_level_key: Option<&str>) -> Result<Self> {
207        Self::open(&Container::Memory(Arc::new(bytes)), top_level_key)
208    }
209
210    fn open(container: &Container, top_level_key: Option<&str>) -> Result<Self> {
211        let Loaded { root, mut metadata } = load_file(container)?;
212        let tensors = extract_tensors_at(root, top_level_key)?;
213        metadata.tensor_count = tensors.len();
214        Ok(Self { tensors, metadata })
215    }
216
217    /// Load from a reader
218    ///
219    /// This method is useful when loading from non-file sources like memory buffers.
220    /// This function does not read containers (ZIP, legacy, TAR) — it expects the reader to
221    /// hold a plain pickle. To load a checkpoint from an in-memory buffer, use
222    /// [`PytorchReader::from_bytes`] instead.
223    ///
224    /// # Arguments
225    /// * `reader` - Any type implementing `Read`
226    /// * `top_level_key` - Optional key to extract
227    pub fn from_reader<R: Read>(reader: R, top_level_key: Option<&str>) -> Result<Self> {
228        let root = read_pickle(&mut BufReader::new(reader), &PersistentIds::Unavailable)?;
229        let tensors = extract_tensors_at(root, top_level_key)?;
230        let mut metadata = PytorchMetadata::for_format(FileFormat::Pickle);
231        metadata.tensor_count = tensors.len();
232        Ok(Self { tensors, metadata })
233    }
234
235    /// Get all tensor names
236    pub fn keys(&self) -> Vec<String> {
237        self.tensors.keys().cloned().collect()
238    }
239
240    /// Get a tensor by name
241    pub fn get(&self, name: &str) -> Option<&Tensor> {
242        self.tensors.get(name)
243    }
244
245    /// Get all tensors
246    pub fn tensors(&self) -> &HashMap<String, Tensor> {
247        &self.tensors
248    }
249
250    /// Take ownership of all tensors
251    pub fn into_tensors(self) -> HashMap<String, Tensor> {
252        self.tensors
253    }
254
255    /// Get metadata about the loaded file
256    ///
257    /// Provides information about the file format, version, endianness, etc.
258    pub fn metadata(&self) -> &PytorchMetadata {
259        &self.metadata
260    }
261
262    /// Get the number of tensors in the file
263    pub fn len(&self) -> usize {
264        self.tensors.len()
265    }
266
267    /// Check if the file contains no tensors
268    pub fn is_empty(&self) -> bool {
269        self.tensors.is_empty()
270    }
271
272    /// Read raw pickle data from a PyTorch file
273    ///
274    /// This is useful for extracting configuration or metadata that isn't tensor data.
275    /// Returns a simplified JSON-like structure that can be easily converted to other formats.
276    /// Tensors and Python objects the reader does not interpret appear as
277    /// [`PickleValue::Unsupported`], naming their Python type.
278    ///
279    /// # Arguments
280    /// * `path` - Path to the PyTorch file
281    /// * `top_level_key` - Optional key to extract from the top-level dictionary
282    ///
283    /// # Returns
284    /// A `PickleValue` representing the pickle data structure
285    pub fn read_pickle_data<P: AsRef<Path>>(
286        path: P,
287        top_level_key: Option<&str>,
288    ) -> Result<PickleValue> {
289        Self::read_pickle_data_from_container(
290            Container::File(path.as_ref().to_path_buf()),
291            top_level_key,
292        )
293    }
294
295    fn read_pickle_data_from_container(
296        container: Container,
297        top_level_key: Option<&str>,
298    ) -> Result<PickleValue> {
299        let Loaded { root, .. } = load_file(&container)?;
300        let value = select_top_level(root, top_level_key)?;
301        Ok(to_pickle_value(value))
302    }
303
304    /// Load and deserialize configuration data from a PyTorch file
305    ///
306    /// This method reads configuration or metadata stored in PyTorch checkpoint files
307    /// and deserializes it into the specified type. It's particularly useful for
308    /// extracting model configurations that might be saved alongside model weights.
309    ///
310    /// # Arguments
311    /// * `path` - Path to the PyTorch file (.pt or .pth)
312    /// * `top_level_key` - Optional key to extract specific data within the pickle file.
313    ///   If `None`, the entire content is deserialized.
314    ///
315    /// # Type Parameters
316    /// * `D` - The target type to deserialize into. Must implement `DeserializeOwned`.
317    ///
318    /// # Returns
319    /// A `Result` containing the deserialized configuration data, or an `Error` if
320    /// reading or deserialization fails. A field whose value the reader cannot represent
321    /// (a numpy scalar, a `torch.dtype`, a tensor) is an error rather than a default.
322    /// Entries the target type does not name are skipped, so weights saved beside the
323    /// configuration do not get in the way.
324    ///
325    /// # Example
326    /// ```rust,no_run
327    /// # use pytorch_reader::PytorchReader;
328    /// # use serde::Deserialize;
329    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
330    /// #[derive(Debug, Deserialize)]
331    /// struct ModelConfig {
332    ///     hidden_size: usize,
333    ///     num_layers: usize,
334    /// }
335    ///
336    /// let config: ModelConfig = PytorchReader::load_config("model.pth", Some("config"))?;
337    /// # Ok(())
338    /// # }
339    /// ```
340    pub fn load_config<D, P>(path: P, top_level_key: Option<&str>) -> Result<D>
341    where
342        D: DeserializeOwned,
343        P: AsRef<Path>,
344    {
345        let pickle_value = Self::read_pickle_data_from_container(
346            Container::File(path.as_ref().to_path_buf()),
347            top_level_key,
348        )?;
349        let nested_value = to_nested_value(pickle_value);
350        let deserializer = Deserializer::<DefaultAdapter>::new(nested_value, false);
351        Ok(D::deserialize(deserializer)?)
352    }
353}
354
355/// Simplified representation of pickle data
356///
357/// This enum provides a JSON-like structure that's easier to work with
358/// than the internal pickle Object type.
359#[derive(Debug, Clone, PartialEq)]
360pub enum PickleValue {
361    /// None/null value
362    None,
363    /// Boolean value
364    Bool(bool),
365    /// Integer value
366    Int(i64),
367    /// Floating point value
368    Float(f64),
369    /// String value
370    String(String),
371    /// List/array of values
372    List(Vec<PickleValue>),
373    /// Dictionary/map of string keys to values
374    Dict(HashMap<String, PickleValue>),
375    /// Binary data
376    Bytes(Vec<u8>),
377    /// A value the reader cannot represent, holding the name of its Python type: a tensor
378    /// or storage, a class such as `torch.float32`, an object it does not interpret
379    /// (`numpy.core.multiarray.scalar`, `torch.device`), or an `int` too wide for an `i64`.
380    Unsupported(String),
381}
382
383// ---------------------------------------------------------------------------------------------
384// Format detection and loading
385// ---------------------------------------------------------------------------------------------
386
387/// A parsed file: the root pickle object plus what the container says about itself.
388struct Loaded {
389    root: Object,
390    /// `tensor_count` is filled in once tensors have been extracted.
391    metadata: PytorchMetadata,
392}
393
394#[derive(Clone)]
395pub(crate) enum Container {
396    File(PathBuf),
397    Memory(Arc<Vec<u8>>),
398}
399
400impl Container {
401    /// First `n` bytes, for format detection. no full read
402    fn header(&self, n: usize) -> Result<Vec<u8>> {
403        match self {
404            Container::File(path) => {
405                let mut header = Vec::new();
406                File::open(path)?.take(n as u64).read_to_end(&mut header)?;
407                Ok(header)
408            }
409            Container::Memory(buf) => Ok(buf[..buf.len().min(n)].to_vec()),
410        }
411    }
412
413    pub(crate) fn reader(&self) -> io::Result<Box<dyn ContainerRead>> {
414        match self {
415            Container::File(path) => Ok(Box::new(BufReader::new(File::open(path)?))),
416            Container::Memory(buf) => Ok(Box::new(Cursor::new(ArcBytes(Arc::clone(buf))))),
417        }
418    }
419}
420
421pub(crate) trait ContainerRead: Read + BufRead + Seek + Send {}
422impl<T: Read + BufRead + Seek + Send + ?Sized> ContainerRead for T {}
423
424pub(crate) struct ArcBytes(pub(crate) Arc<Vec<u8>>);
425impl AsRef<[u8]> for ArcBytes {
426    fn as_ref(&self) -> &[u8] {
427        &self.0
428    }
429}
430
431/// The magic number 0x1950a86a20f9469cfc6c opening a legacy file, as protocols 2 and up
432/// write it: little-endian bytes inside a `LONG1`.
433const LEGACY_MAGIC_BYTES: [u8; 10] = [0x6c, 0xfc, 0x9c, 0x46, 0xf9, 0x20, 0x6a, 0xa8, 0x50, 0x19];
434
435/// The same value in decimal, which protocols 0 and 1 write as `LONG` text.
436const LEGACY_MAGIC_TEXT: &[u8] = b"119547037146038801333356";
437
438/// How far into the file the magic is looked for. `torch.save` picks the protocol, and
439/// each frames the first pickle differently: protocol 4 puts a `PROTO` and a 9-byte `FRAME`
440/// ahead of the value, protocol 0 none at all. Every encoding starts well inside this.
441const LEGACY_MAGIC_SEARCH: usize = 64;
442
443/// Whether a file opens with the legacy magic number in any protocol's encoding.
444fn starts_with_legacy_magic(header: &[u8]) -> bool {
445    let window = &header[..header.len().min(LEGACY_MAGIC_SEARCH)];
446    window
447        .windows(LEGACY_MAGIC_BYTES.len())
448        .any(|bytes| bytes == LEGACY_MAGIC_BYTES)
449        || window
450            .windows(LEGACY_MAGIC_TEXT.len())
451            .any(|bytes| bytes == LEGACY_MAGIC_TEXT)
452}
453
454/// Offset of the `ustar` magic within a TAR header.
455const TAR_MAGIC_OFFSET: usize = 257;
456
457fn detect_format(container: &Container) -> Result<FileFormat> {
458    let header = container.header(TAR_MAGIC_OFFSET + 5)?;
459
460    if header.starts_with(b"PK\x03\x04") || header.starts_with(b"PK\x05\x06") {
461        Ok(FileFormat::Zip)
462    } else if header.get(TAR_MAGIC_OFFSET..TAR_MAGIC_OFFSET + 5) == Some(b"ustar") {
463        Ok(FileFormat::Tar)
464    } else if starts_with_legacy_magic(&header) {
465        Ok(FileFormat::Legacy)
466    } else {
467        match header.first() {
468            Some(0x80) | Some(b'(') | Some(b'}') | Some(b']') | Some(b'c')
469                if !starts_like_safetensors(&header) =>
470            {
471                Ok(FileFormat::Pickle)
472            }
473            _ => Err(PytorchError::InvalidFormat(
474                "not a PyTorch checkpoint: no ZIP, TAR, legacy or pickle header found".to_string(),
475            )),
476        }
477    }
478}
479
480/// The largest JSON header the safetensors format accepts.
481const SAFETENSORS_MAX_HEADER: u64 = 100_000_000;
482
483/// Whether `header` opens like a safetensors file: a little-endian `u64` JSON length, then
484/// the `{` the JSON starts with. The length's low byte can equal a pickle opcode (a 128-byte
485/// JSON header starts with `0x80`, the `PROTO` opcode), so the first byte alone can't tell
486/// the two apart.
487fn starts_like_safetensors(header: &[u8]) -> bool {
488    let Some(len) = header.first_chunk::<8>().map(|b| u64::from_le_bytes(*b)) else {
489        return false;
490    };
491    len <= SAFETENSORS_MAX_HEADER && header.get(8) == Some(&b'{')
492}
493
494fn load_file(container: &Container) -> Result<Loaded> {
495    match detect_format(container)? {
496        FileFormat::Zip => load_zip(container),
497        FileFormat::Tar => load_tar(container),
498        FileFormat::Legacy => load_legacy(container),
499        FileFormat::Pickle => load_plain_pickle(container),
500    }
501}
502
503fn load_zip(container: &Container) -> Result<Loaded> {
504    let source = ZipSource::open(container)?;
505
506    match source.read_text("byteorder")?.as_deref() {
507        None | Some("little") => {}
508        Some("big") => return Err(big_endian_error()),
509        Some(other) => {
510            return Err(PytorchError::InvalidFormat(format!(
511                "Unrecognized byteorder entry '{other}', expected 'little' or 'big'"
512            )));
513        }
514    }
515    let mut metadata = PytorchMetadata::for_format(FileFormat::Zip);
516    metadata.format_version = source.read_text(".format_version")?;
517    metadata.pytorch_version = source.read_text("version")?;
518    metadata.has_storage_alignment = source.has_entry(".storage_alignment");
519    metadata.total_data_size = Some(source.data_size()? as usize);
520
521    let pickle = source.pickle()?;
522    let ids = PersistentIds::Storages(Arc::new(StorageSource::Zip(source)));
523    let root = read_pickle(&mut Cursor::new(pickle), &ids)?;
524
525    Ok(Loaded { root, metadata })
526}
527
528fn load_legacy(container: &Container) -> Result<Loaded> {
529    let (mut reader, backing): (Box<dyn ContainerRead>, Backing) = match container {
530        Container::File(path) => {
531            let file = File::open(path)?;
532            let stream = file.try_clone()?;
533            (Box::new(BufReader::new(stream)), Backing::File(file))
534        }
535        Container::Memory(buf) => (
536            Box::new(Cursor::new(ArcBytes(Arc::clone(buf)))),
537            Backing::Memory(Arc::clone(buf)),
538        ),
539    };
540
541    let read_header = |reader: &mut Box<dyn ContainerRead>, what: &str| {
542        read_pickle(reader, &PersistentIds::Unavailable).map_err(|e| {
543            PytorchError::InvalidFormat(format!("Failed to read {what} from legacy format: {e}"))
544        })
545    };
546    // How long the magic pickle is depends on the protocol it was written at, so it is
547    // consumed as a pickle rather than skipped by a fixed offset. Its value is already
548    // known from the format detection.
549    read_header(&mut reader, "magic number")?;
550    // PyTorch refuses anything but 1001 here.
551    let protocol_version = read_header(&mut reader, "protocol version")?;
552    if !matches!(protocol_version, Object::Int(1001)) {
553        return Err(PytorchError::InvalidFormat(format!(
554            "Unsupported legacy protocol version {protocol_version:?}, expected 1001"
555        )));
556    }
557    let sys_info = read_header(&mut reader, "system info")?;
558    check_little_endian(&sys_info)?;
559
560    // The source reads storages at explicit offsets through a duplicate of the handle the
561    // headers stream through, so both come from the one open above and a replacement of
562    // `path` cannot slip in between two opens. No storage is read until `finish` below, by
563    // which point the stream is done with the cursor the duplicate shares.
564    let source = Arc::new(StorageSource::Legacy(LegacySource::new(backing)));
565    let root = read_pickle(&mut reader, &PersistentIds::Storages(source.clone()))?;
566
567    // The storage keys, in the order their bytes follow.
568    let storage_keys = match read_header(&mut reader, "storage key list")? {
569        Object::List(keys) => keys
570            .iter()
571            .map(|key| key_string(key, "legacy storage key"))
572            .collect::<std::result::Result<Vec<_>, _>>()?,
573        other => {
574            return Err(PytorchError::InvalidFormat(format!(
575                "legacy storage key list must be a list, got {other:?}"
576            )));
577        }
578    };
579
580    let data_start = reader.stream_position()?;
581    let file_len = reader.seek(SeekFrom::End(0))?;
582    if let StorageSource::Legacy(legacy) = &*source {
583        legacy
584            .finish(&storage_keys, data_start, file_len)
585            .map_err(|e| PytorchError::InvalidFormat(e.to_string()))?;
586    }
587
588    let mut metadata = PytorchMetadata::for_format(FileFormat::Legacy);
589    metadata.total_data_size = Some((file_len - data_start) as usize);
590    Ok(Loaded { root, metadata })
591}
592
593fn load_plain_pickle(container: &Container) -> Result<Loaded> {
594    let mut reader = container.reader()?;
595    let root = read_pickle(&mut reader, &PersistentIds::Unavailable)?;
596    Ok(Loaded {
597        root,
598        metadata: PytorchMetadata::for_format(FileFormat::Pickle),
599    })
600}
601
602fn load_tar(container: &Container) -> Result<Loaded> {
603    let mut archive = tar::Archive::new(container.reader()?);
604    let mut entries: HashMap<String, Vec<u8>> = HashMap::new();
605
606    for entry in archive.entries().map_err(PytorchError::Tar)? {
607        let mut entry = entry.map_err(PytorchError::Tar)?;
608        let name = entry
609            .path()
610            .map_err(PytorchError::Tar)?
611            .to_string_lossy()
612            .trim_start_matches("./")
613            .to_string();
614        if matches!(
615            name.as_str(),
616            "sys_info" | "storages" | "tensors" | "pickle"
617        ) {
618            let mut data = Vec::new();
619            entry.read_to_end(&mut data).map_err(PytorchError::Tar)?;
620            entries.insert(name, data);
621        }
622    }
623
624    if let Some(sys_info) = entries.remove("sys_info") {
625        let sys_info = read_pickle(&mut Cursor::new(sys_info), &PersistentIds::Unavailable)?;
626        check_little_endian(&sys_info)?;
627    }
628    let mut take = |name: &str| {
629        entries
630            .remove(name)
631            .ok_or_else(|| PytorchError::InvalidFormat(format!("TAR file missing '{name}' entry")))
632    };
633    let storages = take("storages")?;
634    let tensors = take("tensors")?;
635    let pickle = take("pickle")?;
636
637    let total_data_size = storages.len();
638    let (tar_source, storage_info) = parse_tar_storages(storages)?;
639    let source = Arc::new(StorageSource::Tar(tar_source));
640    let tensors = parse_tar_tensors(&tensors, &storage_info, &source)?;
641    let root = read_pickle(&mut Cursor::new(pickle), &PersistentIds::Tensors(tensors))?;
642
643    let mut metadata = PytorchMetadata::for_format(FileFormat::Tar);
644    metadata.total_data_size = Some(total_data_size);
645    Ok(Loaded { root, metadata })
646}
647
648/// Per storage key, the element type and byte length a TAR `storages` entry declares.
649type TarStorageInfo = HashMap<String, (DType, usize)>;
650
651/// Parse the `storages` entry: a count, then per storage `(key, location, storage type)`,
652/// an `i64` element count and the bytes, then a list of storage views.
653fn parse_tar_storages(blob: Vec<u8>) -> Result<(TarSource, TarStorageInfo)> {
654    let mut cursor = Cursor::new(blob.as_slice());
655    let count = read_tar_count(&mut cursor, "storage")?;
656
657    // Every storage costs at least a pickle and a count, so the count bounds the blob.
658    let plausible = count.min(blob.len() / 8);
659    let mut layout: HashMap<String, (usize, usize)> = HashMap::with_capacity(plausible);
660    let mut info = TarStorageInfo::with_capacity(plausible);
661
662    for _ in 0..count {
663        let meta = read_pickle(&mut cursor, &PersistentIds::Unavailable)?;
664        let [key, _location, storage_type] = tar_fields(&meta, "storage metadata")?;
665        // The tables key storages by int `_cdata`; the main pickle's persistent ids are
666        // `str(_cdata)`. Both are normalized to strings.
667        let key = key_string(key, "TAR storage key")?;
668        let dtype = match storage_type {
669            Object::Class { name, .. } => storage_type_to_dtype(name.as_str())?,
670            other => {
671                return Err(PytorchError::InvalidFormat(format!(
672                    "TAR storage type must be a class, got {other:?}"
673                )));
674            }
675        };
676
677        let numel = read_tar_usize(&mut cursor, "storage element count")?;
678        let byte_len = numel.checked_mul(dtype.size()).ok_or_else(|| {
679            PytorchError::InvalidFormat(format!("TAR storage '{key}' byte length overflows usize"))
680        })?;
681        let offset = cursor.position() as usize;
682        let end = offset
683            .checked_add(byte_len)
684            .filter(|&end| end <= blob.len())
685            .ok_or_else(|| {
686                PytorchError::InvalidFormat(format!(
687                    "TAR storage '{key}' ({byte_len} bytes) extends beyond the storages entry"
688                ))
689            })?;
690
691        layout.insert(key.clone(), (offset, byte_len));
692        info.insert(key, (dtype, byte_len));
693        cursor.set_position(end as u64);
694    }
695
696    // Views of root storages: (view key, root key, element offset, element count).
697    // PyTorch reads this list unconditionally, so its absence means a truncated entry.
698    {
699        let views = read_pickle(&mut cursor, &PersistentIds::Unavailable).map_err(|e| {
700            PytorchError::InvalidFormat(format!("TAR storage views list missing: {e}"))
701        })?;
702        let Object::List(views) = views else {
703            return Err(PytorchError::InvalidFormat(format!(
704                "TAR storage views must be a list, got {views:?}"
705            )));
706        };
707        for view in views {
708            let [target, root, offset, numel] = tar_fields(&view, "storage view")?;
709            let target = key_string(target, "TAR storage view key")?;
710            let root = key_string(root, "TAR storage view root")?;
711            let offset = non_negative(offset, "TAR storage view offset")?;
712            let numel = non_negative(numel, "TAR storage view element count")?;
713            let &(root_offset, root_len) = layout.get(&root).ok_or_else(|| {
714                PytorchError::InvalidFormat(format!(
715                    "TAR storage view '{target}' refers to unknown storage '{root}'"
716                ))
717            })?;
718            let (dtype, _) = info[&root];
719            let element_size = dtype.size();
720            let (start, len) = offset
721                .checked_mul(element_size)
722                .zip(numel.checked_mul(element_size))
723                .filter(|&(start, len)| start.checked_add(len).is_some_and(|end| end <= root_len))
724                .ok_or_else(|| {
725                    PytorchError::InvalidFormat(format!(
726                        "TAR storage view '{target}' lies outside storage '{root}'"
727                    ))
728                })?;
729            layout.insert(target.clone(), (root_offset + start, len));
730            info.insert(target, (dtype, len));
731        }
732    }
733
734    Ok((TarSource::new(blob, layout), info))
735}
736
737/// Parse the `tensors` entry: a count, then per tensor `(key, storage key, tensor type)`,
738/// an `i32` rank, 4 unused bytes, `rank` sizes, `rank` strides and the storage offset.
739fn parse_tar_tensors(
740    blob: &[u8],
741    storage_info: &TarStorageInfo,
742    source: &Arc<StorageSource>,
743) -> Result<HashMap<String, Tensor>> {
744    let mut cursor = Cursor::new(blob);
745    let count = read_tar_count(&mut cursor, "tensor")?;
746    let mut tensors = HashMap::with_capacity(count.min(blob.len() / 8));
747
748    for _ in 0..count {
749        let meta = read_pickle(&mut cursor, &PersistentIds::Unavailable)?;
750        let [key, storage_key, _tensor_type] = tar_fields(&meta, "tensor metadata")?;
751        let key = key_string(key, "TAR tensor key")?;
752        let storage_key = key_string(storage_key, "TAR tensor storage key")?;
753
754        let rank = cursor.read_i32::<LittleEndian>()?;
755        let rank = usize::try_from(rank)
756            .ok()
757            .filter(|&rank| rank <= 64)
758            .ok_or_else(|| {
759                PytorchError::InvalidFormat(format!("TAR tensor '{key}' has invalid rank {rank}"))
760            })?;
761        cursor.read_i32::<LittleEndian>()?; // Padding: the rank was once written as 8 bytes.
762        let shape = (0..rank)
763            .map(|_| read_tar_usize(&mut cursor, "tensor shape"))
764            .collect::<Result<Vec<_>>>()?;
765        let stride = (0..rank)
766            .map(|_| read_tar_usize(&mut cursor, "tensor stride"))
767            .collect::<Result<Vec<_>>>()?;
768        let storage_offset = read_tar_usize(&mut cursor, "tensor storage offset")?;
769
770        let &(dtype, byte_len) = storage_info.get(&storage_key).ok_or_else(|| {
771            PytorchError::InvalidFormat(format!(
772                "TAR tensor '{key}' refers to unknown storage '{storage_key}'"
773            ))
774        })?;
775        let storage = StorageRef {
776            source: source.clone(),
777            key: storage_key,
778            dtype: Some(dtype),
779            // A TAR view is resolved into its own layout entry above, so `byte_len` is
780            // already the view's length and nothing is reachable past it.
781            reachable_bytes: byte_len,
782            view_offset: 0,
783        };
784        let tensor = build_tensor(storage, dtype, storage_offset, shape, stride)?;
785        tensors.insert(key, tensor);
786    }
787
788    Ok(tensors)
789}
790
791fn read_tar_count(cursor: &mut Cursor<&[u8]>, what: &str) -> Result<usize> {
792    let count = read_pickle(cursor, &PersistentIds::Unavailable)?;
793    Ok(non_negative(&count, &format!("TAR {what} count"))?)
794}
795
796/// Read one of the `i64` fields in the TAR binary tables as a `usize`.
797fn read_tar_usize(cursor: &mut Cursor<&[u8]>, what: &str) -> Result<usize> {
798    let value = cursor.read_i64::<LittleEndian>()?;
799    usize::try_from(value).map_err(|_| {
800        PytorchError::InvalidFormat(format!("TAR {what} must be non-negative, got {value}"))
801    })
802}
803
804/// View a TAR metadata pickle as a tuple of exactly `N` fields.
805fn tar_fields<'a, const N: usize>(obj: &'a Object, what: &str) -> Result<&'a [Object; N]> {
806    match obj {
807        Object::Tuple(fields) => <&[Object; N]>::try_from(fields.as_slice()).map_err(|_| {
808            PytorchError::InvalidFormat(format!(
809                "TAR {what} must have {N} fields, got {}",
810                fields.len()
811            ))
812        }),
813        other => Err(PytorchError::InvalidFormat(format!(
814            "TAR {what} must be a tuple, got {other:?}"
815        ))),
816    }
817}
818
819/// Refuse a legacy file that was not written on a little-endian machine.
820///
821/// PyTorch always records a `little_endian` bool here, so a header without a usable one is
822/// malformed rather than a file to guess about: reading big-endian storages as
823/// little-endian would silently transpose every value.
824fn check_little_endian(sys_info: &Object) -> Result<()> {
825    let Object::Dict(dict) = sys_info else {
826        return Err(PytorchError::InvalidFormat(format!(
827            "Legacy sys_info must be a dict, got {sys_info:?}"
828        )));
829    };
830    match dict.get("little_endian") {
831        Some(Object::Bool(true)) => Ok(()),
832        Some(Object::Bool(false)) => Err(big_endian_error()),
833        other => Err(PytorchError::InvalidFormat(format!(
834            "Legacy sys_info must carry a little_endian bool, got {other:?}"
835        ))),
836    }
837}
838
839fn big_endian_error() -> PytorchError {
840    PytorchError::InvalidFormat(
841        "Big-endian PyTorch files are not supported. The file was saved on a big-endian system and requires byte order conversion."
842            .to_string(),
843    )
844}
845
846// ---------------------------------------------------------------------------------------------
847// Root object handling
848// ---------------------------------------------------------------------------------------------
849
850/// Return `root`, or the entry under `key` when one is given.
851fn select_top_level(root: Object, key: Option<&str>) -> Result<Object> {
852    let Some(key) = key else {
853        return Ok(root);
854    };
855    let Object::Dict(mut dict) = root else {
856        return Err(PytorchError::InvalidFormat(format!(
857            "Cannot select top-level key '{key}': the file's root is not a dictionary"
858        )));
859    };
860    dict.remove(key).ok_or_else(|| {
861        let mut available: Vec<_> = dict.keys().cloned().collect();
862        available.sort();
863        PytorchError::KeyNotFound(format!(
864            "'{key}' not found. Available top-level keys: {available:?}"
865        ))
866    })
867}
868
869fn extract_tensors_at(
870    root: Object,
871    top_level_key: Option<&str>,
872) -> Result<HashMap<String, Tensor>> {
873    match select_top_level(root, top_level_key)? {
874        Object::Dict(dict) => Ok(extract_tensors(dict)),
875        other => {
876            let found = other.python_type_name();
877            Err(PytorchError::InvalidFormat(match top_level_key {
878                Some(key) => {
879                    format!("Top-level key '{key}' does not hold a dictionary, found {found}")
880                }
881                None => {
882                    format!("Expected a dictionary at the root of the PyTorch file, found {found}")
883                }
884            }))
885        }
886    }
887}
888
889/// Convert an internal object to the public [`PickleValue`].
890///
891/// Tuples become lists, and anything without a JSON-like counterpart (classes, tensors,
892/// storages, uninterpreted objects) becomes [`PickleValue::Unsupported`] naming its type.
893fn to_pickle_value(obj: Object) -> PickleValue {
894    match obj {
895        Object::None => PickleValue::None,
896        Object::Bool(b) => PickleValue::Bool(b),
897        Object::Int(i) => PickleValue::Int(i),
898        Object::Float(f) => PickleValue::Float(f),
899        Object::String(s) => PickleValue::String(s),
900        Object::Bytes(b) => PickleValue::Bytes(b),
901        Object::List(items) | Object::Tuple(items) => {
902            PickleValue::List(items.into_iter().map(to_pickle_value).collect())
903        }
904        Object::Dict(dict) => PickleValue::Dict(
905            dict.into_iter()
906                .map(|(k, v)| (k, to_pickle_value(v)))
907                .collect(),
908        ),
909        Object::Class { .. } | Object::Storage(_) | Object::Opaque(_) | Object::Tensor(_) => {
910            PickleValue::Unsupported(obj.python_type_name())
911        }
912    }
913}
914
915/// Convert a [`PickleValue`] to a [`NestedValue`] for deserialization.
916fn to_nested_value(value: PickleValue) -> NestedValue {
917    match value {
918        PickleValue::None => NestedValue::Default(None),
919        PickleValue::Bool(b) => NestedValue::Bool(b),
920        PickleValue::Int(i) => NestedValue::I64(i),
921        PickleValue::Float(f) => NestedValue::F64(f),
922        PickleValue::String(s) => NestedValue::String(s),
923        PickleValue::List(list) => {
924            NestedValue::Vec(list.into_iter().map(to_nested_value).collect())
925        }
926        PickleValue::Dict(dict) => NestedValue::Map(
927            dict.into_iter()
928                .map(|(k, v)| (k, to_nested_value(v)))
929                .collect(),
930        ),
931        PickleValue::Bytes(data) => {
932            NestedValue::Vec(data.into_iter().map(NestedValue::U8).collect())
933        }
934        PickleValue::Unsupported(type_name) => NestedValue::Unsupported(type_name),
935    }
936}