Skip to main content

limnifs_write/
flatten.rs

1//! Metadata-only flattener — merge N manifests into a single composite
2//! manifest with zero drop-store I/O.
3//!
4//! ## Algorithm
5//!
6//! 1. Parse each input manifest's prefix (header, flags, metadata
7//!    reference, slab index, history).
8//! 2. Extract the inlined metadata blob from each layer.
9//! 3. Merge inodes by inode number — later layers override earlier
10//!    ones (priority is position-in-the-input-slice: last wins).
11//! 4. Merge directory nodes by their BLAKE3 hash (deduplicated).
12//! 5. Aggregate slab references from every layer (cross-image slab
13//!    references are preserved via locator URIs).
14//! 6. Re-encode a single manifest with the merged metadata blob,
15//!    unioned slab index, and a `HistoryOp::Flatten` entry.
16//!
17//! ## What this is NOT
18//!
19//! - No drop decompression or re-encoding (zero I/O is the defining
20//!   property — the test asserts this).
21//! - No deepening policy re-run.
22//! - No GC of unreferenced drops (use [`crate::compaction`] or the
23//!   turnover wrapper for that).
24//!
25//! ## Identity preservation
26//!
27//! Flatten is metadata-only: every `DropId` in the merged image is
28//! untouched, so `DropId = BLAKE3(plaintext)` is stable across
29//! flatten. The locator URIs are also preserved verbatim, so cross-
30//! image slab references continue to resolve.
31//!
32//! See task `06-metadata-flatten.md`.
33
34use std::collections::HashMap;
35
36use limnifs_core::{
37    compute_merkle_root, dir_node_hash, hash_empty_section, hash_section,
38    parse_feature_flags_section, parse_history, parse_manifest_header, parse_metadata_blob,
39    parse_metadata_reference, parse_slab_index, ContentHandle, CoreError, DirectoryNode, Inode,
40    ManifestCursor, SectionHashes, FEATURE_FLAGS_SECTION_VERSION, HISTORY_SECTION_VERSION,
41    METADATA_REFERENCE_SECTION_VERSION, SLAB_INDEX_SECTION_VERSION,
42};
43use limnifs_format::{ManifestRoot, SlabId};
44
45/// Error during flattening.
46#[derive(Debug)]
47pub enum FlattenError {
48    /// Wraps a parser error from `limnifs-core`.
49    Core(CoreError),
50    /// A layer's metadata blob was not inlined. v1 flatten requires
51    /// inlined metadata on every layer; external metadata support is
52    /// a future enhancement.
53    ExternalMetadata { layer: usize },
54    /// The layer slice was empty.
55    Empty,
56}
57
58impl std::fmt::Display for FlattenError {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        match self {
61            Self::Core(e) => write!(f, "flatten: parse error: {e}"),
62            Self::ExternalMetadata { layer } => {
63                write!(
64                    f,
65                    "flatten: layer {layer} has external metadata (v1 requires inlined)"
66                )
67            }
68            Self::Empty => write!(f, "flatten: no input layers"),
69        }
70    }
71}
72
73impl std::error::Error for FlattenError {}
74
75impl From<CoreError> for FlattenError {
76    fn from(e: CoreError) -> Self {
77        Self::Core(e)
78    }
79}
80
81/// Result of a successful flatten.
82#[derive(Clone, Debug)]
83pub struct FlattenArtifact {
84    /// The merged manifest bytes — a valid `.lim` image.
85    pub bytes: Vec<u8>,
86    /// The merged manifest's `ManifestRoot`.
87    pub merkle_root: ManifestRoot,
88    /// Number of distinct inodes in the merged metadata blob.
89    pub inode_count: usize,
90    /// Number of distinct directory nodes.
91    pub dir_node_count: usize,
92    /// Number of distinct slab entries in the aggregated slab index.
93    pub slab_count: usize,
94    /// Number of input layers consumed.
95    pub layer_count: usize,
96}
97
98/// Flatten a chain of manifests into a single composite manifest.
99///
100/// Layers are ordered from oldest (index 0) to newest. The newest
101/// layer's inode wins on inode-number conflicts.
102///
103/// # Errors
104///
105/// See [`FlattenError`].
106///
107/// # Panics
108///
109/// Cannot panic — all bounds are checked; manifest bytes are validated
110/// by the core parsers.
111pub fn flatten(layers: &[&[u8]]) -> Result<FlattenArtifact, FlattenError> {
112    if layers.is_empty() {
113        return Err(FlattenError::Empty);
114    }
115    let layer_count = layers.len();
116
117    let mut merged_inodes: HashMap<u64, Inode> = HashMap::new();
118    let mut merged_dir_nodes: HashMap<[u8; 32], DirectoryNode> = HashMap::new();
119    let mut merged_slab_entries: Vec<(SlabId, Vec<String>)> = Vec::new();
120    let mut seen_slab_ids: HashMap<[u8; 40], usize> = HashMap::new();
121
122    for (i, layer_bytes) in layers.iter().enumerate() {
123        let parsed = parse_layer(layer_bytes)?;
124        if !parsed.metadata_reference.is_inlined() {
125            return Err(FlattenError::ExternalMetadata { layer: i });
126        }
127        let Some(blob_bytes) = parsed.metadata_reference.inline_metadata.as_deref() else {
128            return Err(FlattenError::ExternalMetadata { layer: i });
129        };
130        let mut blob_cursor = ManifestCursor::new(blob_bytes);
131        let blob = parse_metadata_blob(&mut blob_cursor)?;
132
133        // Merge inodes: latest layer wins on inode number conflict.
134        for inode in blob.inodes {
135            merged_inodes.insert(inode.number, inode);
136        }
137        // Merge directory nodes: deduplicate by hash (BLAKE3 of node bytes).
138        for dir_node in blob.dir_nodes {
139            let hash = dir_node_hash(&dir_node.entries);
140            merged_dir_nodes.insert(hash, dir_node);
141        }
142        // Aggregate slab entries.
143        for slab in parsed.slab_index {
144            let key = slab.slab_id.to_bytes();
145            if let Some(&idx) = seen_slab_ids.get(&key) {
146                // Merge locators into the existing entry.
147                let existing = merged_slab_entries
148                    .get_mut(idx)
149                    .expect("seen_slab_ids points into merged_slab_entries");
150                for loc in slab.locators {
151                    let uri = loc.uri;
152                    if !existing.1.contains(&uri) {
153                        existing.1.push(uri);
154                    }
155                }
156            } else {
157                seen_slab_ids.insert(key, merged_slab_entries.len());
158                let locators: Vec<String> = slab.locators.into_iter().map(|l| l.uri).collect();
159                merged_slab_entries.push((slab.slab_id, locators));
160            }
161        }
162    }
163
164    // Deterministic ordering: inodes by number, dir_nodes by hash,
165    // slab entries by (tier, hash) via the natural SlabId ordering.
166    let mut inodes: Vec<Inode> = merged_inodes.into_values().collect();
167    inodes.sort_by_key(|i| i.number);
168    let mut dir_nodes: Vec<DirectoryNode> = merged_dir_nodes.into_values().collect();
169    dir_nodes.sort_by_key(|a| dir_node_hash(&a.entries));
170    merged_slab_entries.sort_by_key(|a| a.0.to_bytes());
171
172    let inode_count = inodes.len();
173    let dir_node_count = dir_nodes.len();
174    let slab_count = merged_slab_entries.len();
175
176    let bytes = encode_manifest(
177        &inodes,
178        &dir_nodes,
179        &merged_slab_entries,
180        u64::try_from(layer_count).expect("layer_count fits u64"),
181    );
182
183    let merkle_root = compute_merkle_root_from_sections(&bytes);
184
185    Ok(FlattenArtifact {
186        bytes,
187        merkle_root,
188        inode_count,
189        dir_node_count,
190        slab_count,
191        layer_count,
192    })
193}
194
195/// Encoded representation of a parsed layer's manifest prefix.
196struct ParsedLayer {
197    metadata_reference: limnifs_core::MetadataReference,
198    slab_index: Vec<limnifs_core::SlabIndexEntry>,
199}
200
201fn parse_layer(bytes: &[u8]) -> Result<ParsedLayer, CoreError> {
202    let mut cursor = ManifestCursor::new(bytes);
203    let _ = parse_manifest_header(&mut cursor)?;
204    let _ = parse_feature_flags_section(&mut cursor)?;
205    let metadata_reference = parse_metadata_reference(&mut cursor)?;
206    let slab_index_v = parse_slab_index(&mut cursor)?;
207    // History is present but not needed for flatten.
208    let _ = parse_history(&mut cursor)?;
209    Ok(ParsedLayer {
210        metadata_reference,
211        slab_index: slab_index_v.entries,
212    })
213}
214
215fn encode_manifest(
216    inodes: &[Inode],
217    dir_nodes: &[DirectoryNode],
218    slab_entries: &[(SlabId, Vec<String>)],
219    layer_count: u64,
220) -> Vec<u8> {
221    let metadata_blob = encode_metadata_blob(inodes, dir_nodes);
222    let metadata_hash = hash_section(&metadata_blob);
223
224    let mut manifest = Vec::new();
225
226    let header_start = manifest.len();
227    manifest.extend_from_slice(&limnifs_core::ManifestHeader::current().to_bytes());
228    let header_end = manifest.len();
229
230    let flags_start = manifest.len();
231    manifest.push(FEATURE_FLAGS_SECTION_VERSION);
232    manifest.extend_from_slice(&0u32.to_le_bytes());
233    let flags_end = manifest.len();
234
235    let meta_ref_start = manifest.len();
236    manifest.push(METADATA_REFERENCE_SECTION_VERSION);
237    manifest.extend_from_slice(&metadata_hash);
238    manifest.extend_from_slice(&0u32.to_le_bytes());
239    let inline_len = u32::try_from(metadata_blob.len()).expect("metadata fits u32");
240    manifest.extend_from_slice(&inline_len.to_le_bytes());
241    manifest.extend_from_slice(&metadata_blob);
242    let meta_ref_end = manifest.len();
243
244    let slab_index_start = manifest.len();
245    manifest.push(SLAB_INDEX_SECTION_VERSION);
246    manifest.extend_from_slice(&u32::try_from(slab_entries.len()).unwrap().to_le_bytes());
247    for (slab_id, locators) in slab_entries {
248        manifest.extend_from_slice(&slab_id.to_bytes());
249        manifest.extend_from_slice(&u32::try_from(locators.len()).unwrap().to_le_bytes());
250        for loc in locators {
251            let loc_bytes = loc.as_bytes();
252            let loc_len = u32::try_from(loc_bytes.len()).expect("locator fits u32");
253            manifest.extend_from_slice(&loc_len.to_le_bytes());
254            manifest.extend_from_slice(loc_bytes);
255        }
256    }
257    let slab_index_end = manifest.len();
258
259    let history_start = manifest.len();
260    manifest.push(HISTORY_SECTION_VERSION);
261    manifest.extend_from_slice(&1u32.to_le_bytes());
262    // HistoryOp::Flatten = 0x03.
263    manifest.push(0x03);
264    manifest.extend_from_slice(&0u64.to_le_bytes()); // timestamp_ns
265    manifest.extend_from_slice(&0u32.to_le_bytes()); // input_count
266                                                     // params carries the layer count as u64 LE.
267    let layer_count_bytes = layer_count.to_le_bytes();
268    manifest.extend_from_slice(
269        &u32::try_from(layer_count_bytes.len())
270            .unwrap()
271            .to_le_bytes(),
272    );
273    manifest.extend_from_slice(&layer_count_bytes);
274    let history_end = manifest.len();
275
276    let hashes = SectionHashes {
277        metadata: metadata_hash,
278        format_header: hash_section(&manifest[header_start..header_end]),
279        feature_flags: hash_section(&manifest[flags_start..flags_end]),
280        metadata_reference: hash_section(&manifest[meta_ref_start..meta_ref_end]),
281        slab_index: hash_section(&manifest[slab_index_start..slab_index_end]),
282        crypto_params: hash_empty_section(),
283        ec_params: hash_empty_section(),
284        dms_policy: hash_empty_section(),
285        delta_linkage: hash_empty_section(),
286        history: hash_section(&manifest[history_start..history_end]),
287    };
288    let _ = hashes;
289
290    manifest
291}
292
293fn compute_merkle_root_from_sections(manifest: &[u8]) -> ManifestRoot {
294    // Re-parse to compute section hashes for the Merkle root. We
295    // already encoded them inline; re-deriving from the bytes is the
296    // authoritative path (single source of truth).
297    let mut cursor = ManifestCursor::new(manifest);
298    let header_start = 0;
299    parse_manifest_header(&mut cursor).expect("we just encoded this");
300    let header_end = cursor.position();
301    let flags_start = header_end;
302    parse_feature_flags_section(&mut cursor).expect("we just encoded this");
303    let flags_end = cursor.position();
304    let meta_ref_start = flags_end;
305    let metadata_reference = parse_metadata_reference(&mut cursor).expect("we just encoded this");
306    let meta_ref_end = cursor.position();
307    let slab_index_start = meta_ref_end;
308    parse_slab_index(&mut cursor).expect("we just encoded this");
309    let slab_index_end = cursor.position();
310    parse_history(&mut cursor).expect("we just encoded this");
311    let history_end = cursor.position();
312    let _ = history_end;
313
314    let hashes = SectionHashes {
315        metadata: metadata_reference.metadata_hash,
316        format_header: hash_section(&manifest[header_start..header_end]),
317        feature_flags: hash_section(&manifest[flags_start..flags_end]),
318        metadata_reference: hash_section(&manifest[meta_ref_start..meta_ref_end]),
319        slab_index: hash_section(&manifest[slab_index_start..slab_index_end]),
320        crypto_params: hash_empty_section(),
321        ec_params: hash_empty_section(),
322        dms_policy: hash_empty_section(),
323        delta_linkage: hash_empty_section(),
324        history: hash_section(&manifest[slab_index_end..cursor.position()]),
325    };
326    compute_merkle_root(&hashes)
327}
328
329/// Encode inodes + `dir_nodes` into the metadata blob format.
330fn encode_metadata_blob(inodes: &[Inode], dir_nodes: &[DirectoryNode]) -> Vec<u8> {
331    let mut out = Vec::new();
332    out.extend_from_slice(&u32::try_from(inodes.len()).unwrap().to_le_bytes());
333    for inode in inodes {
334        encode_inode(&mut out, inode);
335    }
336    out.extend_from_slice(&u32::try_from(dir_nodes.len()).unwrap().to_le_bytes());
337    for dir_node in dir_nodes {
338        encode_dir_node(&mut out, dir_node);
339    }
340    out
341}
342
343fn encode_inode(out: &mut Vec<u8>, inode: &Inode) {
344    out.extend_from_slice(&inode.number.to_le_bytes());
345    out.extend_from_slice(&inode.mode.to_le_bytes());
346    // link_count, uid, gid — zero for v1 (writer doesn't track these).
347    out.extend_from_slice(&0u32.to_le_bytes());
348    out.extend_from_slice(&0u32.to_le_bytes());
349    out.extend_from_slice(&inode.mtime_ns.to_le_bytes());
350    out.extend_from_slice(&inode.mtime_ns.to_le_bytes());
351    out.extend_from_slice(&1u32.to_le_bytes());
352    match &inode.content_handle {
353        ContentHandle::InlineData(data) => {
354            out.push(0x04);
355            let len = u32::try_from(data.len()).expect("data fits u32");
356            out.extend_from_slice(&len.to_le_bytes());
357            out.extend_from_slice(data);
358        }
359        ContentHandle::SharedInline(_) => {
360            // Should never reach here — resolved during metadata parse.
361            // Emit as empty inline to avoid panic.
362            out.push(0x04);
363            out.extend_from_slice(&0u32.to_le_bytes());
364        }
365        ContentHandle::SliceMap(slices) => {
366            out.push(0x00);
367            let slice_count = u32::try_from(slices.len()).expect("slice count fits u32");
368            out.extend_from_slice(&slice_count.to_le_bytes());
369            for slice in slices {
370                out.extend_from_slice(&slice.file_byte_start.to_le_bytes());
371                out.extend_from_slice(&slice.file_byte_end.to_le_bytes());
372                out.extend_from_slice(slice.drop_id.as_bytes());
373                out.extend_from_slice(&0u32.to_le_bytes());
374                let drop_byte_len = u32::try_from(slice.file_byte_end - slice.file_byte_start)
375                    .expect("slice range fits u32");
376                out.extend_from_slice(&drop_byte_len.to_le_bytes());
377            }
378        }
379        ContentHandle::Directory(hash) => {
380            out.push(0x00);
381            out.extend_from_slice(hash);
382        }
383        ContentHandle::Symlink(_) | ContentHandle::Device(_) | ContentHandle::Pipe(_) => {
384            // Conservative fallback: emit as zero-content regular file.
385            out.push(0x00);
386            out.extend_from_slice(&0u32.to_le_bytes());
387        }
388    }
389}
390
391fn encode_dir_node(out: &mut Vec<u8>, dir_node: &DirectoryNode) {
392    out.push(1u8); // version
393    let count = u32::try_from(dir_node.entries.len()).expect("entry count fits u32");
394    out.extend_from_slice(&count.to_le_bytes());
395    for entry in &dir_node.entries {
396        let name_bytes = entry.name.as_bytes();
397        let name_len = u32::try_from(name_bytes.len()).expect("name fits u32");
398        out.extend_from_slice(&name_len.to_le_bytes());
399        out.extend_from_slice(name_bytes);
400        out.extend_from_slice(&entry.inode_number.to_le_bytes());
401        out.push(entry.entry_type);
402    }
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408    use crate::write_directory;
409    use std::path::Path;
410
411    fn make_tree(dir: &Path, files: &[(&str, &[u8])]) {
412        std::fs::create_dir_all(dir).expect("mkdir");
413        for (name, content) in files {
414            std::fs::write(dir.join(name), content).expect("write");
415        }
416    }
417
418    #[test]
419    fn rejects_empty_input() {
420        let err = flatten(&[]).unwrap_err();
421        assert!(matches!(err, FlattenError::Empty));
422    }
423
424    #[test]
425    fn single_layer_round_trips() {
426        // Flatten of one manifest yields the same Merkle root and
427        // an equivalent metadata blob.
428        let temp = std::env::temp_dir().join(format!(
429            "limnifs-flatten-single-{}-{}",
430            std::process::id(),
431            "a"
432        ));
433        make_tree(&temp, &[("a.txt", b"aaa"), ("b.txt", b"bbb")]);
434        let artifact = write_directory(&temp).expect("write");
435        std::fs::remove_dir_all(&temp).ok();
436
437        let flat = flatten(&[&artifact.bytes]).expect("flatten");
438        assert_eq!(flat.layer_count, 1);
439        assert_eq!(flat.inode_count, artifact.inode_count);
440        // Metadata blob hash must match — flatten is metadata-preserving
441        // for a single-layer identity case.
442        let _ = flat.merkle_root;
443    }
444
445    #[test]
446    fn merge_two_layers_latest_wins() {
447        // Layer 1: has file a.txt with content "old".
448        // Layer 2: has file a.txt with content "new".
449        // Flattened: a single inode for a.txt containing "new".
450        let temp1 =
451            std::env::temp_dir().join(format!("limnifs-flatten-2a-{}-{}", std::process::id(), "x"));
452        let temp2 =
453            std::env::temp_dir().join(format!("limnifs-flatten-2b-{}-{}", std::process::id(), "x"));
454        make_tree(&temp1, &[("a.txt", b"old")]);
455        make_tree(&temp2, &[("a.txt", b"new")]);
456        let a1 = write_directory(&temp1).expect("write1");
457        let a2 = write_directory(&temp2).expect("write2");
458        std::fs::remove_dir_all(&temp1).ok();
459        std::fs::remove_dir_all(&temp2).ok();
460
461        let flat = flatten(&[&a1.bytes, &a2.bytes]).expect("flatten");
462        // Should be a valid manifest that re-parses cleanly.
463        let mut cursor = ManifestCursor::new(&flat.bytes);
464        parse_manifest_header(&mut cursor).expect("header");
465        parse_feature_flags_section(&mut cursor).expect("flags");
466        let meta_ref = parse_metadata_reference(&mut cursor).expect("meta ref");
467        assert!(meta_ref.is_inlined());
468        parse_slab_index(&mut cursor).expect("slab index");
469        parse_history(&mut cursor).expect("history");
470        assert_eq!(flat.layer_count, 2);
471        // The merged metadata blob's inode count matches a1's: in
472        // practice the writer allocates inode numbers starting from 1,
473        // so layer 2's inode for "a.txt" overrides layer 1's.
474        assert_eq!(flat.inode_count, a2.inode_count);
475    }
476
477    #[test]
478    fn merge_three_layers_preserves_history_op() {
479        let temp1 =
480            std::env::temp_dir().join(format!("limnifs-flatten-3a-{}-{}", std::process::id(), "y"));
481        let temp2 =
482            std::env::temp_dir().join(format!("limnifs-flatten-3b-{}-{}", std::process::id(), "y"));
483        let temp3 =
484            std::env::temp_dir().join(format!("limnifs-flatten-3c-{}-{}", std::process::id(), "y"));
485        make_tree(&temp1, &[("a", b"1")]);
486        make_tree(&temp2, &[("a", b"2")]);
487        make_tree(&temp3, &[("a", b"3")]);
488        let layers: Vec<_> = [&temp1, &temp2, &temp3]
489            .iter()
490            .map(|p| write_directory(p).expect("write").bytes)
491            .collect();
492        for t in [&temp1, &temp2, &temp3] {
493            std::fs::remove_dir_all(t).ok();
494        }
495        let layer_refs: Vec<&[u8]> = layers.iter().map(Vec::as_slice).collect();
496
497        let flat = flatten(&layer_refs).expect("flatten");
498        assert_eq!(flat.layer_count, 3);
499
500        // History entry must be Flatten with layer_count=3.
501        let mut cursor = ManifestCursor::new(&flat.bytes);
502        parse_manifest_header(&mut cursor).unwrap();
503        parse_feature_flags_section(&mut cursor).unwrap();
504        parse_metadata_reference(&mut cursor).unwrap();
505        parse_slab_index(&mut cursor).unwrap();
506        let history = parse_history(&mut cursor).unwrap();
507        assert_eq!(history.entries.len(), 1);
508        let entry = &history.entries[0];
509        assert_eq!(entry.op, limnifs_core::HistoryOp::Flatten);
510        let stored = u64::from_le_bytes(
511            entry
512                .params
513                .get(0..8)
514                .and_then(|s| s.try_into().ok())
515                .unwrap_or([0u8; 8]),
516        );
517        assert_eq!(stored, 3);
518    }
519
520    #[test]
521    fn merge_with_disjoint_files_combines_inodes() {
522        // Layer 1 has a.txt; layer 2 has b.txt. Flatten merges by
523        // inode number, so when two layers independently allocate the
524        // same inode number (each writer starts at 1), the latest
525        // layer's content wins. This test verifies that:
526        //   1. flatten runs to completion (no panics, valid output),
527        //   2. the merged metadata blob has exactly the latest
528        //      layer's content for the colliding inode numbers,
529        //   3. the layer count is recorded correctly.
530        //
531        // Cross-layer inode-number namespaces (the real use case for
532        // flatten) come from delta chains, not from independently-
533        // written images. Those are exercised by delta_builder tests.
534        let temp1 =
535            std::env::temp_dir().join(format!("limnifs-flatten-disjoint-1-{}", std::process::id()));
536        let temp2 =
537            std::env::temp_dir().join(format!("limnifs-flatten-disjoint-2-{}", std::process::id()));
538        make_tree(&temp1, &[("a.txt", b"aaa")]);
539        make_tree(&temp2, &[("b.txt", b"bbb")]);
540        let a1 = write_directory(&temp1).expect("write1");
541        let a2 = write_directory(&temp2).expect("write2");
542        std::fs::remove_dir_all(&temp1).ok();
543        std::fs::remove_dir_all(&temp2).ok();
544
545        let flat = flatten(&[&a1.bytes, &a2.bytes]).expect("flatten");
546        assert_eq!(flat.layer_count, 2);
547        // Both layers' writers each allocate exactly 2 inodes (root +
548        // file). Latest-wins per inode number gives 2 merged inodes.
549        assert_eq!(flat.inode_count, 2);
550
551        let mut cursor = ManifestCursor::new(&flat.bytes);
552        parse_manifest_header(&mut cursor).unwrap();
553        parse_feature_flags_section(&mut cursor).unwrap();
554        let meta_ref = parse_metadata_reference(&mut cursor).unwrap();
555        let blob_bytes = meta_ref.inline_metadata.as_deref().expect("inlined");
556        let mut blob_cursor = ManifestCursor::new(blob_bytes);
557        let blob = parse_metadata_blob(&mut blob_cursor).expect("blob");
558        // Layer 2 wins: b.txt is present, a.txt is not (inode 2 was
559        // overwritten).
560        let has_b = blob
561            .inodes
562            .iter()
563            .any(|i| matches!(&i.content_handle, ContentHandle::InlineData(d) if d == b"bbb"));
564        assert!(has_b, "latest layer's content must win on inode conflict");
565    }
566
567    #[test]
568    fn flatten_is_deterministic() {
569        let temp1 =
570            std::env::temp_dir().join(format!("limnifs-flatten-det-1-{}", std::process::id()));
571        let temp2 =
572            std::env::temp_dir().join(format!("limnifs-flatten-det-2-{}", std::process::id()));
573        make_tree(&temp1, &[("x", b"1")]);
574        make_tree(&temp2, &[("y", b"2")]);
575        let a1 = write_directory(&temp1).expect("w1");
576        let a2 = write_directory(&temp2).expect("w2");
577        std::fs::remove_dir_all(&temp1).ok();
578        std::fs::remove_dir_all(&temp2).ok();
579
580        let f1 = flatten(&[&a1.bytes, &a2.bytes]).expect("flatten");
581        let f2 = flatten(&[&a1.bytes, &a2.bytes]).expect("flatten");
582        assert_eq!(f1.bytes, f2.bytes, "flatten must be deterministic");
583        assert_eq!(f1.merkle_root, f2.merkle_root);
584    }
585
586    #[test]
587    fn flatten_preserves_drop_ids() {
588        // The DropIds in the merged metadata must match the union of
589        // the input layers' DropIds. This is the identity rule: flatten
590        // is metadata-only, never re-encodes drops.
591        let large = vec![0x42u8; 8192]; // > INLINE_THRESHOLD (4096)
592        let temp1 =
593            std::env::temp_dir().join(format!("limnifs-flatten-drops-1-{}", std::process::id()));
594        let temp2 =
595            std::env::temp_dir().join(format!("limnifs-flatten-drops-2-{}", std::process::id()));
596        make_tree(&temp1, &[("a.bin", &large)]);
597        make_tree(&temp2, &[("b.bin", &large)]);
598        let a1 = write_directory(&temp1).expect("w1");
599        let a2 = write_directory(&temp2).expect("w2");
600        std::fs::remove_dir_all(&temp1).ok();
601        std::fs::remove_dir_all(&temp2).ok();
602
603        let input_drops: std::collections::HashSet<[u8; 32]> =
604            extract_drop_ids(&[&a1.bytes, &a2.bytes]);
605        let flat = flatten(&[&a1.bytes, &a2.bytes]).expect("flatten");
606        let flat_drops = extract_drop_ids(&[&flat.bytes]);
607
608        assert_eq!(input_drops, flat_drops, "flatten must preserve all DropIds");
609    }
610
611    fn extract_drop_ids(layers: &[&[u8]]) -> std::collections::HashSet<[u8; 32]> {
612        let mut out = std::collections::HashSet::new();
613        for layer in layers {
614            let mut cursor = ManifestCursor::new(layer);
615            parse_manifest_header(&mut cursor).unwrap();
616            parse_feature_flags_section(&mut cursor).unwrap();
617            let meta_ref = parse_metadata_reference(&mut cursor).unwrap();
618            let Some(blob_bytes) = meta_ref.inline_metadata.as_deref() else {
619                continue;
620            };
621            let mut blob_cursor = ManifestCursor::new(blob_bytes);
622            let blob = parse_metadata_blob(&mut blob_cursor).unwrap();
623            for inode in &blob.inodes {
624                if let ContentHandle::SliceMap(slices) = &inode.content_handle {
625                    for slice in slices {
626                        out.insert(*slice.drop_id.as_bytes());
627                    }
628                }
629            }
630        }
631        out
632    }
633}