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