1use std::collections::BTreeMap;
24use std::io::Read;
25use std::path::PathBuf;
26
27use crate::chunker::Chunker;
28use crate::classifier;
29use crate::config::WriteConfig;
30use crate::{
31 encode_dir_node, hash_section, PendingContent, PendingFile, PendingInode, TournamentSpec,
32 WriteArtifact, WriteContext, WriteError,
33};
34
35struct StreamCodecs {
38 chunker: crate::chunker::ParallelFastCDC,
39 classifier: classifier::Classifier,
40 text_codec: u8,
41 binary_codec: u8,
42 tunables: limnifs_core::codec::CodecTunables,
43 tournament: TournamentSpec,
44}
45
46impl StreamCodecs {
47 fn from_config(
48 chunker: crate::chunker::ParallelFastCDC,
49 classifier: classifier::Classifier,
50 config: &WriteConfig,
51 ) -> Result<Self, WriteError> {
52 let registry = config
53 .codec_registry()
54 .map_err(|e| WriteError::Io(std::io::Error::other(format!("codec registry: {e}"))))?;
55 let tournament_codec_ids: Vec<u8> = config
56 .tournament
57 .codecs
58 .iter()
59 .filter_map(|n| registry.lookup_by_name(n))
60 .collect();
61 Ok(Self {
62 chunker,
63 classifier,
64 text_codec: config.text_codec_id().unwrap_or(0x04),
65 binary_codec: config.binary_codec_id().unwrap_or(0x01),
66 tunables: config.to_core_tunables(),
67 tournament: TournamentSpec {
68 codec_ids: tournament_codec_ids,
69 min_size: config.tournament.min_size_threshold as usize,
70 skip_for_binary: config.tournament.skip_for_binary,
71 short_circuit_permille: config.tournament.short_circuit_threshold,
72 },
73 })
74 }
75}
76
77#[derive(Clone, Copy, Debug)]
83pub struct EntryMeta {
84 pub mtime_ns: u64,
85 pub perms: u32,
86 pub uid: u32,
87 pub gid: u32,
88}
89
90impl EntryMeta {
91 #[must_use]
93 pub const fn new(mtime_ns: u64, perms: u32) -> Self {
94 Self {
95 mtime_ns,
96 perms,
97 uid: 0,
98 gid: 0,
99 }
100 }
101
102 #[must_use]
104 pub const fn owner(mut self, uid: u32, gid: u32) -> Self {
105 self.uid = uid;
106 self.gid = gid;
107 self
108 }
109}
110
111struct StreamDir {
115 meta: EntryMeta,
116 children: BTreeMap<String, StreamNode>,
117}
118
119impl Default for StreamDir {
120 fn default() -> Self {
121 Self {
122 meta: EntryMeta::new(0, 0o755),
123 children: BTreeMap::new(),
124 }
125 }
126}
127
128enum StreamNode {
129 Dir(StreamDir),
130 File {
132 inode_number: u64,
133 },
134 Symlink {
135 inode_number: u64,
136 },
137 DeferredLink {
141 target: String,
142 },
143}
144
145pub struct StreamWriter<'a> {
164 ctx: WriteContext,
165 config: &'a WriteConfig,
166 codecs: StreamCodecs,
167 inline_threshold: u64,
168 tree: StreamDir,
169 staged: Vec<StagedEntry<'a>>,
175}
176
177struct StagedEntry<'a> {
180 name: String,
181 meta: EntryMeta,
182 xattrs: Vec<limnifs_core::inode::XAttr>,
183 inode_number: u64,
184 data: &'a [u8],
185}
186
187impl<'a> StreamWriter<'a> {
188 pub fn new(config: &'a WriteConfig) -> Result<Self, WriteError> {
195 let mut ctx = WriteContext::new();
196 ctx.chunker = crate::chunker_from_config(config)?;
197 ctx.categorizers_disabled = config.categorizers.is_empty();
198 ctx.rw_mode = matches!(config.mode, crate::config::ImageMode::ReadWrite(_));
199 ctx.auto_turnover = config.turnover_threshold > 0;
200 ctx.collect_dict_samples = config.dictionaries.enabled;
201 ctx.inline_threshold = config.defaults.inline_threshold as usize;
202 ctx.metadata_externalize_threshold = config.defaults.metadata_externalize_threshold;
203 ctx.emit_shared_inline = config.defaults.shared_inline;
204 let classifier = ctx.classifier;
205 let chunker = ctx.chunker.clone();
206 Ok(Self {
207 codecs: StreamCodecs::from_config(chunker, classifier, config)?,
208 inline_threshold: u64::try_from(ctx.inline_threshold).unwrap_or(u64::MAX),
209 ctx,
210 config,
211 tree: StreamDir::default(),
212 staged: Vec::new(),
213 })
214 }
215
216 pub fn add_file(
225 &mut self,
226 name: &str,
227 meta: EntryMeta,
228 xattrs: &[(String, Vec<u8>)],
229 reader: &mut dyn Read,
230 ) -> Result<(), WriteError> {
231 let (parent, leaf) = descend(&mut self.tree, name)?;
232 if parent.children.contains_key(leaf) {
233 return Err(name_conflict(name));
234 }
235 let inode_number = self.ctx.alloc_inode();
236 let pf = PendingFile {
237 path: PathBuf::from(name),
238 inode_number,
239 file_len: 0,
240 mtime_ns: meta.mtime_ns,
241 mode: limnifs_core::inode::S_IFREG | (meta.perms & 0o7777),
242 uid: meta.uid,
243 gid: meta.gid,
244 };
245 let wire_xattrs = to_wire_xattrs(xattrs)?;
246 self.ctx.pending_files.push(pf.clone());
247
248 let chunks = self.codecs.chunker.chunk_reader(reader)?;
249 let total_len: u64 = chunks.iter().map(|c| c.len() as u64).sum();
250 self.ctx.file_count += 1;
251
252 if total_len <= self.inline_threshold {
253 let mut data = Vec::with_capacity(total_len as usize);
254 for chunk in &chunks {
255 data.extend_from_slice(chunk);
256 }
257 self.ctx.inodes.push(PendingInode {
258 number: inode_number,
259 mode: limnifs_core::inode::S_IFREG | (meta.perms & 0o7777),
260 uid: meta.uid,
261 gid: meta.gid,
262 mtime_ns: meta.mtime_ns,
263 xattrs: wire_xattrs,
264 content: PendingContent::Inline(data),
265 });
266 } else {
267 let mut drops = Vec::with_capacity(chunks.len());
268 let mut slices = Vec::with_capacity(chunks.len());
269 let mut offset: u64 = 0;
270 for chunk in &chunks {
271 let drop_id = hash_section(chunk);
272 slices.push(crate::PendingSlice {
273 drop_id,
274 file_byte_start: offset,
275 file_byte_end: offset + chunk.len() as u64,
276 });
277 offset += chunk.len() as u64;
278 let class = self.codecs.classifier.classify(chunk);
279 let (codec_id, compressed) = crate::compress_chunk_with_tournament(
280 chunk,
281 class,
282 self.codecs.text_codec,
283 self.codecs.binary_codec,
284 &self.codecs.tunables,
285 &self.codecs.tournament,
286 );
287 drops.push((drop_id, chunk.clone(), compressed, codec_id, 0));
288 }
289 self.ctx
290 .merge_chunked_file(&pf, crate::ChunkedFileResult { drops, slices });
291 if let Some(inode) = self.ctx.inodes.last_mut() {
294 if let PendingContent::DropBacked { file_len, .. } = &mut inode.content {
295 *file_len = total_len;
296 }
297 }
298 self.ctx
299 .pending_files
300 .last_mut()
301 .expect("pushed above")
302 .file_len = total_len;
303 }
304 parent
305 .children
306 .insert(leaf.to_owned(), StreamNode::File { inode_number });
307 Ok(())
308 }
309
310 pub fn stage_file(
325 &mut self,
326 name: &str,
327 meta: EntryMeta,
328 xattrs: &[(String, Vec<u8>)],
329 data: &'a [u8],
330 ) -> Result<(), WriteError> {
331 let (parent, leaf) = descend(&mut self.tree, name)?;
332 if parent.children.contains_key(leaf) {
333 return Err(name_conflict(name));
334 }
335 let inode_number = self.ctx.alloc_inode();
336 self.ctx.file_count += 1;
337 crate::progress::emit_file(std::path::Path::new(name), data.len() as u64);
338 parent
339 .children
340 .insert(leaf.to_owned(), StreamNode::File { inode_number });
341 self.staged.push(StagedEntry {
342 name: name.to_owned(),
343 meta,
344 xattrs: to_wire_xattrs(xattrs)?,
345 inode_number,
346 data,
347 });
348 Ok(())
349 }
350
351 pub fn add_dir(&mut self, name: &str, meta: EntryMeta) -> Result<(), WriteError> {
361 if name == "/" {
362 return Ok(()); }
364 let (parent, leaf) = descend(&mut self.tree, name)?;
365 match parent.children.get_mut(leaf) {
366 None => {
367 parent.children.insert(
368 leaf.to_owned(),
369 StreamNode::Dir(StreamDir {
370 meta,
371 children: BTreeMap::new(),
372 }),
373 );
374 Ok(())
375 }
376 Some(StreamNode::Dir(dir)) => {
377 dir.meta.mtime_ns = meta.mtime_ns;
378 Ok(())
379 }
380 Some(_) => Err(name_conflict(name)),
381 }
382 }
383
384 pub fn add_hardlink(&mut self, name: &str, target: &str) -> Result<(), WriteError> {
394 let (parent, leaf) = descend(&mut self.tree, name)?;
395 if parent.children.contains_key(leaf) {
396 return Err(name_conflict(name));
397 }
398 parent.children.insert(
402 leaf.to_owned(),
403 StreamNode::DeferredLink {
404 target: target.to_owned(),
405 },
406 );
407 Ok(())
408 }
409
410 pub fn add_symlink(
418 &mut self,
419 name: &str,
420 target: &str,
421 meta: EntryMeta,
422 ) -> Result<(), WriteError> {
423 let (parent, leaf) = descend(&mut self.tree, name)?;
424 if parent.children.contains_key(leaf) {
425 return Err(name_conflict(name));
426 }
427 let inode_number = self.ctx.alloc_inode();
428 self.ctx.inodes.push(PendingInode {
429 number: inode_number,
430 mode: limnifs_core::inode::S_IFLNK | (meta.perms & 0o7777),
431 uid: meta.uid,
432 gid: meta.gid,
433 mtime_ns: meta.mtime_ns,
434 xattrs: Vec::new(),
435 content: PendingContent::Symlink(target.to_owned()),
436 });
437 parent
438 .children
439 .insert(leaf.to_owned(), StreamNode::Symlink { inode_number });
440 Ok(())
441 }
442
443 pub fn finish(mut self) -> Result<WriteArtifact, WriteError> {
449 self.flush_staged()?;
450 let mut tree = std::mem::take(&mut self.tree);
451 resolve_deferred_links(&mut tree, &mut self.ctx)?;
452 self.ctx.root_inode_number = self.materialize_dir(tree)?;
453 self.ctx
454 .train_and_apply_dictionary(&self.config.dictionaries);
455 Ok(self.ctx.assemble())
456 }
457
458 fn flush_staged(&mut self) -> Result<(), WriteError> {
467 if self.staged.is_empty() {
468 return Ok(());
469 }
470 let staged = std::mem::take(&mut self.staged);
471 let codecs = &self.codecs;
472 use rayon::prelude::*;
473 let results: Vec<crate::ChunkedFileResult> = staged
474 .par_iter()
475 .map(|entry| {
476 let chunks: Vec<&[u8]> = codecs.chunker.chunk_slice(entry.data);
477 let mut drops = Vec::with_capacity(chunks.len());
478 let mut slices = Vec::with_capacity(chunks.len());
479 let mut offset: u64 = 0;
480 for chunk in &chunks {
481 let drop_id = crate::hash_section(chunk);
482 slices.push(crate::PendingSlice {
483 drop_id,
484 file_byte_start: offset,
485 file_byte_end: offset + chunk.len() as u64,
486 });
487 offset += chunk.len() as u64;
488 let class = codecs.classifier.classify(chunk);
489 let (codec_id, compressed) = crate::compress_chunk_with_tournament(
490 chunk,
491 class,
492 codecs.text_codec,
493 codecs.binary_codec,
494 &codecs.tunables,
495 &codecs.tournament,
496 );
497 drops.push((drop_id, (*chunk).to_vec(), compressed, codec_id, 0));
498 }
499 crate::ChunkedFileResult { drops, slices }
500 })
501 .collect();
502 for (entry, result) in staged.iter().zip(results) {
503 let total_len = entry.data.len() as u64;
506 let pf = PendingFile {
507 path: std::path::PathBuf::from(&entry.name),
508 inode_number: entry.inode_number,
509 file_len: total_len,
510 mtime_ns: entry.meta.mtime_ns,
511 mode: limnifs_core::inode::S_IFREG | (entry.meta.perms & 0o7777),
512 uid: entry.meta.uid,
513 gid: entry.meta.gid,
514 };
515 self.ctx.pending_files.push(pf.clone());
516 if total_len <= self.inline_threshold {
517 let mut data = Vec::with_capacity(entry.data.len());
521 data.extend_from_slice(entry.data);
522 self.ctx.inodes.push(PendingInode {
523 number: entry.inode_number,
524 mode: limnifs_core::inode::S_IFREG | (entry.meta.perms & 0o7777),
525 uid: entry.meta.uid,
526 gid: entry.meta.gid,
527 mtime_ns: entry.meta.mtime_ns,
528 xattrs: entry.xattrs.clone(),
529 content: PendingContent::Inline(data),
530 });
531 } else {
532 if !entry.xattrs.is_empty() {
533 self.ctx
534 .inode_xattrs
535 .insert(entry.inode_number, entry.xattrs.clone());
536 }
537 self.ctx.merge_chunked_file(&pf, result);
538 }
539 }
540 Ok(())
541 }
542
543 fn materialize_dir(&mut self, dir: StreamDir) -> Result<u64, WriteError> {
546 let inode_number = self.ctx.alloc_inode();
547 self.ctx.dir_count += 1;
548 let mut entries = Vec::with_capacity(dir.children.len());
549 for (name, node) in dir.children {
550 let (child_inode, entry_type) = match node {
551 StreamNode::Dir(child) => (self.materialize_dir(child)?, 0x02),
552 StreamNode::File { inode_number } => (inode_number, 0x01),
553 StreamNode::Symlink { inode_number } => (inode_number, 0x03),
554 StreamNode::DeferredLink { .. } => {
555 return Err(WriteError::Io(std::io::Error::other(
556 "internal: unresolved hardlink reached materialization",
557 )));
558 }
559 };
560 entries.push((name, child_inode, entry_type));
561 }
562 entries.sort_by(|a, b| a.0.cmp(&b.0));
565 self.ctx.dir_nodes.push(encode_dir_node(&entries));
566 self.ctx.inodes.push(PendingInode {
567 number: inode_number,
568 mode: limnifs_core::inode::S_IFDIR | (dir.meta.perms & 0o7777),
569 uid: dir.meta.uid,
570 gid: dir.meta.gid,
571 mtime_ns: dir.meta.mtime_ns,
572 xattrs: Vec::new(),
573 content: PendingContent::Directory(entries),
574 });
575 Ok(inode_number)
576 }
577}
578
579fn descend<'a, 'b>(
582 root: &'a mut StreamDir,
583 name: &'b str,
584) -> Result<(&'a mut StreamDir, &'b str), WriteError> {
585 if name.is_empty() || name.starts_with('/') || name.ends_with('/') {
586 return Err(bad_name(name));
587 }
588 if name.contains(['\\', '\0']) {
592 return Err(bad_name(name));
593 }
594 let mut dir = root;
595 let mut components = name.split('/').peekable();
596 let leaf = components.next_back().expect("non-empty name has a leaf");
597 for component in components {
598 if component.is_empty() || component == "." || component == ".." {
599 return Err(bad_name(name));
600 }
601 dir = match dir
602 .children
603 .entry(component.to_owned())
604 .or_insert_with(|| StreamNode::Dir(StreamDir::default()))
605 {
606 StreamNode::Dir(child) => child,
607 StreamNode::File { .. }
608 | StreamNode::Symlink { .. }
609 | StreamNode::DeferredLink { .. } => return Err(name_conflict(name)),
610 };
611 }
612 if leaf.is_empty() || leaf == "." || leaf == ".." {
613 return Err(bad_name(name));
614 }
615 Ok((dir, leaf))
616}
617
618fn resolve_deferred_links(root: &mut StreamDir, ctx: &mut WriteContext) -> Result<(), WriteError> {
626 fn bad(target: &str) -> WriteError {
627 WriteError::Io(std::io::Error::other(format!(
628 "hardlink target {target:?} is not a file in the tree"
629 )))
630 }
631
632 fn collect(dir: &StreamDir, out: &mut Vec<String>) {
633 for node in dir.children.values() {
634 match node {
635 StreamNode::DeferredLink { target } => out.push(target.clone()),
636 StreamNode::Dir(child) => collect(child, out),
637 _ => {}
638 }
639 }
640 }
641
642 fn chase(root: &StreamDir, target: &str, seen: &mut Vec<String>) -> Result<u64, WriteError> {
643 if seen.iter().any(|s| s == target) {
644 return Err(bad(target)); }
646 seen.push(target.to_owned());
647 let mut dir = root;
648 let mut components = target.split('/').filter(|c| !c.is_empty());
649 loop {
650 let Some(component) = components.next() else {
651 return Err(bad(target));
652 };
653 match dir.children.get(component) {
654 Some(StreamNode::File { inode_number }) if components.next().is_none() => {
655 return Ok(*inode_number);
656 }
657 Some(StreamNode::DeferredLink { target: t }) if components.next().is_none() => {
660 return chase(root, t, seen);
661 }
662 Some(StreamNode::Dir(child)) => dir = child,
663 _ => return Err(bad(target)),
664 }
665 }
666 }
667
668 fn replace(
669 dir: &mut StreamDir,
670 resolved: &mut std::vec::IntoIter<u64>,
671 ctx: &mut WriteContext,
672 ) {
673 for node in dir.children.values_mut() {
674 match node {
675 StreamNode::DeferredLink { .. } => {
676 let inode_number = resolved.next().expect("collected matches replaced");
677 *ctx.nlink_counts.entry(inode_number).or_insert(1) += 1;
678 *node = StreamNode::File { inode_number };
679 }
680 StreamNode::Dir(child) => replace(child, resolved, ctx),
681 _ => {}
682 }
683 }
684 }
685
686 let mut targets = Vec::new();
687 collect(root, &mut targets);
688 if targets.is_empty() {
689 return Ok(());
690 }
691 let mut resolved = Vec::with_capacity(targets.len());
692 for target in &targets {
693 resolved.push(chase(root, target, &mut Vec::new())?);
694 }
695 replace(root, &mut resolved.into_iter(), ctx);
696 Ok(())
697}
698
699fn to_wire_xattrs(
705 raw: &[(String, Vec<u8>)],
706) -> Result<Vec<limnifs_core::inode::XAttr>, WriteError> {
707 const TOTAL_CAP: usize = 64 * 1024;
708 let mut out = Vec::with_capacity(raw.len());
709 let mut total = 0usize;
710 for (key, value) in raw {
711 if key.contains('\0') || key.contains('\n') || value.contains(&0) || value.contains(&b'\n')
712 {
713 return Err(WriteError::Io(std::io::Error::other(format!(
714 "xattr {key:?} carries NUL or newline bytes the pax record format cannot represent"
715 ))));
716 }
717 total += key.len() + value.len();
718 if total > TOTAL_CAP {
719 break;
720 }
721 out.push(limnifs_core::inode::XAttr {
722 namespace: 0,
723 key: key.clone(),
724 value: value.clone(),
725 });
726 }
727 Ok(out)
728}
729
730fn bad_name(name: &str) -> WriteError {
731 WriteError::Io(std::io::Error::other(format!(
732 "invalid stream entry name {name:?}: must be a non-empty relative path without '.', '..', '\\', or NUL components"
733 )))
734}
735
736fn name_conflict(name: &str) -> WriteError {
737 WriteError::Io(std::io::Error::other(format!(
738 "stream entry conflict: {name:?} already exists with a different type"
739 )))
740}
741
742#[cfg(test)]
743mod tests {
744 use super::*;
745
746 fn writer() -> StreamWriter<'static> {
747 let config: &'static WriteConfig = Box::leak(Box::new(WriteConfig::default_v0_1()));
749 StreamWriter::new(config).expect("default config is valid")
750 }
751
752 fn pseudo_random_bytes(seed: u64, count: usize) -> Vec<u8> {
753 let mut state = seed;
754 let mut out = Vec::with_capacity(count);
755 for _ in 0..count {
756 state = state
757 .wrapping_mul(6_364_136_223_846_793_005)
758 .wrapping_add(1_442_695_040_888_963_407);
759 out.push(u8::try_from(state >> 56).expect("fits u8"));
760 }
761 out
762 }
763
764 fn add_all(w: &mut StreamWriter<'_>) {
765 w.add_dir("docs", EntryMeta::new(7_000_000_000_000, 0o755))
766 .expect("dir");
767 w.add_file(
768 "docs/readme.txt",
769 EntryMeta::new(1_000_000_000, 0o644),
770 &[],
771 &mut b"hello stream writer\n".as_slice(),
772 )
773 .expect("file 1");
774 let big = pseudo_random_bytes(9, 600 * 1024);
775 w.add_file(
776 "data/big.bin",
777 EntryMeta::new(2_000_000_000, 0o755),
778 &[],
779 &mut big.as_slice(),
780 )
781 .expect("file 2");
782 w.add_symlink(
783 "latest",
784 "docs/readme.txt",
785 EntryMeta::new(3_000_000_000, 0o777),
786 )
787 .expect("symlink");
788 }
789
790 #[test]
791 fn same_entry_sequence_packs_identically() {
792 let a = {
793 let mut w = writer();
794 add_all(&mut w);
795 w.finish().expect("finish a").bytes
796 };
797 let b = {
798 let mut w = writer();
799 add_all(&mut w);
800 w.finish().expect("finish b").bytes
801 };
802 assert_eq!(a, b);
803 }
804
805 #[test]
806 fn empty_stream_writes_root_only() {
807 let artifact = writer().finish().expect("finish");
808 assert_eq!(artifact.dir_count, 1);
809 assert_eq!(artifact.file_count, 0);
810 assert!(artifact.slabs.is_empty());
811 }
812
813 #[test]
817 fn entry_meta_lands_on_inodes() {
818 let artifact = {
819 let mut w = writer();
820 w.add_dir("d", EntryMeta::new(1_111_111_111_111, 0o755).owner(12, 34))
821 .expect("dir");
822 w.add_file(
823 "d/f.txt",
824 EntryMeta::new(1_234_567_891_234, 0o640).owner(1000, 20),
825 &[],
826 &mut b"body\n".as_slice(),
827 )
828 .expect("file");
829 w.stage_file(
830 "d/s.bin",
831 EntryMeta::new(2_222_222_222_222, 0o600).owner(1001, 21),
832 &[],
833 b"staged",
834 )
835 .expect("staged");
836 w.add_symlink(
837 "d/l",
838 "d/f.txt",
839 EntryMeta::new(3_333_333_333_333, 0o777).owner(1002, 22),
840 )
841 .expect("symlink");
842 w.finish().expect("finish")
843 };
844 use limnifs_core::{parse_metadata_blob, parse_metadata_reference, ManifestCursor};
845 let mut cursor = ManifestCursor::new(&artifact.bytes);
846 limnifs_core::parse_manifest_header(&mut cursor).expect("header");
847 limnifs_core::parse_feature_flags_section(&mut cursor).expect("flags");
848 let meta_ref = parse_metadata_reference(&mut cursor).expect("meta");
849 let inline = meta_ref.inline_metadata.as_ref().expect("inline");
850 let mut blob_cursor = ManifestCursor::new(inline);
851 let blob = parse_metadata_blob(&mut blob_cursor).expect("blob");
852
853 let file = blob
854 .inodes
855 .iter()
856 .find(|i| i.uid == 1000 && i.gid == 20)
857 .expect("owned file inode");
858 assert_eq!(file.mtime_ns, 1_234_567_891_234);
859 assert_eq!(file.mode & 0o7777, 0o640);
860 assert!(blob
861 .inodes
862 .iter()
863 .any(|i| i.uid == 1001 && i.gid == 21 && i.mtime_ns == 2_222_222_222_222));
864 assert!(blob
865 .inodes
866 .iter()
867 .any(|i| i.uid == 1002 && i.gid == 22 && i.mtime_ns == 3_333_333_333_333));
868 assert!(blob.inodes.iter().any(|i| i.is_directory()
869 && i.uid == 12
870 && i.gid == 34
871 && i.mtime_ns == 1_111_111_111_111));
872 }
873
874 #[test]
875 fn small_files_inline_and_big_files_slab() {
876 let artifact = {
877 let mut w = writer();
878 add_all(&mut w);
879 w.finish().expect("finish")
880 };
881 assert_eq!(artifact.file_count, 2);
882 assert_eq!(artifact.dir_count, 3); assert_eq!(artifact.slabs.len(), 1);
884 }
885
886 #[test]
887 fn staged_path_is_byte_identical_to_serial() {
888 let big_a = pseudo_random_bytes(31, 600 * 1024);
889 let big_b = pseudo_random_bytes(32, 900 * 1024);
890 let staged = {
891 let mut w = writer();
892 w.add_dir("docs", EntryMeta::new(7_000_000_000_000, 0o755))
893 .expect("dir");
894 w.add_file(
895 "tiny.txt",
896 EntryMeta::new(1, 0o644),
897 &[],
898 &mut b"small inline entry\n".as_slice(),
899 )
900 .expect("immediate file");
901 w.stage_file("docs/a.bin", EntryMeta::new(2, 0o644), &[], &big_a)
902 .expect("staged a");
903 w.stage_file("docs/b.bin", EntryMeta::new(3, 0o755), &[], &big_b)
904 .expect("staged b");
905 w.stage_file(
906 "docs/tiny2.txt",
907 EntryMeta::new(4, 0o600),
908 &[],
909 b"also inline\n",
910 )
911 .expect("staged tiny");
912 w.finish().expect("finish staged").bytes
913 };
914 let serial = {
915 let mut w = writer();
916 w.add_dir("docs", EntryMeta::new(7_000_000_000_000, 0o755))
917 .expect("dir");
918 w.add_file(
919 "tiny.txt",
920 EntryMeta::new(1, 0o644),
921 &[],
922 &mut b"small inline entry\n".as_slice(),
923 )
924 .expect("immediate file");
925 w.add_file(
926 "docs/a.bin",
927 EntryMeta::new(2, 0o644),
928 &[],
929 &mut big_a.as_slice(),
930 )
931 .expect("serial a");
932 w.add_file(
933 "docs/b.bin",
934 EntryMeta::new(3, 0o755),
935 &[],
936 &mut big_b.as_slice(),
937 )
938 .expect("serial b");
939 w.add_file(
940 "docs/tiny2.txt",
941 EntryMeta::new(4, 0o600),
942 &[],
943 &mut b"also inline\n".as_slice(),
944 )
945 .expect("serial tiny");
946 w.finish().expect("finish serial").bytes
947 };
948 assert_eq!(staged, serial, "staged flush must equal the serial path");
949 }
950
951 #[test]
952 fn staged_detects_conflicts_and_bad_names() {
953 let mut w = writer();
954 w.stage_file("a.txt", EntryMeta::new(0, 0o644), &[], b"x")
955 .expect("stage");
956 assert!(w
957 .stage_file("a.txt", EntryMeta::new(0, 0o644), &[], b"y")
958 .is_err());
959 assert!(w
960 .stage_file("", EntryMeta::new(0, 0o644), &[], b"y")
961 .is_err());
962 assert!(w
963 .stage_file("/abs", EntryMeta::new(0, 0o644), &[], b"y")
964 .is_err());
965 assert!(w
966 .stage_file("a.txt/child", EntryMeta::new(0, 0o644), &[], b"y")
967 .is_err());
968 }
969
970 #[test]
971 fn rejects_bad_and_conflicting_names() {
972 let mut w = writer();
973 assert!(w
974 .add_file("", EntryMeta::new(0, 0o644), &[], &mut [].as_slice())
975 .is_err());
976 assert!(w
977 .add_file("/abs", EntryMeta::new(0, 0o644), &[], &mut [].as_slice())
978 .is_err());
979 assert!(w
980 .add_file("a/../b", EntryMeta::new(0, 0o644), &[], &mut [].as_slice())
981 .is_err());
982 assert!(w
983 .add_file("ok.txt", EntryMeta::new(0, 0o644), &[], &mut [].as_slice())
984 .is_ok());
985 assert!(w
987 .add_file("ok.txt", EntryMeta::new(0, 0o644), &[], &mut [].as_slice())
988 .is_err());
989 assert!(w
991 .add_file(
992 "ok.txt/child",
993 EntryMeta::new(0, 0o644),
994 &[],
995 &mut [].as_slice()
996 )
997 .is_err());
998 assert!(w
1000 .add_symlink("ok.txt", "x", EntryMeta::new(0, 0o777))
1001 .is_err());
1002 assert!(w
1005 .add_file(r"a\b", EntryMeta::new(0, 0o644), &[], &mut [].as_slice())
1006 .is_err());
1007 assert!(w
1008 .add_dir(r"\\server\share", EntryMeta::new(0, 0o755))
1009 .is_err());
1010 assert!(w
1011 .add_file("nul\0x", EntryMeta::new(0, 0o644), &[], &mut [].as_slice())
1012 .is_err());
1013 }
1014}