Skip to main content

snix_castore/import/
mod.rs

1//! The main library function here is [ingest_entries], receiving a stream of
2//! [IngestionEntry].
3//!
4//! Specific implementations, such as ingesting from the filesystem, live in
5//! child modules.
6
7use crate::directoryservice::{DirectoryPutter, DirectoryService};
8use crate::path::{Path, PathBuf};
9use crate::{B3Digest, Directory, Node};
10use futures::{Stream, StreamExt};
11use tracing::Level;
12
13use hashbrown::{HashMap, HashSet, hash_set};
14use tracing::instrument;
15
16mod error;
17pub use error::IngestionError;
18
19pub mod archive;
20pub mod fs;
21
22/// Ingests [IngestionEntry] from the given stream into a the passed [DirectoryService].
23/// On success, returns the root [Node].
24///
25/// The stream must have the following invariants:
26/// - All children entries must come before their parents.
27/// - The last entry must be the root node which must have a single path component.
28/// - Every entry should have a unique path, and only consist of normal components.
29///   This means, no windows path prefixes, absolute paths, `.` or `..`.
30/// - All referenced directories must have an associated directory entry in the stream.
31///   This means if there is a file entry for `foo/bar`, there must also be a `foo` directory
32///   entry.
33///
34/// Internally we maintain a [HashMap] of [PathBuf] to partially populated [Directory] at that
35/// path. Once we receive an [IngestionEntry] for the directory itself, we remove it from the
36/// map and upload it to the [DirectoryService] through a lazily created [DirectoryPutter].
37///
38/// On success, returns the root node.
39#[instrument(skip_all, ret(level = Level::TRACE), err)]
40pub async fn ingest_entries<DS, S, E>(
41    directory_service: DS,
42    mut entries: S,
43) -> Result<Node, IngestionError<E>>
44where
45    DS: DirectoryService,
46    S: Stream<Item = Result<IngestionEntry, E>> + Send + std::marker::Unpin,
47    E: std::error::Error,
48{
49    // For a given path, this holds the [Directory] structs as they are populated.
50    let mut directories: HashMap<PathBuf, Directory> = HashMap::default();
51    let mut maybe_directory_putter: Option<Box<dyn DirectoryPutter>> = None;
52
53    // Directory digests we already sent out. When ingesting a tree containing two identical subtrees,
54    // we don't want to send these directories twice.
55    let mut sent_directories: HashSet<B3Digest> = HashSet::new();
56
57    let root_node = loop {
58        let entry = entries
59            .next()
60            .await
61            // The last entry of the stream must have 1 path component, after which
62            // we break the loop manually.
63            .ok_or(IngestionError::UnexpectedEndOfStream)??;
64
65        let (path, node) = match entry {
66            IngestionEntry::Dir { path } => {
67                // If the entry is a directory, we traversed all its children (and
68                // populated it in `directories`).
69                // If we don't have it in directories, it's a directory without
70                // children.
71                let directory = directories
72                    .remove(&path)
73                    // In that case, it contained no children
74                    .unwrap_or_default();
75
76                let directory_size = directory.size();
77                let directory_digest = directory.digest();
78
79                // Get a directory putter, or create a new one if this is the first directory uploaded.
80                let directory_putter = maybe_directory_putter
81                    .get_or_insert_with(|| directory_service.put_multiple_start());
82
83                // Use the directory_putter to upload the directory, if we didn't upload it yet.
84                if let hash_set::Entry::Vacant(vacant_entry) =
85                    sent_directories.entry(directory_digest)
86                {
87                    // upload, ...
88                    if let Err(e) = directory_putter.put(directory).await {
89                        return Err(IngestionError::UploadDirectoryError(path, e));
90                    }
91                    // and mark in sent_directories.
92                    vacant_entry.insert();
93                }
94
95                // return the Node::Directory, so it can be used in its parents.
96                (
97                    path,
98                    Node::Directory {
99                        digest: directory_digest,
100                        size: directory_size,
101                    },
102                )
103            }
104            IngestionEntry::Symlink { path, target } => {
105                let target: crate::SymlinkTarget = bytes::Bytes::from(target)
106                    .try_into()
107                    .map_err(|err| IngestionError::InvalidSymlinkTarget(path.clone(), err))?;
108                (path, Node::Symlink { target })
109            }
110            IngestionEntry::Regular {
111                path,
112                size,
113                executable,
114                digest,
115            } => (
116                path,
117                Node::File {
118                    digest,
119                    size,
120                    executable,
121                },
122            ),
123        };
124
125        let parent = path.parent().expect("Snix bug: got entry with root node");
126
127        if parent == crate::Path::ROOT {
128            break node;
129        } else {
130            let name = path
131                .file_name()
132                // If this is the root node, it will have an empty name.
133                .unwrap_or_else(|| "".try_into().unwrap())
134                .to_owned();
135
136            // record node in parent directory, creating a new [Directory] if not there yet.
137            directories
138                .entry_ref(parent)
139                .or_default()
140                .add(name, node)
141                .map_err(|e| IngestionError::ConstructDirectoryError(path, e))?;
142        }
143    };
144
145    assert!(
146        entries.count().await == 0,
147        "Snix bug: left over elements in the stream"
148    );
149
150    assert!(
151        directories.is_empty(),
152        "Snix bug: left over directories after processing ingestion stream"
153    );
154
155    // if there were directories uploaded, make sure we flush the putter, so
156    // they're all persisted to the backend.
157    if let Some(mut directory_putter) = maybe_directory_putter {
158        #[cfg_attr(not(debug_assertions), allow(unused))]
159        let root_directory_digest = directory_putter
160            .close()
161            .await
162            .map_err(|e| IngestionError::FinalizeDirectoryUpload(e))?;
163
164        #[cfg(debug_assertions)]
165        {
166            if let Node::Directory { digest, .. } = &root_node {
167                debug_assert_eq!(&root_directory_digest, digest);
168            } else {
169                unreachable!("Snix bug: directory putter initialized but no root directory node");
170            }
171        }
172    };
173
174    Ok(root_node)
175}
176
177#[derive(Debug, Clone, Eq, PartialEq)]
178pub enum IngestionEntry {
179    Regular {
180        path: PathBuf,
181        size: u64,
182        executable: bool,
183        digest: B3Digest,
184    },
185    Symlink {
186        path: PathBuf,
187        target: Vec<u8>,
188    },
189    Dir {
190        path: PathBuf,
191    },
192}
193
194impl IngestionEntry {
195    fn path(&self) -> &Path {
196        match self {
197            IngestionEntry::Regular { path, .. } => path,
198            IngestionEntry::Symlink { path, .. } => path,
199            IngestionEntry::Dir { path } => path,
200        }
201    }
202
203    fn is_dir(&self) -> bool {
204        matches!(self, IngestionEntry::Dir { .. })
205    }
206}
207
208#[cfg(test)]
209mod test {
210    use rstest::rstest;
211
212    use crate::fixtures::DUMMY_DIGEST;
213    use crate::fixtures::{DIRECTORY_COMPLICATED, DIRECTORY_WITH_KEEP, EMPTY_BLOB_DIGEST};
214    use crate::utils::gen_test_directory_service;
215    use crate::{Directory, Node};
216
217    use super::IngestionEntry;
218    use super::ingest_entries;
219
220    #[rstest]
221    #[case::single_file(vec![IngestionEntry::Regular {
222        path: "foo".parse().unwrap(),
223        size: 42,
224        executable: true,
225        digest: *DUMMY_DIGEST,
226    }],
227        Node::File{digest: *DUMMY_DIGEST, size: 42, executable: true}
228    )]
229    #[case::single_symlink(vec![IngestionEntry::Symlink {
230        path: "foo".parse().unwrap(),
231        target: b"blub".into(),
232    }],
233        Node::Symlink{target: "blub".try_into().unwrap()}
234    )]
235    #[case::single_dir(vec![IngestionEntry::Dir {
236        path: "foo".parse().unwrap(),
237    }],
238        Node::Directory{digest: Directory::default().digest(), size: Directory::default().size()}
239    )]
240    #[case::dir_with_keep(vec![
241        IngestionEntry::Regular {
242            path: "foo/.keep".parse().unwrap(),
243            size: 0,
244            executable: false,
245            digest: *EMPTY_BLOB_DIGEST,
246        },
247        IngestionEntry::Dir {
248            path: "foo".parse().unwrap(),
249        },
250    ],
251        Node::Directory{ digest: DIRECTORY_WITH_KEEP.digest(), size: DIRECTORY_WITH_KEEP.size()}
252    )]
253    /// This is intentionally a bit unsorted, though it still satisfies all
254    /// requirements we have on the order of elements in the stream.
255    #[case::directory_complicated(vec![
256        IngestionEntry::Regular {
257            path: "blub/.keep".parse().unwrap(),
258            size: 0,
259            executable: false,
260            digest: *EMPTY_BLOB_DIGEST,
261        },
262        IngestionEntry::Regular {
263            path: "blub/keep/.keep".parse().unwrap(),
264            size: 0,
265            executable: false,
266            digest: *EMPTY_BLOB_DIGEST,
267        },
268        IngestionEntry::Dir {
269            path: "blub/keep".parse().unwrap(),
270        },
271        IngestionEntry::Symlink {
272            path: "blub/aa".parse().unwrap(),
273            target: b"/nix/store/somewhereelse".into(),
274        },
275        IngestionEntry::Dir {
276            path: "blub".parse().unwrap(),
277        },
278    ],
279    Node::Directory{ digest: DIRECTORY_COMPLICATED.digest(), size: DIRECTORY_COMPLICATED.size() }
280    )]
281    #[tokio::test]
282    async fn test_ingestion(#[case] entries: Vec<IngestionEntry>, #[case] exp_root_node: Node) {
283        let root_node = ingest_entries(
284            gen_test_directory_service(),
285            futures::stream::iter(entries.into_iter().map(Ok::<_, std::io::Error>)),
286        )
287        .await
288        .expect("must succeed");
289
290        assert_eq!(exp_root_node, root_node, "root node should match");
291    }
292
293    #[rstest]
294    #[case::empty_entries(vec![])]
295    #[case::missing_intermediate_dir(vec![
296        IngestionEntry::Regular {
297            path: "blub/.keep".parse().unwrap(),
298            size: 0,
299            executable: false,
300            digest: *EMPTY_BLOB_DIGEST,
301        },
302    ])]
303    #[tokio::test]
304    async fn test_end_of_stream(#[case] entries: Vec<IngestionEntry>) {
305        use crate::import::IngestionError;
306
307        let result = ingest_entries(
308            gen_test_directory_service(),
309            futures::stream::iter(entries.into_iter().map(Ok::<_, std::io::Error>)),
310        )
311        .await;
312        assert!(matches!(result, Err(IngestionError::UnexpectedEndOfStream)));
313    }
314
315    #[rstest]
316    #[should_panic]
317    #[case::leaf_after_parent(vec![
318        IngestionEntry::Dir {
319            path: "blub".parse().unwrap(),
320        },
321        IngestionEntry::Regular {
322            path: "blub/.keep".parse().unwrap(),
323            size: 0,
324            executable: false,
325            digest: *EMPTY_BLOB_DIGEST,
326        },
327    ])]
328    #[should_panic]
329    #[case::root_in_entry(vec![
330        IngestionEntry::Regular {
331            path: ".keep".parse().unwrap(),
332            size: 0,
333            executable: false,
334            digest: *EMPTY_BLOB_DIGEST,
335        },
336        IngestionEntry::Dir {
337            path: "".parse().unwrap(),
338        },
339    ])]
340    #[tokio::test]
341    async fn test_ingestion_fail(#[case] entries: Vec<IngestionEntry>) {
342        let _ = ingest_entries(
343            gen_test_directory_service(),
344            futures::stream::iter(entries.into_iter().map(Ok::<_, std::io::Error>)),
345        )
346        .await;
347    }
348}