1use 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#[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 let mut archive = tokio_tar::Archive::new(&mut r);
72
73 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 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 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
152struct 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 pub fn new() -> Self {
181 IngestionEntryGraph {
182 graph: DiGraph::new(),
183 path_to_index: HashMap::new(),
184 root_node: None,
185 }
186 }
187
188 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 !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 if path.components().count() == 1 {
210 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 let parent_index = self.add(IngestionEntry::Dir {
222 path: parent_path.to_owned(),
223 })?;
224
225 self.graph.add_edge(parent_index, index, ());
227 }
228
229 self.path_to_index.insert(path, index);
230
231 Ok(index)
232 }
233
234 pub fn finalize(self) -> Result<Vec<IngestionEntry>, Error> {
238 let Some(root_node_index) = self.root_node else {
240 return Err(Error::UnexpectedNumberOfTopLevelEntries);
241 };
242
243 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 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 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 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}