1use 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#[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 let mut directories: HashMap<PathBuf, Directory> = HashMap::default();
51 let mut maybe_directory_putter: Option<Box<dyn DirectoryPutter>> = None;
52
53 let mut sent_directories: HashSet<B3Digest> = HashSet::new();
56
57 let root_node = loop {
58 let entry = entries
59 .next()
60 .await
61 .ok_or(IngestionError::UnexpectedEndOfStream)??;
64
65 let (path, node) = match entry {
66 IngestionEntry::Dir { path } => {
67 let directory = directories
72 .remove(&path)
73 .unwrap_or_default();
75
76 let directory_size = directory.size();
77 let directory_digest = directory.digest();
78
79 let directory_putter = maybe_directory_putter
81 .get_or_insert_with(|| directory_service.put_multiple_start());
82
83 if let hash_set::Entry::Vacant(vacant_entry) =
85 sent_directories.entry(directory_digest)
86 {
87 if let Err(e) = directory_putter.put(directory).await {
89 return Err(IngestionError::UploadDirectoryError(path, e));
90 }
91 vacant_entry.insert();
93 }
94
95 (
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 .unwrap_or_else(|| "".try_into().unwrap())
134 .to_owned();
135
136 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 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 #[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}