Skip to main content

hf_hub/repository/
files.rs

1//! Repository file listing, metadata, download, and upload APIs.
2//!
3//! Common entry points:
4//!
5//! - Use [`HFRepository::list_tree`] to stream file and directory entries, or [`HFRepository::get_paths_info`] /
6//!   [`HFRepository::get_file_metadata`] for targeted lookups.
7//! - Use [`HFRepository::download_file`] to write one file to disk, [`HFRepository::download_file_stream`] to stream
8//!   bytes, [`HFRepository::download_file_to_bytes`] to collect a file into memory, and
9//!   [`HFRepository::snapshot_download`] to fetch a whole revision.
10//! - Use [`HFRepository::upload_file`] and [`HFRepository::upload_folder`] for convenience, or
11//!   [`HFRepository::create_commit`] when you need an explicit set of add/delete operations in one commit.
12//!
13//! The public types in this module are shared across the listing, download, and
14//! upload helpers implemented on [`HFRepository`].
15
16#[cfg(not(target_family = "wasm"))]
17use std::path::PathBuf;
18use std::pin::Pin;
19use std::sync::Arc;
20
21use bytes::Bytes;
22use futures::stream::Stream;
23use serde::{Deserialize, Serialize};
24
25#[allow(unused_imports)] // used by intra-doc links
26use super::HFRepository;
27use crate::constants;
28use crate::error::HFResult;
29
30/// LFS metadata attached to a repository file, when the file is stored in Git LFS.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct BlobLfsInfo {
33    /// Original file size in bytes, when reported by the Hub.
34    pub size: Option<u64>,
35    /// SHA-256 object id of the LFS payload.
36    pub sha256: Option<String>,
37    /// Size in bytes of the LFS pointer file stored in git.
38    pub pointer_size: Option<u64>,
39}
40
41/// Summary of the last commit that touched a tree entry, included when expanded metadata is requested.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43#[serde(rename_all = "camelCase")]
44pub struct LastCommitInfo {
45    /// Commit SHA, when available.
46    pub id: Option<String>,
47    /// Commit title/summary line.
48    pub title: Option<String>,
49    /// Commit timestamp in ISO 8601 format, when available.
50    pub date: Option<String>,
51}
52
53/// Security-scan summary for a file in a repository.
54///
55/// Populated on [`RepoTreeEntry::File`] entries when the listing was requested with `expand=true`.
56#[derive(Debug, Clone, Deserialize, Serialize)]
57#[serde(rename_all = "camelCase")]
58pub struct BlobSecurityInfo {
59    /// Status string reported by the scanner (e.g., `"safe"`, `"unsafe"`, `"suspicious"`). The
60    /// file is considered safe iff `status == "safe"`.
61    pub status: String,
62    /// Antivirus-scan details, when present.
63    #[serde(default)]
64    pub av_scan: Option<serde_json::Value>,
65    /// Pickle-import-scan details, when present.
66    #[serde(default)]
67    pub pickle_import_scan: Option<serde_json::Value>,
68}
69
70/// Metadata returned from a HEAD request on a file's resolve URL.
71///
72/// Produced by [`HFRepository::get_file_metadata`]
73/// and used internally by snapshot downloads.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct FileMetadataInfo {
76    /// Path of the file within the repository.
77    pub filename: String,
78    /// ETag of the file content (normalized, with weak prefix and quotes stripped).
79    pub etag: String,
80    /// Commit hash the revision resolved to (from the `X-Repo-Commit` header).
81    pub commit_hash: String,
82    /// Xet content hash if the file is stored in Xet (from the `X-Xet-Hash` header).
83    pub xet_hash: Option<String>,
84    /// File size in bytes. Falls back to `0` if neither `X-Linked-Size` nor `Content-Length`
85    /// is present on the response.
86    pub file_size: u64,
87    /// Final URL the HEAD request resolved to after redirects (Hub URL or CDN). `None` when no
88    /// redirect was followed and the request URL itself was not preserved.
89    pub location: Option<String>,
90}
91
92/// File or directory entry returned by repository tree/listing APIs.
93#[derive(Debug, Clone, Deserialize, Serialize)]
94#[serde(tag = "type", rename_all = "lowercase")]
95pub enum RepoTreeEntry {
96    /// A file entry in the repository tree.
97    File {
98        /// Object id reported by the Hub for this entry.
99        oid: String,
100        /// File size in bytes.
101        size: u64,
102        /// Repository-relative path.
103        path: String,
104        /// LFS metadata, when the file is LFS-backed.
105        lfs: Option<BlobLfsInfo>,
106        /// Last-commit summary, only when expanded metadata is requested.
107        #[serde(default, rename = "lastCommit")]
108        last_commit: Option<LastCommitInfo>,
109        /// Xet content hash, when the file is Xet-backed.
110        #[serde(default, rename = "xetHash")]
111        xet_hash: Option<String>,
112        /// Security-scan summary for the file, only when expanded metadata is requested.
113        #[serde(default, rename = "securityFileStatus")]
114        security: Option<BlobSecurityInfo>,
115    },
116    /// A directory entry in the repository tree.
117    Directory {
118        /// Object id reported by the Hub for this entry.
119        oid: String,
120        /// Repository-relative path.
121        path: String,
122        /// Last-commit summary, only when expanded metadata is requested.
123        #[serde(default, rename = "lastCommit")]
124        last_commit: Option<LastCommitInfo>,
125    },
126}
127
128/// Response body returned after creating a commit.
129///
130/// Includes URLs for the commit and any PR that was opened, along with the commit OID
131/// when present. Returned by [`HFRepository::create_commit`]
132/// and related upload/delete helpers.
133#[derive(Debug, Clone, Deserialize)]
134#[serde(rename_all = "camelCase")]
135pub struct CommitInfo {
136    /// URL of the created commit on the Hub, when available.
137    #[serde(default)]
138    pub commit_url: Option<String>,
139    /// Commit message recorded for the operation.
140    #[serde(default)]
141    pub commit_message: Option<String>,
142    /// Commit description/body, when provided.
143    #[serde(default)]
144    pub commit_description: Option<String>,
145    /// Commit SHA, when returned by the API.
146    #[serde(default)]
147    pub commit_oid: Option<String>,
148    /// Pull-request URL, when `create_pr` was enabled and a PR was opened.
149    #[serde(default)]
150    pub pr_url: Option<String>,
151    /// Pull-request number, when `create_pr` was enabled and a PR was opened.
152    #[serde(default)]
153    pub pr_num: Option<u64>,
154}
155
156/// File mutation included in [`HFRepository::create_commit`].
157///
158/// Use the constructors ([`add_file`](Self::add_file), [`add_bytes`](Self::add_bytes),
159/// [`delete`](Self::delete)) — they read more linearly than the bare variants and
160/// pick the right [`AddSource`] for you. See [`AddSource`] for the trade-off
161/// between path-backed and in-memory uploads.
162///
163/// ```rust,no_run
164/// use hf_hub::repository::CommitOperation;
165///
166/// let ops = vec![
167///     CommitOperation::add_file("model.safetensors", "/local/path/model.safetensors"),
168///     CommitOperation::add_bytes("config.json", br#"{"vocab_size":50257}"#.as_slice()),
169///     CommitOperation::delete("old/checkpoint.bin"),
170/// ];
171/// # let _ = ops;
172/// ```
173#[derive(Debug, Clone)]
174pub enum CommitOperation {
175    /// Add or replace a file in the repository.
176    Add {
177        /// Destination path within the repository.
178        path_in_repo: String,
179        /// Source of the uploaded contents.
180        source: AddSource,
181    },
182    /// Delete a file from the repository.
183    Delete {
184        /// Repository-relative path to remove.
185        path_in_repo: String,
186    },
187}
188
189impl CommitOperation {
190    /// Add an operation backed by a local file path. Equivalent to
191    /// `Add { path_in_repo, source: AddSource::file(source) }`. Prefer this
192    /// for any non-trivial file size — see [`AddSource::File`] for the streaming
193    /// behavior and memory cost.
194    #[cfg(not(target_family = "wasm"))]
195    pub fn add_file(path_in_repo: impl Into<String>, source: impl Into<PathBuf>) -> Self {
196        CommitOperation::Add {
197            path_in_repo: path_in_repo.into(),
198            source: AddSource::file(source),
199        }
200    }
201
202    /// Add operation backed by in-memory bytes. Equivalent to
203    /// `Add { path_in_repo, source: AddSource::bytes(source) }`. The bytes are
204    /// retained in the operation and re-read at commit time — see
205    /// [`AddSource::Bytes`] for the memory cost.
206    pub fn add_bytes(path_in_repo: impl Into<String>, source: impl Into<Bytes>) -> Self {
207        CommitOperation::Add {
208            path_in_repo: path_in_repo.into(),
209            source: AddSource::bytes(source),
210        }
211    }
212
213    /// Delete operation for a repository path.
214    pub fn delete(path_in_repo: impl Into<String>) -> Self {
215        CommitOperation::Delete {
216            path_in_repo: path_in_repo.into(),
217        }
218    }
219}
220
221/// Stream of byte chunks produced by a [`StreamSource`].
222///
223/// `Send` on native targets; the bound is dropped on wasm, where streams
224/// typically wrap thread-bound JS values.
225#[cfg(not(target_family = "wasm"))]
226pub type SourceByteStream = Pin<Box<dyn Stream<Item = HFResult<Bytes>> + Send>>;
227#[cfg(target_family = "wasm")]
228pub type SourceByteStream = Pin<Box<dyn Stream<Item = HFResult<Bytes>>>>;
229
230/// Factory that produces a fresh [`SourceByteStream`] each time `open` is called.
231///
232/// The upload pipeline reads a source up to three times — a small "sample"
233/// prefix for the preupload classification, the full content for the LFS SHA-256
234/// hash, and the full content for the xet upload — so a one-shot stream isn't
235/// enough. Implementations must be cheap to re-open and produce identical bytes
236/// across opens.
237///
238/// The trait requires `Send + Sync` so that [`AddSource`] (and therefore
239/// `CommitOperation::Add { source }`) can be moved between tasks.
240pub trait StreamFactory: Send + Sync {
241    /// Produce a fresh stream over the source's bytes.
242    fn open(&self) -> SourceByteStream;
243}
244
245/// Source backed by a re-readable stream of byte chunks of known length.
246///
247/// Use this when the bytes are too large to materialize in memory at once.
248/// The upload pipeline only ever holds one chunk in flight at a time.
249#[derive(Clone)]
250pub struct StreamSource {
251    factory: Arc<dyn StreamFactory>,
252    size: u64,
253}
254
255impl StreamSource {
256    /// Construct a [`StreamSource`] from a factory and a known total byte length.
257    ///
258    /// `size` must equal the total number of bytes produced by `factory.open()`,
259    /// which must be the same across every invocation.
260    pub fn new(factory: Arc<dyn StreamFactory>, size: u64) -> Self {
261        Self { factory, size }
262    }
263
264    /// Total number of bytes the stream will produce.
265    pub fn size(&self) -> u64 {
266        self.size
267    }
268
269    /// Open a fresh byte stream from the source.
270    pub fn open(&self) -> SourceByteStream {
271        self.factory.open()
272    }
273}
274
275impl std::fmt::Debug for StreamSource {
276    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
277        f.debug_struct("StreamSource").field("size", &self.size).finish_non_exhaustive()
278    }
279}
280
281/// Source of content for a [`CommitOperation::Add`] operation.
282///
283/// Use [`File`](Self::File) for content already on disk, especially large files:
284/// the path is stored, but file contents are read when the commit runs and are
285/// streamed where possible. Use [`Bytes`](Self::Bytes) for small in-memory content;
286/// the buffer is owned by the operation and may be cloned for LFS/xet uploads.
287///
288/// `AddSource::File` is not a snapshot: the file must still exist, unchanged,
289/// when [`HFRepository::create_commit`] runs.
290#[derive(Debug, Clone)]
291pub enum AddSource {
292    /// Read file contents from this local path at commit time.
293    #[cfg(not(target_family = "wasm"))]
294    File(PathBuf),
295
296    /// In-memory contents used as the file body. Stored as [`bytes::Bytes`] so the
297    /// internal `.clone()` calls along the upload path are cheap refcount bumps
298    /// rather than full memory copies.
299    Bytes(Bytes),
300
301    /// Lazily-read byte stream of known total length. Lets the upload pipeline
302    /// process a source whose total size exceeds available memory: only one
303    /// chunk is held in flight at any time. See [`StreamSource`] for the
304    /// factory contract (must be re-readable to satisfy the sample, hash, and
305    /// upload passes).
306    Stream(StreamSource),
307}
308
309impl AddSource {
310    /// Construct an [`AddSource::File`] from a local file path.
311    #[cfg(not(target_family = "wasm"))]
312    pub fn file(path: impl Into<PathBuf>) -> Self {
313        AddSource::File(path.into())
314    }
315
316    /// Construct an [`AddSource::Bytes`] from in-memory contents.
317    ///
318    /// `bytes` accepts any type that converts to [`bytes::Bytes`]: `Vec<u8>` is
319    /// taken by value with no copy (via `Bytes::from`), `&'static [u8]` /
320    /// `&'static str` are wrapped without copying, and an existing [`Bytes`]
321    /// passes through unchanged.
322    pub fn bytes(bytes: impl Into<Bytes>) -> Self {
323        AddSource::Bytes(bytes.into())
324    }
325
326    /// Construct an [`AddSource::Stream`] from a [`StreamSource`].
327    ///
328    /// The total length must be known up-front and the factory must produce
329    /// identical bytes on every `open()` — see [`StreamSource::new`].
330    pub fn stream(source: StreamSource) -> Self {
331        AddSource::Stream(source)
332    }
333}
334
335pub(super) fn extract_etag(response: &reqwest::Response) -> Option<String> {
336    let headers = response.headers();
337    let raw = headers
338        .get(constants::HEADER_X_LINKED_ETAG)
339        .or_else(|| headers.get(reqwest::header::ETAG))
340        .and_then(|v| v.to_str().ok())?;
341    let normalized = raw.strip_prefix("W/").unwrap_or(raw);
342    Some(normalized.trim_matches('"').to_string())
343}
344
345pub(super) fn extract_commit_hash(response: &reqwest::Response) -> Option<String> {
346    response
347        .headers()
348        .get(constants::HEADER_X_REPO_COMMIT)
349        .and_then(|v| v.to_str().ok())
350        .map(|s| s.to_string())
351}
352
353pub(crate) fn extract_file_size(response: &reqwest::Response) -> Option<u64> {
354    let headers = response.headers();
355    headers
356        .get(constants::HEADER_X_LINKED_SIZE)
357        .or_else(|| headers.get(reqwest::header::CONTENT_LENGTH))
358        .and_then(|v| v.to_str().ok())
359        .and_then(|v| v.parse().ok())
360}
361
362pub(crate) fn extract_xet_hash(response: &reqwest::Response) -> Option<String> {
363    response
364        .headers()
365        .get(constants::HEADER_X_XET_HASH)
366        .and_then(|v| v.to_str().ok())
367        .map(|s| s.to_string())
368}
369
370/// Check if a path matches any of the given glob patterns using the `globset` crate.
371pub(super) fn matches_any_glob(patterns: &[String], path: &str) -> bool {
372    patterns.iter().any(|p| {
373        globset::Glob::new(p)
374            .ok()
375            .map(|g| g.compile_matcher().is_match(path))
376            .unwrap_or(false)
377    })
378}
379
380#[cfg(test)]
381mod tests {
382    use super::{BlobSecurityInfo, CommitInfo, FileMetadataInfo, RepoTreeEntry};
383
384    #[test]
385    fn test_repo_tree_entry_deserialize_file() {
386        let json = r#"{"type":"file","oid":"abc123","size":100,"path":"test.txt"}"#;
387        let entry: RepoTreeEntry = serde_json::from_str(json).unwrap();
388        match entry {
389            RepoTreeEntry::File {
390                path,
391                size,
392                xet_hash,
393                security,
394                ..
395            } => {
396                assert_eq!(path, "test.txt");
397                assert_eq!(size, 100);
398                assert!(xet_hash.is_none());
399                assert!(security.is_none());
400            },
401            _ => panic!("Expected File variant"),
402        }
403    }
404
405    #[test]
406    fn test_repo_tree_entry_file_expanded() {
407        let json = r#"{
408            "type":"file","oid":"abc123","size":100,"path":"weights.safetensors",
409            "xetHash":"xet-deadbeef",
410            "securityFileStatus":{"status":"safe","avScan":{"virusFound":false},"pickleImportScan":null},
411            "lastCommit":{"id":"sha","title":"t","date":"2025-01-01T00:00:00Z"}
412        }"#;
413        let entry: RepoTreeEntry = serde_json::from_str(json).unwrap();
414        match entry {
415            RepoTreeEntry::File {
416                xet_hash,
417                security,
418                last_commit,
419                ..
420            } => {
421                assert_eq!(xet_hash.as_deref(), Some("xet-deadbeef"));
422                let security = security.unwrap();
423                assert_eq!(security.status, "safe");
424                assert!(security.av_scan.is_some());
425                assert!(security.pickle_import_scan.is_none());
426                assert!(last_commit.is_some());
427            },
428            _ => panic!("Expected File variant"),
429        }
430    }
431
432    #[test]
433    fn test_repo_tree_entry_directory_with_last_commit() {
434        let json = r#"{"type":"directory","oid":"def456","path":"src","lastCommit":{"id":"sha","title":"t","date":"2025-01-01T00:00:00Z"}}"#;
435        let entry: RepoTreeEntry = serde_json::from_str(json).unwrap();
436        match entry {
437            RepoTreeEntry::Directory { path, last_commit, .. } => {
438                assert_eq!(path, "src");
439                assert!(last_commit.is_some());
440            },
441            _ => panic!("Expected Directory variant"),
442        }
443    }
444
445    #[test]
446    fn test_repo_tree_entry_deserialize_directory() {
447        let json = r#"{"type":"directory","oid":"def456","path":"src"}"#;
448        let entry: RepoTreeEntry = serde_json::from_str(json).unwrap();
449        match entry {
450            RepoTreeEntry::Directory { path, last_commit, .. } => {
451                assert_eq!(path, "src");
452                assert!(last_commit.is_none());
453            },
454            _ => panic!("Expected Directory variant"),
455        }
456    }
457
458    #[test]
459    fn test_blob_security_info_unsafe_status() {
460        let json = r#"{"status":"suspicious","avScan":null,"pickleImportScan":{"matches":[]}}"#;
461        let info: BlobSecurityInfo = serde_json::from_str(json).unwrap();
462        assert_eq!(info.status, "suspicious");
463        assert!(info.av_scan.is_none());
464        assert!(info.pickle_import_scan.is_some());
465    }
466
467    #[test]
468    fn test_commit_info_with_pr() {
469        let json = r#"{
470            "commitUrl":"https://huggingface.co/owner/repo/commit/abc123",
471            "commitOid":"abc123",
472            "prUrl":"https://huggingface.co/owner/repo/discussions/7",
473            "prNum":7
474        }"#;
475        let info: CommitInfo = serde_json::from_str(json).unwrap();
476        assert_eq!(info.commit_oid.as_deref(), Some("abc123"));
477        assert_eq!(info.pr_url.as_deref(), Some("https://huggingface.co/owner/repo/discussions/7"));
478        assert_eq!(info.pr_num, Some(7));
479    }
480
481    #[test]
482    fn test_commit_info_no_pr() {
483        let json = r#"{"commitUrl":"https://huggingface.co/owner/repo/commit/abc","commitOid":"abc"}"#;
484        let info: CommitInfo = serde_json::from_str(json).unwrap();
485        assert_eq!(info.commit_url.as_deref(), Some("https://huggingface.co/owner/repo/commit/abc"));
486        assert!(info.pr_url.is_none());
487        assert!(info.pr_num.is_none());
488    }
489
490    #[test]
491    fn test_file_metadata_info_location_round_trip() {
492        let original = FileMetadataInfo {
493            filename: "config.json".into(),
494            etag: "abc".into(),
495            commit_hash: "deadbeef".into(),
496            xet_hash: None,
497            file_size: 100,
498            location: Some("https://cdn-lfs.huggingface.co/repos/.../config.json".into()),
499        };
500        let json = serde_json::to_string(&original).unwrap();
501        assert!(json.contains("location"));
502        let round_tripped: FileMetadataInfo = serde_json::from_str(&json).unwrap();
503        assert_eq!(round_tripped.location, original.location);
504    }
505
506    #[test]
507    fn test_file_metadata_info_location_optional() {
508        let json = r#"{"filename":"f","etag":"e","commit_hash":"c","xet_hash":null,"file_size":0}"#;
509        let info: FileMetadataInfo = serde_json::from_str(json).unwrap();
510        assert!(info.location.is_none());
511    }
512}