Skip to main content

snix_castore/import/
archive.rs

1//! Imports from an archive (tarballs)
2
3use std::collections::HashMap;
4
5use petgraph::Direction;
6use petgraph::graph::{DiGraph, NodeIndex};
7use petgraph::visit::{DfsPostOrder, EdgeRef};
8use tokio::io::AsyncRead;
9use tokio_stream::StreamExt;
10use tracing::{Level, instrument, warn};
11
12use crate::Node;
13use crate::blobservice::BlobService;
14use crate::directoryservice::DirectoryService;
15use crate::import::{IngestionEntry, IngestionError, ingest_entries};
16
17use super::blobs::{self, ConcurrentBlobUploader};
18
19type TarPathBuf = std::path::PathBuf;
20
21#[derive(Debug, thiserror::Error)]
22pub enum Error {
23    #[error("unable to construct stream of entries: {0}")]
24    Entries(std::io::Error),
25
26    #[error("unable to read next entry: {0}")]
27    NextEntry(std::io::Error),
28
29    #[error("unable to read path for entry: {0}")]
30    PathRead(std::io::Error),
31
32    #[error("unable to convert path {0} for entry: {1}")]
33    PathConvert(TarPathBuf, std::io::Error),
34
35    #[error("unable to read size field for {0}: {1}")]
36    Size(TarPathBuf, std::io::Error),
37
38    #[error("unable to read mode field for {0}: {1}")]
39    Mode(TarPathBuf, std::io::Error),
40
41    #[error("unable to read link name field for {0}: {1}")]
42    LinkName(TarPathBuf, std::io::Error),
43
44    #[error("unsupported tar entry {0} type: {1:?}")]
45    EntryType(TarPathBuf, tokio_tar::EntryType),
46
47    #[error("symlink missing target {0}")]
48    MissingSymlinkTarget(TarPathBuf),
49
50    #[error("unexpected number of top level directory entries")]
51    UnexpectedNumberOfTopLevelEntries,
52
53    #[error(transparent)]
54    BlobUploadError(#[from] blobs::Error),
55}
56
57/// Ingests elements from the archive readable at the passed reader into a the
58/// passed [`BlobService`] and [`DirectoryService`].
59#[instrument(skip_all, ret(level = Level::TRACE), err)]
60pub async fn ingest_archive<BS, DS, R>(
61    blob_service: BS,
62    directory_service: DS,
63    mut r: R,
64) -> Result<Node, IngestionError<Error>>
65where
66    BS: BlobService + Clone + 'static,
67    DS: DirectoryService,
68    R: AsyncRead + Unpin,
69{
70    // Open the archive.
71    let mut archive = tokio_tar::Archive::new(&mut r);
72
73    // Since tarballs can have entries in any arbitrary order, we need to
74    // buffer all of the directory metadata so we can reorder directory
75    // contents and entries to meet the requires of the castore.
76
77    // In the first phase, collect up all the regular files and symlinks.
78    let mut nodes = IngestionEntryGraph::new();
79
80    let mut blob_uploader = ConcurrentBlobUploader::new(blob_service);
81
82    let mut entries_iter = archive.entries().map_err(Error::Entries)?;
83    while let Some(mut entry) = entries_iter.try_next().await.map_err(Error::NextEntry)? {
84        let tar_path: TarPathBuf = entry.path().map_err(Error::PathRead)?.into();
85
86        // construct a castore PathBuf, which we use in the produced IngestionEntry.
87        let path = crate::path::PathBuf::from_host_path(tar_path.as_path(), true)
88            .map_err(|e| Error::PathConvert(tar_path.clone(), e))?;
89
90        let header = entry.header();
91        let entry = match header.entry_type() {
92            tokio_tar::EntryType::Regular
93            | tokio_tar::EntryType::GNUSparse
94            | tokio_tar::EntryType::Continuous => {
95                let size = header
96                    .size()
97                    .map_err(|e| Error::Size(tar_path.clone(), e))?;
98
99                let digest = blob_uploader
100                    .upload(&path, size, &mut entry)
101                    .await
102                    .map_err(Error::BlobUploadError)?;
103
104                let executable = entry
105                    .header()
106                    .mode()
107                    .map_err(|e| Error::Mode(tar_path, e))?
108                    & 64
109                    != 0;
110
111                IngestionEntry::Regular {
112                    path,
113                    size,
114                    executable,
115                    digest,
116                }
117            }
118            tokio_tar::EntryType::Symlink => IngestionEntry::Symlink {
119                target: entry
120                    .link_name()
121                    .map_err(|e| Error::LinkName(tar_path.clone(), e))?
122                    .ok_or_else(|| Error::MissingSymlinkTarget(tar_path.clone()))?
123                    .into_owned()
124                    .into_os_string()
125                    .into_encoded_bytes(),
126                path,
127            },
128            // Push a bogus directory marker so we can make sure this directoy gets
129            // created. We don't know the digest and size until after reading the full
130            // tarball.
131            tokio_tar::EntryType::Directory => IngestionEntry::Dir { path },
132
133            tokio_tar::EntryType::XGlobalHeader | tokio_tar::EntryType::XHeader => continue,
134
135            entry_type => return Err(Error::EntryType(tar_path, entry_type).into()),
136        };
137
138        nodes.add(entry)?;
139    }
140
141    blob_uploader.join().await.map_err(Error::BlobUploadError)?;
142
143    let root_node = ingest_entries(
144        directory_service,
145        futures::stream::iter(nodes.finalize()?.into_iter().map(Ok)),
146    )
147    .await?;
148
149    Ok(root_node)
150}
151
152/// Keep track of the directory structure of a file tree being ingested. This is used
153/// for ingestion sources which do not provide any ordering or uniqueness guarantees
154/// like tarballs.
155///
156/// If we ingest multiple entries with the same paths and both entries are not directories,
157/// the newer entry will replace the latter entry, disconnecting the old node's children
158/// from the graph.
159///
160/// Once all nodes are ingested a call to [IngestionEntryGraph::finalize] will return
161/// a list of entries compute by performaing a DFS post order traversal of the graph
162/// from the top-level directory entry.
163///
164/// This expects the directory structure to contain a single top-level directory entry.
165/// An error is returned if this is not the case and ingestion will fail.
166struct IngestionEntryGraph {
167    graph: DiGraph<IngestionEntry, ()>,
168    path_to_index: HashMap<crate::path::PathBuf, NodeIndex>,
169    root_node: Option<NodeIndex>,
170}
171
172impl Default for IngestionEntryGraph {
173    fn default() -> Self {
174        Self::new()
175    }
176}
177
178impl IngestionEntryGraph {
179    /// Creates a new ingestion entry graph.
180    pub fn new() -> Self {
181        IngestionEntryGraph {
182            graph: DiGraph::new(),
183            path_to_index: HashMap::new(),
184            root_node: None,
185        }
186    }
187
188    /// Adds a new entry to the graph. Parent directories are automatically inserted.
189    /// If a node exists in the graph with the same name as the new entry and both the old
190    /// and new nodes are not directories, the node is replaced and is disconnected from its
191    /// children.
192    pub fn add(&mut self, entry: IngestionEntry) -> Result<NodeIndex, Error> {
193        let path = entry.path().to_owned();
194
195        let index = match self.path_to_index.get(entry.path()) {
196            Some(&index) => {
197                // If either the old entry or new entry are not directories, we'll replace the old
198                // entry.
199                if !entry.is_dir() || !self.get_node(index).is_dir() {
200                    self.replace_node(index, entry);
201                }
202
203                index
204            }
205            None => self.graph.add_node(entry),
206        };
207
208        // for archives, a path with 1 component is the root node
209        if path.components().count() == 1 {
210            // We expect archives to contain a single root node, if there is another root node
211            // entry with a different path name, this is unsupported.
212            if let Some(root_node) = self.root_node
213                && self.get_node(root_node).path() != path.as_ref()
214            {
215                return Err(Error::UnexpectedNumberOfTopLevelEntries);
216            }
217
218            self.root_node = Some(index)
219        } else if let Some(parent_path) = path.parent() {
220            // Recursively add the parent node until it hits the root node.
221            let parent_index = self.add(IngestionEntry::Dir {
222                path: parent_path.to_owned(),
223            })?;
224
225            // Insert an edge from the parent directory to the child entry.
226            self.graph.add_edge(parent_index, index, ());
227        }
228
229        self.path_to_index.insert(path, index);
230
231        Ok(index)
232    }
233
234    /// Traverses the graph in DFS post order and collects the entries into a [`Vec<IngestionEntry>`].
235    ///
236    /// Unreachable parts of the graph are not included in the result.
237    pub fn finalize(self) -> Result<Vec<IngestionEntry>, Error> {
238        // There must be a root node.
239        let Some(root_node_index) = self.root_node else {
240            return Err(Error::UnexpectedNumberOfTopLevelEntries);
241        };
242
243        // The root node must be a directory.
244        if !self.get_node(root_node_index).is_dir() {
245            return Err(Error::UnexpectedNumberOfTopLevelEntries);
246        }
247
248        let mut traversal = DfsPostOrder::new(&self.graph, root_node_index);
249        let mut nodes = Vec::with_capacity(self.graph.node_count());
250        while let Some(node_index) = traversal.next(&self.graph) {
251            nodes.push(self.get_node(node_index).clone());
252        }
253
254        Ok(nodes)
255    }
256
257    /// Replaces the node with the specified entry. The node's children are disconnected.
258    ///
259    /// This should never be called if both the old and new nodes are directories.
260    fn replace_node(&mut self, index: NodeIndex, new_entry: IngestionEntry) {
261        let entry = self
262            .graph
263            .node_weight_mut(index)
264            .expect("Snix bug: missing node entry");
265
266        debug_assert!(!(entry.is_dir() && new_entry.is_dir()));
267
268        // Replace the node itself.
269        warn!(
270            "saw duplicate entry in archive at path {:?}. old: {:?} new: {:?}",
271            entry.path(),
272            &entry,
273            &new_entry
274        );
275        *entry = new_entry;
276
277        // Remove any outgoing edges to disconnect the old node's children.
278        let edges = self
279            .graph
280            .edges_directed(index, Direction::Outgoing)
281            .map(|edge| edge.id())
282            .collect::<Vec<_>>();
283        for edge in edges {
284            self.graph.remove_edge(edge);
285        }
286    }
287
288    fn get_node(&self, index: NodeIndex) -> &IngestionEntry {
289        self.graph
290            .node_weight(index)
291            .expect("Snix bug: missing node entry")
292    }
293}
294
295#[cfg(test)]
296mod test {
297    use std::sync::LazyLock;
298
299    use super::{Error, IngestionEntryGraph};
300    use crate::B3Digest;
301    use crate::import::IngestionEntry;
302
303    use rstest::rstest;
304
305    pub static EMPTY_DIGEST: LazyLock<B3Digest> =
306        LazyLock::new(|| blake3::hash(&[]).as_bytes().into());
307    pub static DIR_A: LazyLock<IngestionEntry> = LazyLock::new(|| IngestionEntry::Dir {
308        path: "a".parse().unwrap(),
309    });
310    pub static DIR_B: LazyLock<IngestionEntry> = LazyLock::new(|| IngestionEntry::Dir {
311        path: "b".parse().unwrap(),
312    });
313    pub static DIR_A_B: LazyLock<IngestionEntry> = LazyLock::new(|| IngestionEntry::Dir {
314        path: "a/b".parse().unwrap(),
315    });
316    pub static FILE_A: LazyLock<IngestionEntry> = LazyLock::new(|| IngestionEntry::Regular {
317        path: "a".parse().unwrap(),
318        size: 0,
319        executable: false,
320        digest: *EMPTY_DIGEST,
321    });
322    pub static FILE_A_B: LazyLock<IngestionEntry> = LazyLock::new(|| IngestionEntry::Regular {
323        path: "a/b".parse().unwrap(),
324        size: 0,
325        executable: false,
326        digest: *EMPTY_DIGEST,
327    });
328    pub static FILE_A_B_C: LazyLock<IngestionEntry> = LazyLock::new(|| IngestionEntry::Regular {
329        path: "a/b/c".parse().unwrap(),
330        size: 0,
331        executable: false,
332        digest: *EMPTY_DIGEST,
333    });
334
335    #[rstest]
336    #[case::implicit_directories(&[&*FILE_A_B_C], &[&*FILE_A_B_C, &*DIR_A_B, &*DIR_A])]
337    #[case::explicit_directories(&[&*DIR_A, &*DIR_A_B, &*FILE_A_B_C], &[&*FILE_A_B_C, &*DIR_A_B, &*DIR_A])]
338    #[case::inaccessible_tree(&[&*DIR_A, &*DIR_A_B, &*FILE_A_B], &[&*FILE_A_B, &*DIR_A])]
339    fn node_ingestion_success(
340        #[case] in_entries: &[&IngestionEntry],
341        #[case] exp_entries: &[&IngestionEntry],
342    ) {
343        let mut nodes = IngestionEntryGraph::new();
344
345        for entry in in_entries {
346            nodes.add((*entry).clone()).expect("failed to add entry");
347        }
348
349        let entries = nodes.finalize().expect("invalid entries");
350
351        let exp_entries: Vec<IngestionEntry> =
352            exp_entries.iter().map(|entry| (*entry).clone()).collect();
353
354        assert_eq!(entries, exp_entries);
355    }
356
357    #[rstest]
358    #[case::no_top_level_entries(&[], Error::UnexpectedNumberOfTopLevelEntries)]
359    #[case::multiple_top_level_dirs(&[&*DIR_A, &*DIR_B], Error::UnexpectedNumberOfTopLevelEntries)]
360    #[case::top_level_file_entry(&[&*FILE_A], Error::UnexpectedNumberOfTopLevelEntries)]
361    fn node_ingestion_error(#[case] in_entries: &[&IngestionEntry], #[case] exp_error: Error) {
362        let mut nodes = IngestionEntryGraph::new();
363
364        let result = (|| {
365            for entry in in_entries {
366                nodes.add((*entry).clone())?;
367            }
368            nodes.finalize()
369        })();
370
371        let error = result.expect_err("expected error");
372        assert_eq!(error.to_string(), exp_error.to_string());
373    }
374}