+12
| //! Reserve Mach-O header slack in the `decmpfs-stub` binary so the `exe` packer | ||
| //! can splice a new `LC_SEGMENT_64` (`SMOL/__DECMPFS`) into it in place, without | ||
| //! relinking. Only the stub bin, only on macOS, only under the `exe` feature — | ||
| //! every other build is untouched. | ||
| fn main() { | ||
| let exe_feature = std::env::var_os("CARGO_FEATURE_EXE").is_some(); | ||
| let macos = std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos"); | ||
| if exe_feature && macos { | ||
| println!("cargo::rustc-link-arg-bin=decmpfs-stub=-Wl,-headerpad,0x1000"); | ||
| } | ||
| } |
| //! The self-replacing stub binary (feature `exe`). A packer injects a | ||
| //! zstd-compressed payload executable into a copy of THIS binary; on first run | ||
| //! the copy decompresses the payload, writes it back to disk FS-compressed, | ||
| //! atomically replaces itself, and execs it. Every later run is the plain | ||
| //! materialized executable — this stub is gone. | ||
| //! | ||
| //! A bare, unpacked `decmpfs-stub` carries no payload, so `self_replace_and_exec` | ||
| //! returns `Ok(false)` and there is nothing to run: it exits non-zero with a | ||
| //! diagnostic rather than pretend-succeeding. | ||
| fn main() { | ||
| let argv: Vec<String> = std::env::args().collect(); | ||
| match decmpfs::exe::self_replace_and_exec(&argv) { | ||
| // Unix: exec replaces the process image, so Ok(true) never actually | ||
| // returns here. Windows spawns the materialized sibling and returns true. | ||
| Ok(true) => {} | ||
| Ok(false) => { | ||
| eprintln!( | ||
| "decmpfs-stub: no packed payload in this binary — pack one in with \ | ||
| decmpfs::exe::pack_executable_with_stub before running it." | ||
| ); | ||
| std::process::exit(2); | ||
| } | ||
| Err(e) => { | ||
| eprintln!("decmpfs-stub: {e}"); | ||
| std::process::exit(1); | ||
| } | ||
| } | ||
| } |
| //! Object-format surgery: splice the `SMOL/__DECMPFS` section into a Mach-O | ||
| //! stub (signable, then ad-hoc re-signed via the system `codesign`), or append | ||
| //! the `[payload][hash][len][MAGIC]` footer to an ELF/PE stub (their loaders | ||
| //! don't enforce a signature to `execve`, so no surgery is needed). | ||
| //! | ||
| //! Ported from napi-rs `crates/napi-compress/src/inject.rs` (the proven Mach-O | ||
| //! segment-insertion + slack/LINKEDIT-shift logic), with the crate-based | ||
| //! ad-hoc signer replaced by a `codesign -s - -f` shell-out so the crate stays | ||
| //! dep-lean. | ||
| //! | ||
| //! Every structural offset is walked at runtime from the load commands — the | ||
| //! stub is rebuilt with header slack (`-headerpad`) that guarantees room for | ||
| //! the new segment command, not a fixed offset. The byte layout matches a | ||
| //! binject(LIEF)-produced reference: segment `filesize` is the unpadded body | ||
| //! length, `vmsize` is page-rounded; section `size` is the body length, | ||
| //! `offset`/`fileoff` is the old `__LINKEDIT` fileoff; W^X `initprot` = | ||
| //! `maxprot` = `VM_PROT_READ`. | ||
| use std::path::Path; | ||
| const MH_MAGIC_64: u32 = 0xfeed_facf; | ||
| const LC_SEGMENT_64: u32 = 0x19; | ||
| const LC_SYMTAB: u32 = 0x02; | ||
| const LC_DYSYMTAB: u32 = 0x0b; | ||
| const LC_DYLD_INFO: u32 = 0x22; | ||
| const LC_DYLD_INFO_ONLY: u32 = 0x8000_0022; | ||
| const LC_FUNCTION_STARTS: u32 = 0x26; | ||
| const LC_DATA_IN_CODE: u32 = 0x29; | ||
| const LC_CODE_SIGNATURE: u32 = 0x1d; | ||
| const LC_DYLD_CHAINED_FIXUPS: u32 = 0x8000_0034; | ||
| const LC_DYLD_EXPORTS_TRIE: u32 = 0x8000_0033; | ||
| const CPU_TYPE_ARM64: u32 = 0x0100_000c; | ||
| const MACH_HEADER_64_SIZE: usize = 32; | ||
| /// `cmd,cmdsize,segname[16],vmaddr,vmsize,fileoff,filesize,maxprot,initprot,nsects,flags`. | ||
| const SEGMENT_COMMAND_64_SIZE: usize = 72; | ||
| /// `sectname[16],segname[16],addr,size,offset,align,reloff,nreloc,flags,reserved1..3`. | ||
| const SECTION_64_SIZE: usize = 80; | ||
| const NEW_LC_SIZE: usize = SEGMENT_COMMAND_64_SIZE + SECTION_64_SIZE; // 152 | ||
| /// `VM_PROT_READ` only. An injected segment MUST be read-only: RWX (0x07) makes | ||
| /// dyld refuse to mmap the bundle on dlopen (EACCES) even with a valid signature. | ||
| const VM_PROT_READ: u32 = 0x01; | ||
| fn u32_le(bytes: &[u8], off: usize) -> Result<u32, String> { | ||
| bytes | ||
| .get(off..off + 4) | ||
| .and_then(|s| s.try_into().ok()) | ||
| .map(u32::from_le_bytes) | ||
| .ok_or_else(|| format!("truncated u32 at offset {off}")) | ||
| } | ||
| fn u64_le(bytes: &[u8], off: usize) -> Result<u64, String> { | ||
| bytes | ||
| .get(off..off + 8) | ||
| .and_then(|s| s.try_into().ok()) | ||
| .map(u64::from_le_bytes) | ||
| .ok_or_else(|| format!("truncated u64 at offset {off}")) | ||
| } | ||
| fn put_u32(bytes: &mut [u8], off: usize, value: u32) -> Result<(), String> { | ||
| bytes | ||
| .get_mut(off..off + 4) | ||
| .ok_or_else(|| format!("truncated u32 write at offset {off}"))? | ||
| .copy_from_slice(&value.to_le_bytes()); | ||
| Ok(()) | ||
| } | ||
| fn put_u64(bytes: &mut [u8], off: usize, value: u64) -> Result<(), String> { | ||
| bytes | ||
| .get_mut(off..off + 8) | ||
| .ok_or_else(|| format!("truncated u64 write at offset {off}"))? | ||
| .copy_from_slice(&value.to_le_bytes()); | ||
| Ok(()) | ||
| } | ||
| fn round_up(value: u64, align: u64) -> u64 { | ||
| if align == 0 { | ||
| return value; | ||
| } | ||
| value.div_ceil(align) * align | ||
| } | ||
| /// One linkedit-pointing field to bump: the absolute byte offset of a `u32` | ||
| /// file-offset field within the (post-splice) command stream. | ||
| struct OffsetField { | ||
| at: usize, | ||
| } | ||
| /// The structural anchors found by walking the load commands once. | ||
| struct Layout { | ||
| page_size: u64, | ||
| /// Byte offset of the `__LINKEDIT` segment command (splice point). | ||
| linkedit_lc_off: usize, | ||
| linkedit_fileoff: u64, | ||
| linkedit_vmaddr: u64, | ||
| /// `LC_CODE_SIGNATURE`, if present: (command byte offset, sig dataoff). | ||
| code_sig: Option<CodeSig>, | ||
| /// First `__TEXT` section file offset — the slack boundary the new LC must fit | ||
| /// under (everything before it is header + load commands). | ||
| first_section_offset: u64, | ||
| end_of_lc: usize, | ||
| /// Linkedit-pointing `u32` file-offset fields, by their byte offset in the | ||
| /// ORIGINAL command stream (caller re-bases by +NEW_LC after the splice). | ||
| linkedit_pointers: Vec<OffsetField>, | ||
| } | ||
| struct CodeSig { | ||
| lc_off: usize, | ||
| dataoff: u64, | ||
| } | ||
| /// A NUL-padded fixed-width name slot equals `want`. | ||
| fn name_eq(slot: &[u8], want: &[u8]) -> bool { | ||
| slot.len() >= want.len() | ||
| && &slot[..want.len()] == want | ||
| && slot[want.len()..].iter().all(|&b| b == 0) | ||
| } | ||
| /// Walk the mach_header_64 + load commands once, recording every anchor the | ||
| /// surgery touches. Refuses anything but a single-arch 64-bit LE Mach-O. | ||
| fn read_layout(bytes: &[u8]) -> Result<Layout, String> { | ||
| if u32_le(bytes, 0)? != MH_MAGIC_64 { | ||
| return Err("not a 64-bit little-endian Mach-O (bad magic)".to_string()); | ||
| } | ||
| let cputype = u32_le(bytes, 4)?; | ||
| let page_size: u64 = if cputype == CPU_TYPE_ARM64 { | ||
| 0x4000 | ||
| } else { | ||
| 0x1000 | ||
| }; | ||
| let ncmds = u32_le(bytes, 16)?; | ||
| let mut linkedit_lc_off: Option<usize> = None; | ||
| let mut linkedit_fileoff = 0u64; | ||
| let mut linkedit_vmaddr = 0u64; | ||
| let mut code_sig: Option<CodeSig> = None; | ||
| let mut first_section_offset = u64::MAX; | ||
| let mut linkedit_pointers: Vec<OffsetField> = Vec::new(); | ||
| let mut off = MACH_HEADER_64_SIZE; | ||
| for _ in 0..ncmds { | ||
| let cmd = u32_le(bytes, off)?; | ||
| let cmdsize = u32_le(bytes, off + 4)? as usize; | ||
| if cmdsize < 8 || off + cmdsize > bytes.len() { | ||
| return Err(format!( | ||
| "malformed load command at {off} (cmdsize {cmdsize})" | ||
| )); | ||
| } | ||
| match cmd { | ||
| LC_SEGMENT_64 => { | ||
| let segname = bytes | ||
| .get(off + 8..off + 24) | ||
| .ok_or_else(|| format!("truncated segname at {off}"))?; | ||
| let fileoff = u64_le(bytes, off + 40)?; | ||
| let nsects = u32_le(bytes, off + 64)?; | ||
| if name_eq(segname, b"__LINKEDIT") { | ||
| linkedit_lc_off = Some(off); | ||
| linkedit_fileoff = fileoff; | ||
| linkedit_vmaddr = u64_le(bytes, off + 24)?; | ||
| } | ||
| // Track the smallest section file offset across every segment — the | ||
| // header-slack ceiling (load commands must not overrun the first | ||
| // mapped section's bytes). | ||
| let mut soff = off + SEGMENT_COMMAND_64_SIZE; | ||
| for _ in 0..nsects { | ||
| let sect_off = u32_le(bytes, soff + 48)? as u64; | ||
| // offset == 0 marks a zero-fill section (__bss/__thread_bss); skip it. | ||
| if sect_off != 0 && sect_off < first_section_offset { | ||
| first_section_offset = sect_off; | ||
| } | ||
| soff = soff | ||
| .checked_add(SECTION_64_SIZE) | ||
| .ok_or_else(|| "section table offset overflow".to_string())?; | ||
| } | ||
| } | ||
| LC_DYLD_INFO | LC_DYLD_INFO_ONLY => { | ||
| // rebase_off@8, bind_off@16, weak_bind_off@24, lazy_bind_off@32, export_off@40. | ||
| for field in [8, 16, 24, 32, 40] { | ||
| linkedit_pointers.push(OffsetField { at: off + field }); | ||
| } | ||
| } | ||
| LC_SYMTAB => { | ||
| // symoff@8, stroff@16. | ||
| linkedit_pointers.push(OffsetField { at: off + 8 }); | ||
| linkedit_pointers.push(OffsetField { at: off + 16 }); | ||
| } | ||
| LC_DYSYMTAB => { | ||
| // tocoff@32, modtaboff@40, extrefsymoff@48, indirectsymoff@56, | ||
| // extreloff@64, locreloff@72 — all linkedit-relative file offsets. | ||
| for field in [32, 40, 48, 56, 64, 72] { | ||
| linkedit_pointers.push(OffsetField { at: off + field }); | ||
| } | ||
| } | ||
| LC_FUNCTION_STARTS | LC_DATA_IN_CODE | LC_DYLD_CHAINED_FIXUPS | LC_DYLD_EXPORTS_TRIE => { | ||
| // linkedit_data_command: dataoff@8. | ||
| linkedit_pointers.push(OffsetField { at: off + 8 }); | ||
| } | ||
| LC_CODE_SIGNATURE => { | ||
| // linkedit_data_command: dataoff is a u32 file offset at +8 (NOT a u64). | ||
| code_sig = Some(CodeSig { | ||
| lc_off: off, | ||
| dataoff: u32_le(bytes, off + 8)? as u64, | ||
| }); | ||
| } | ||
| _ => {} | ||
| } | ||
| off = off | ||
| .checked_add(cmdsize) | ||
| .ok_or_else(|| "load command offset overflow".to_string())?; | ||
| } | ||
| let linkedit_lc_off = | ||
| linkedit_lc_off.ok_or_else(|| "no __LINKEDIT segment to anchor the new section".to_string())?; | ||
| if first_section_offset == u64::MAX { | ||
| return Err("no mapped section to bound the header slack".to_string()); | ||
| } | ||
| Ok(Layout { | ||
| page_size, | ||
| linkedit_lc_off, | ||
| linkedit_fileoff, | ||
| linkedit_vmaddr, | ||
| code_sig, | ||
| first_section_offset, | ||
| end_of_lc: off, | ||
| linkedit_pointers, | ||
| }) | ||
| } | ||
| /// Build the 152-byte `LC_SEGMENT_64` + one `section_64` for `SMOL/__DECMPFS`. | ||
| fn build_segment_lc( | ||
| body_len: u64, | ||
| delta: u64, | ||
| fileoff: u64, | ||
| vmaddr: u64, | ||
| ) -> Result<Vec<u8>, String> { | ||
| let mut lc = vec![0u8; NEW_LC_SIZE]; | ||
| // segment_command_64 | ||
| put_u32(&mut lc, 0, LC_SEGMENT_64)?; | ||
| put_u32(&mut lc, 4, NEW_LC_SIZE as u32)?; | ||
| lc.get_mut(8..12) | ||
| .ok_or_else(|| "new LC too short for segname".to_string())? | ||
| .copy_from_slice(b"SMOL"); // segname (NUL-padded) | ||
| put_u64(&mut lc, 24, vmaddr)?; // vmaddr | ||
| put_u64(&mut lc, 32, delta)?; // vmsize (page-rounded) | ||
| put_u64(&mut lc, 40, fileoff)?; // fileoff | ||
| put_u64(&mut lc, 48, body_len)?; // filesize (unpadded body) | ||
| put_u32(&mut lc, 56, VM_PROT_READ)?; // maxprot | ||
| put_u32(&mut lc, 60, VM_PROT_READ)?; // initprot (W^X) | ||
| put_u32(&mut lc, 64, 1)?; // nsects | ||
| put_u32(&mut lc, 68, 0)?; // flags | ||
| // section_64 at +72 | ||
| let s = SEGMENT_COMMAND_64_SIZE; | ||
| lc.get_mut(s..s + 9) | ||
| .ok_or_else(|| "new LC too short for sectname".to_string())? | ||
| .copy_from_slice(b"__DECMPFS"); // sectname | ||
| lc.get_mut(s + 16..s + 20) | ||
| .ok_or_else(|| "new LC too short for section segname".to_string())? | ||
| .copy_from_slice(b"SMOL"); // segname | ||
| put_u64(&mut lc, s + 32, vmaddr)?; // addr | ||
| put_u64(&mut lc, s + 40, body_len)?; // size (unpadded body) | ||
| put_u32(&mut lc, s + 48, fileoff as u32)?; // offset | ||
| put_u32(&mut lc, s + 52, 2)?; // align 2^2 = 4 | ||
| // reloff/nreloc/flags/reserved1..3 stay 0 | ||
| Ok(lc) | ||
| } | ||
| /// Inject `section_body` into `stub`, dispatching on the stub's object format. | ||
| /// Mach-O gets the signable `SMOL/__DECMPFS` segment splice (`section_body` is | ||
| /// the caller's [`super::section::build_section_payload`] output); ELF/PE need | ||
| /// no surgery — `section_body` is the caller's [`super::section::build_footer`] | ||
| /// output and is simply appended at EOF. Returns the modified bytes (Mach-O | ||
| /// still UNSIGNED — the caller runs [`resign`]). | ||
| #[allow(dead_code)] // wired in the replace stage | ||
| pub(crate) fn inject_payload(stub: &[u8], section_body: &[u8]) -> Result<Vec<u8>, String> { | ||
| match stub.first().copied() { | ||
| Some(0xcf) if stub.get(0..4) == Some(&MH_MAGIC_64.to_le_bytes()) => { | ||
| inject_macho(stub, section_body) | ||
| } | ||
| Some(0x7f) if stub.get(0..4) == Some(b"\x7fELF") => Ok(append_footer(stub, section_body)), | ||
| Some(0x4d) if stub.get(0..2) == Some(b"MZ") => Ok(append_footer(stub, section_body)), | ||
| _ => Err("unrecognized stub format: not a 64-bit LE Mach-O, ELF, or PE".to_string()), | ||
| } | ||
| } | ||
| /// ELF/PE: their loaders map from program headers / section headers already on | ||
| /// disk and never look past EOF for anything the loader cares about, so the | ||
| /// footer built by [`super::section::build_footer`] rides as a plain append — | ||
| /// no header field moves. | ||
| fn append_footer(stub: &[u8], footer: &[u8]) -> Vec<u8> { | ||
| let mut out = Vec::with_capacity(stub.len() + footer.len()); | ||
| out.extend_from_slice(stub); | ||
| out.extend_from_slice(footer); | ||
| out | ||
| } | ||
| /// Mach-O: splice a READ-only `SMOL/__DECMPFS` `LC_SEGMENT_64`. Returns the modified | ||
| /// bytes (still unsigned — caller re-signs via [`resign`]). | ||
| fn inject_macho(stub: &[u8], section_body: &[u8]) -> Result<Vec<u8>, String> { | ||
| let layout = read_layout(stub)?; | ||
| // 1. Slack guard: the new 152-byte LC must fit between END_OF_LC and the first | ||
| // mapped section. The stub is linked with -headerpad,0x1000 to guarantee it. | ||
| let slack = (layout.first_section_offset as usize) | ||
| .checked_sub(layout.end_of_lc) | ||
| .ok_or_else(|| { | ||
| "first mapped section precedes the end of load commands (corrupt layout)".to_string() | ||
| })?; | ||
| if slack < NEW_LC_SIZE { | ||
| return Err(format!( | ||
| "header slack {slack} < {NEW_LC_SIZE} bytes for the new segment command; \ | ||
| rebuild the stub with -headerpad,0x1000" | ||
| )); | ||
| } | ||
| let body_len = section_body.len() as u64; | ||
| let delta = round_up(body_len, layout.page_size); | ||
| let new_fileoff = layout.linkedit_fileoff; | ||
| let new_vmaddr = layout.linkedit_vmaddr; | ||
| let linkedit_start = layout.linkedit_fileoff as usize; | ||
| // Exclude the old signature bytes — they trail __LINKEDIT and the signer | ||
| // regenerates them. Without a signature, __LINKEDIT runs to EOF. | ||
| let linkedit_end = match &layout.code_sig { | ||
| Some(sig) => sig.dataoff as usize, | ||
| None => stub.len(), | ||
| }; | ||
| if linkedit_end < linkedit_start { | ||
| return Err("code signature precedes __LINKEDIT (corrupt layout)".to_string()); | ||
| } | ||
| // Mach-O section fileoff + every LINKEDIT-relative offset are 32-bit. `linkedit_end | ||
| // + delta` bounds the widest post-splice offset (the new section's fileoff and each | ||
| // `current + delta` bump all fall at or below it), so guard that sum against u32::MAX | ||
| // before the `as u32` casts below — release builds have overflow-checks off, so a | ||
| // >4 GiB payload would otherwise truncate silently and corrupt the LINKEDIT tables. | ||
| if (linkedit_end as u64) | ||
| .checked_add(delta) | ||
| .is_none_or(|end| end > u64::from(u32::MAX)) | ||
| { | ||
| return Err(format!( | ||
| "injected payload too large: file offset {linkedit_end} + {delta} exceeds the u32 Mach-O limit" | ||
| )); | ||
| } | ||
| let end_after_new_lc = layout | ||
| .end_of_lc | ||
| .checked_add(NEW_LC_SIZE) | ||
| .ok_or_else(|| "end-of-load-commands offset overflow".to_string())?; | ||
| let stub_before_linkedit = stub | ||
| .get(0..layout.linkedit_lc_off) | ||
| .ok_or_else(|| "__LINKEDIT LC offset out of range".to_string())?; | ||
| let stub_lc_tail = stub | ||
| .get(layout.linkedit_lc_off..layout.end_of_lc) | ||
| .ok_or_else(|| "load-command tail out of range".to_string())?; | ||
| let stub_headerpad_tail = stub | ||
| .get(end_after_new_lc..linkedit_start) | ||
| .ok_or_else(|| "headerpad tail out of range".to_string())?; | ||
| let stub_linkedit_body = stub | ||
| .get(linkedit_start..linkedit_end) | ||
| .ok_or_else(|| "__LINKEDIT body out of range".to_string())?; | ||
| // 2. Assemble the new file so NOTHING before __LINKEDIT moves its file offset. | ||
| // The new 152-byte LC is written into the header slack: the load-command | ||
| // bytes [linkedit_lc_off, END_OF_LC) shift forward by 152, consuming 152 of | ||
| // the headerpad gap; the first mapped section and every byte up to | ||
| // __LINKEDIT keep their original file offset. __LINKEDIT's body then slides | ||
| // down by DELTA only (the page-rounded section content occupies the old | ||
| // __LINKEDIT file region). | ||
| // | ||
| // [0, linkedit_lc_off) header + LCs before __LINKEDIT's LC | ||
| // new_lc (152) the SMOL/__DECMPFS segment command | ||
| // [linkedit_lc_off, END_OF_LC) __LINKEDIT's LC + the LCs after it | ||
| // [END_OF_LC+152, linkedit_start) remaining headerpad + all mapped bytes | ||
| // section_body + zero-pad to DELTA the injected section content | ||
| // [linkedit_start, linkedit_end) __LINKEDIT body (sans old signature) | ||
| let new_lc = build_segment_lc(body_len, delta, new_fileoff, new_vmaddr)?; | ||
| let pad_len = (delta - body_len) as usize; | ||
| let mut out: Vec<u8> = Vec::with_capacity(stub.len() + delta as usize); | ||
| out.extend_from_slice(stub_before_linkedit); | ||
| out.extend_from_slice(&new_lc); | ||
| out.extend_from_slice(stub_lc_tail); | ||
| // Headerpad after the (now-larger) command stream, shrunk by the 152 bytes the | ||
| // new LC consumed, so the first mapped section stays at its original offset. | ||
| out.extend_from_slice(stub_headerpad_tail); | ||
| out.extend_from_slice(section_body); | ||
| out.resize( | ||
| out | ||
| .len() | ||
| .checked_add(pad_len) | ||
| .ok_or_else(|| "padded section length overflow".to_string())?, | ||
| 0, | ||
| ); | ||
| out.extend_from_slice(stub_linkedit_body); | ||
| // 3. Header: ncmds += 1, sizeofcmds += NEW_LC. (The strip below nets these back | ||
| // down by the code-signature command.) | ||
| let ncmds = u32_le(&out, 16)?; | ||
| put_u32(&mut out, 16, ncmds + 1)?; | ||
| let sizeofcmds = u32_le(&out, 20)?; | ||
| put_u32(&mut out, 20, sizeofcmds + NEW_LC_SIZE as u32)?; | ||
| // 4. Shift __LINKEDIT's own fileoff + vmaddr by DELTA (its LC now sits at | ||
| // linkedit_lc_off + NEW_LC, after the new segment command). When the old | ||
| // signature was excluded, shrink filesize/vmsize to the bytes that remain on | ||
| // disk (up to where the signature began) — else the segment claims more than | ||
| // the file holds and the signer's parser rejects it; the signer re-extends. | ||
| let le_lc = layout | ||
| .linkedit_lc_off | ||
| .checked_add(NEW_LC_SIZE) | ||
| .ok_or_else(|| "__LINKEDIT LC offset overflow after splice".to_string())?; | ||
| let le_fileoff = u64_le(&out, le_lc + 40)?; | ||
| put_u64(&mut out, le_lc + 40, le_fileoff + delta)?; | ||
| let le_vmaddr = u64_le(&out, le_lc + 24)?; | ||
| put_u64(&mut out, le_lc + 24, le_vmaddr + delta)?; | ||
| if let Some(sig) = &layout.code_sig { | ||
| let remaining = sig | ||
| .dataoff | ||
| .checked_sub(layout.linkedit_fileoff) | ||
| .ok_or_else(|| "code signature precedes __LINKEDIT fileoff".to_string())?; | ||
| put_u64(&mut out, le_lc + 48, remaining)?; // filesize | ||
| put_u64(&mut out, le_lc + 32, round_up(remaining, layout.page_size))?; // vmsize | ||
| } | ||
| // 5. Bump every linkedit-pointing file offset by DELTA. A field whose command | ||
| // sits at/after __LINKEDIT's LC had its byte position shifted +NEW_LC when the | ||
| // new LC was written before __LINKEDIT; a field before it keeps its position. | ||
| // Skip zeros (an absent table). | ||
| for field in &layout.linkedit_pointers { | ||
| let at = if field.at >= layout.linkedit_lc_off { | ||
| field | ||
| .at | ||
| .checked_add(NEW_LC_SIZE) | ||
| .ok_or_else(|| "linkedit-pointer offset overflow after splice".to_string())? | ||
| } else { | ||
| field.at | ||
| }; | ||
| let current = u32_le(&out, at)?; | ||
| if current != 0 { | ||
| put_u32(&mut out, at, current + delta as u32)?; | ||
| } | ||
| } | ||
| // 6. Strip LC_CODE_SIGNATURE in place (it is the LAST command, so zeroing its | ||
| // bytes + decrementing the header counts removes it WITHOUT shifting any file | ||
| // offset — a splice here would move __LINKEDIT and re-break step 5). Its | ||
| // trailing __LINKEDIT bytes were already excluded in step 2; the signer | ||
| // re-adds a correct command into the freed command-stream slack. | ||
| if let Some(sig) = &layout.code_sig { | ||
| let sig_lc = sig | ||
| .lc_off | ||
| .checked_add(NEW_LC_SIZE) | ||
| .ok_or_else(|| "code-signature LC offset overflow after splice".to_string())?; | ||
| let sig_cmdsize = u32_le(&out, sig_lc + 4)? as usize; | ||
| let ncmds = u32_le(&out, 16)?; | ||
| put_u32(&mut out, 16, ncmds - 1)?; | ||
| let sizeofcmds = u32_le(&out, 20)?; | ||
| put_u32(&mut out, 20, sizeofcmds - sig_cmdsize as u32)?; | ||
| let zero_range = out | ||
| .get_mut(sig_lc..sig_lc + sig_cmdsize) | ||
| .ok_or_else(|| "code-signature LC out of range after splice".to_string())?; | ||
| zero_range.fill(0); | ||
| } | ||
| Ok(out) | ||
| } | ||
| /// Ad-hoc re-sign a materialized Mach-O at `path` via the system `codesign` | ||
| /// (`codesign -s - -f <path>`). macOS-only; a no-op `Ok(())` elsewhere — ELF/PE | ||
| /// loaders enforce no signature, so the appended footer needs none. | ||
| #[cfg(not(target_os = "macos"))] | ||
| #[allow(dead_code)] // wired in the replace stage | ||
| pub(crate) fn resign(_path: &Path) -> Result<(), String> { | ||
| Ok(()) | ||
| } | ||
| #[cfg(target_os = "macos")] | ||
| #[allow(dead_code)] // wired in the replace stage | ||
| pub(crate) fn resign(path: &Path) -> Result<(), String> { | ||
| let status = std::process::Command::new("codesign") | ||
| .arg("-s") | ||
| .arg("-") | ||
| .arg("-f") | ||
| .arg(path) | ||
| .status() | ||
| .map_err(|source| format!("spawn codesign for {}: {source}", path.display()))?; | ||
| if !status.success() { | ||
| return Err(format!("codesign failed for {} ({status})", path.display())); | ||
| } | ||
| Ok(()) | ||
| } | ||
| #[cfg(test)] | ||
| #[cfg_attr(coverage_nightly, coverage(off))] | ||
| mod tests { | ||
| use super::*; | ||
| use crate::exe::section::{build_footer, find_footer}; | ||
| #[cfg(target_os = "macos")] | ||
| use crate::exe::section::{build_section_payload, find_section, parse_section_payload}; | ||
| /// A minimal two-segment 64-bit Mach-O: `__TEXT` (one section, anchoring the | ||
| /// header-slack boundary) then `__LINKEDIT` (the splice point). Big enough for | ||
| /// `read_layout`/`inject_macho` to walk for real, small enough to stay a unit | ||
| /// test — unlike [`crate::exe::section::synthetic_object_with_section`] (built | ||
| /// for `find_section`'s read path, which never inspects `__LINKEDIT`), this | ||
| /// fixture carries the segment `inject_macho` requires to splice at all. | ||
| #[cfg(target_os = "macos")] | ||
| fn minimal_macho64_with_linkedit( | ||
| first_section_offset: u32, | ||
| linkedit_fileoff: u64, | ||
| linkedit_body: &[u8], | ||
| ) -> Vec<u8> { | ||
| let linkedit_lc_off = MACH_HEADER_64_SIZE + NEW_LC_SIZE; // 32 + 152 = 184 | ||
| let linkedit_len = linkedit_body.len() as u64; | ||
| let mut m = vec![0u8; linkedit_fileoff as usize + linkedit_body.len()]; | ||
| // mach_header_64. | ||
| put_u32(&mut m, 0, MH_MAGIC_64).expect("header magic"); | ||
| put_u32(&mut m, 16, 2).expect("ncmds"); // __TEXT, __LINKEDIT | ||
| put_u32(&mut m, 20, (NEW_LC_SIZE + SEGMENT_COMMAND_64_SIZE) as u32).expect("sizeofcmds"); | ||
| // __TEXT segment_command_64 + one section_64, at MACH_HEADER_64_SIZE. | ||
| let text = MACH_HEADER_64_SIZE; | ||
| put_u32(&mut m, text, LC_SEGMENT_64).expect("text cmd"); | ||
| put_u32(&mut m, text + 4, NEW_LC_SIZE as u32).expect("text cmdsize"); | ||
| m[text + 8..text + 14].copy_from_slice(b"__TEXT"); | ||
| put_u32(&mut m, text + 64, 1).expect("text nsects"); | ||
| let text_sect = text + SEGMENT_COMMAND_64_SIZE; | ||
| m[text_sect..text_sect + 6].copy_from_slice(b"__text"); | ||
| m[text_sect + 16..text_sect + 22].copy_from_slice(b"__TEXT"); | ||
| put_u32(&mut m, text_sect + 48, first_section_offset).expect("text section offset"); | ||
| // __LINKEDIT segment_command_64 (no sections). | ||
| put_u32(&mut m, linkedit_lc_off, LC_SEGMENT_64).expect("linkedit cmd"); | ||
| put_u32(&mut m, linkedit_lc_off + 4, SEGMENT_COMMAND_64_SIZE as u32).expect("linkedit cmdsize"); | ||
| m[linkedit_lc_off + 8..linkedit_lc_off + 18].copy_from_slice(b"__LINKEDIT"); | ||
| put_u64( | ||
| &mut m, | ||
| linkedit_lc_off + 24, | ||
| 0x1_0000_0000 + linkedit_fileoff, | ||
| ) | ||
| .expect("linkedit vmaddr"); | ||
| put_u64(&mut m, linkedit_lc_off + 40, linkedit_fileoff).expect("linkedit fileoff"); | ||
| put_u64(&mut m, linkedit_lc_off + 48, linkedit_len).expect("linkedit filesize"); | ||
| m[linkedit_fileoff as usize..linkedit_fileoff as usize + linkedit_body.len()] | ||
| .copy_from_slice(linkedit_body); | ||
| m | ||
| } | ||
| #[cfg(target_os = "macos")] | ||
| #[test] | ||
| fn macho_injection_round_trips_through_find_section() { | ||
| let body = build_section_payload(0x0123_4567_89ab_cdef, b"the-zstd-payload-bytes"); | ||
| let stub = minimal_macho64_with_linkedit(512, 600, b"LINKEDIT-CONTENT"); | ||
| let out = inject_payload(&stub, &body).expect("inject"); | ||
| let raw = find_section(&out).expect("section found"); | ||
| let got = parse_section_payload(raw).expect("parses"); | ||
| assert_eq!(got.content_hash, 0x0123_4567_89ab_cdef); | ||
| assert_eq!(got.payload, b"the-zstd-payload-bytes"); | ||
| } | ||
| #[test] | ||
| fn elf_and_pe_injection_append_the_footer_verbatim() { | ||
| // The ELF/PE loader maps from program/section headers already on disk and | ||
| // never looks past EOF, so injection is a pure append: the footer bytes | ||
| // `find_footer` locates at the tail of the grown image must equal the | ||
| // footer `build_footer` produced, byte for byte. | ||
| let footer = build_footer(0xfeed_face_dead_beef, b"the-zstd-bytes"); | ||
| let mut elf_stub = vec![0u8; 64]; | ||
| elf_stub[0..4].copy_from_slice(b"\x7fELF"); | ||
| let elf_out = inject_payload(&elf_stub, &footer).expect("inject elf"); | ||
| assert_eq!( | ||
| find_footer(&elf_out).expect("footer found"), | ||
| footer.as_slice() | ||
| ); | ||
| let mut pe_stub = vec![0u8; 64]; | ||
| pe_stub[0..2].copy_from_slice(b"MZ"); | ||
| let pe_out = inject_payload(&pe_stub, &footer).expect("inject pe"); | ||
| assert_eq!( | ||
| find_footer(&pe_out).expect("footer found"), | ||
| footer.as_slice() | ||
| ); | ||
| } | ||
| #[test] | ||
| fn dispatch_rejects_unknown_format() { | ||
| assert!(inject_payload(b"not an object file", b"x").is_err()); | ||
| } | ||
| #[cfg(not(target_os = "macos"))] | ||
| #[test] | ||
| fn resign_is_a_no_op_off_macos() { | ||
| assert!(resign(Path::new("/does/not/exist")).is_ok()); | ||
| } | ||
| } |
+352
| //! Self-replacing executable packing (feature `exe`). | ||
| //! | ||
| //! [`pack_executable`] turns a real executable `E` into a stub `E'` carrying a | ||
| //! zstd-compressed copy of `E` in a signable `SMOL/__DECMPFS` section (macOS) or | ||
| //! an EOF footer (ELF/PE). On first run `E'` calls [`self_replace_and_exec`]: | ||
| //! it decompresses the payload, writes `E` back to disk **FS-compressed** via | ||
| //! [`crate::compress_bytes`], atomically renames over `argv[0]`, re-signs on | ||
| //! macOS, and `execve`s the materialized binary. Every later run is native — the | ||
| //! stub is gone, replaced by the real (smaller-on-disk) executable. | ||
| //! | ||
| //! The section/footer wire format is owned by [`section`]; the object surgery by | ||
| //! [`inject`]; the runtime swap by [`replace`]. All three are private; the crate | ||
| //! surface is the two functions below plus [`PackOutcome`]. | ||
| use std::path::Path; | ||
| use crate::{Error, Gate}; | ||
| mod inject; | ||
| mod replace; | ||
| mod section; | ||
| /// The result of packing an executable. Only `Err` is a hard failure. | ||
| #[derive(Debug, Clone, PartialEq, Eq)] | ||
| pub enum PackOutcome { | ||
| /// Packed: `before` = original size, `after` = the stub's size on disk. | ||
| Packed { before: u64, after: u64 }, | ||
| /// The gate excluded the input (by glob/size) — nothing written. | ||
| SkippedGate, | ||
| } | ||
| /// Host-side packer: read `src`, compress it, inject the payload into a stub, and | ||
| /// write the self-replacing executable to `dest`. `gate` filters by glob/size the | ||
| /// same way [`crate::compress_bytes`] does; a gate miss returns | ||
| /// [`PackOutcome::SkippedGate`] without writing. | ||
| /// | ||
| /// The stub bytes are the CURRENT executable's own image by default (a decmpfs | ||
| /// binary that links the `exe` feature IS the stub); pass an explicit stub via | ||
| /// [`pack_executable_with_stub`] to cross-pack. | ||
| pub fn pack_executable(src: &Path, dest: &Path, gate: &Gate) -> Result<PackOutcome, Error> { | ||
| let stub = std::env::current_exe().map_err(|source| Error::Io { | ||
| context: "resolve current_exe for the pack stub", | ||
| source, | ||
| })?; | ||
| pack_executable_with_stub(&stub, src, dest, gate) | ||
| } | ||
| /// [`pack_executable`] with an explicit stub image — the self-replacing runtime | ||
| /// binary whose `SMOL/__DECMPFS` section/footer receives the payload. | ||
| /// | ||
| /// `gate` is checked against `dest` (the caller's chosen name, normalized to | ||
| /// forward slashes) before anything is read or written — a miss returns | ||
| /// [`PackOutcome::SkippedGate`] and touches neither `stub` nor `dest`. | ||
| pub fn pack_executable_with_stub( | ||
| stub: &Path, | ||
| src: &Path, | ||
| dest: &Path, | ||
| gate: &Gate, | ||
| ) -> Result<PackOutcome, Error> { | ||
| let src_bytes = std::fs::read(src).map_err(|source| Error::Io { | ||
| context: "read the source executable to pack", | ||
| source, | ||
| })?; | ||
| let src_len = src_bytes.len() as u64; | ||
| let dest_name = dest.to_string_lossy().replace('\\', "/"); | ||
| if !gate.matches(&dest_name, src_len) { | ||
| return Ok(PackOutcome::SkippedGate); | ||
| } | ||
| let content_hash = section::fnv1a64(&src_bytes); | ||
| let zstd_payload = | ||
| zstd::stream::encode_all(src_bytes.as_slice(), 19).map_err(|source| Error::Io { | ||
| context: "zstd-compress the source executable", | ||
| source, | ||
| })?; | ||
| let stub_bytes = std::fs::read(stub).map_err(|source| Error::Io { | ||
| context: "read the stub executable", | ||
| source, | ||
| })?; | ||
| // Mach-O carries the payload in a signable `SMOL/__DECMPFS` section body; | ||
| // ELF/PE carry it in an EOF footer. `inject::inject_payload`'s ELF/PE arm is | ||
| // a verbatim append (no structural surgery), so the footer must already be | ||
| // fully formed here — only the Mach-O arm gets its own wrapper. | ||
| let section_body = if is_macho64(&stub_bytes) { | ||
| section::build_section_payload(content_hash, &zstd_payload) | ||
| } else { | ||
| section::build_footer(content_hash, &zstd_payload) | ||
| }; | ||
| let packed = inject::inject_payload(&stub_bytes, §ion_body).map_err(|message| Error::Io { | ||
| context: "inject the packed payload into the stub", | ||
| source: std::io::Error::other(message), | ||
| })?; | ||
| std::fs::write(dest, &packed).map_err(|source| Error::Io { | ||
| context: "write the packed executable", | ||
| source, | ||
| })?; | ||
| #[cfg(unix)] | ||
| mark_executable(dest)?; | ||
| // Re-sign only a Mach-O output — gate on the STUB's format, not the host OS, | ||
| // so cross-packing an ELF/PE stub on a macOS host never hands a non-Mach-O | ||
| // file (whose trailing footer a future codesign could mutate) to codesign. | ||
| // `resign` is itself a no-op off macOS. | ||
| if is_macho64(&stub_bytes) { | ||
| inject::resign(dest).map_err(|message| Error::Io { | ||
| context: "re-sign the packed executable", | ||
| source: std::io::Error::other(message), | ||
| })?; | ||
| } | ||
| let after = std::fs::metadata(dest) | ||
| .map_err(|source| Error::Io { | ||
| context: "stat the packed executable's on-disk size", | ||
| source, | ||
| })? | ||
| .len(); | ||
| Ok(PackOutcome::Packed { | ||
| before: src_len, | ||
| after, | ||
| }) | ||
| } | ||
| /// A 64-bit little-endian Mach-O magic at the head of `bytes` — the same | ||
| /// dispatch [`inject::inject_payload`] makes internally, mirrored here so the | ||
| /// right payload wrapper (section body vs. footer) is built before the call and | ||
| /// so the runtime swap only hands a Mach-O to `codesign`. | ||
| pub(crate) fn is_macho64(bytes: &[u8]) -> bool { | ||
| bytes.get(0..4) == Some(&0xfeed_facfu32.to_le_bytes()) | ||
| } | ||
| /// Set the owner/group/world execute bits on `path`, on top of whatever mode | ||
| /// it landed with. | ||
| #[cfg(unix)] | ||
| fn mark_executable(path: &Path) -> Result<(), Error> { | ||
| use std::os::unix::fs::PermissionsExt; | ||
| let mut perms = std::fs::metadata(path) | ||
| .map_err(|source| Error::Io { | ||
| context: "read the packed executable's permissions", | ||
| source, | ||
| })? | ||
| .permissions(); | ||
| perms.set_mode(perms.mode() | 0o111); | ||
| std::fs::set_permissions(path, perms).map_err(|source| Error::Io { | ||
| context: "set the packed executable's execute bit", | ||
| source, | ||
| }) | ||
| } | ||
| /// True when `path` is a packed stub — it carries a decmpfs payload (a | ||
| /// `SMOL/__DECMPFS` section on Mach-O, an EOF footer on ELF/PE). A plain or | ||
| /// already-materialized executable returns `false`. | ||
| pub fn contains_payload(path: &Path) -> bool { | ||
| section::read_self_section_bytes(path).is_some() | ||
| } | ||
| /// Runtime entry the packed stub calls from its `main`: resolve self → read the | ||
| /// payload → decompress → FS-compress the bytes to disk → atomically replace | ||
| /// `argv[0]` → re-sign (macOS) → `execve`. On success it does NOT return (the | ||
| /// process image is replaced); `Ok(false)` means "this binary is not a packed | ||
| /// stub, run your normal main". `Err` is a genuine I/O / integrity failure. | ||
| pub fn self_replace_and_exec(argv: &[String]) -> Result<bool, Error> { | ||
| replace::materialize_and_exec(argv) | ||
| } | ||
| #[cfg(test)] | ||
| #[cfg_attr(coverage_nightly, coverage(off))] | ||
| mod tests { | ||
| use super::*; | ||
| /// A scratch dir unique to one test, cleaned up by the caller via | ||
| /// [`std::fs::remove_dir_all`] once the test finishes. | ||
| fn scratch_dir(label: &str) -> std::path::PathBuf { | ||
| let dir = std::env::temp_dir().join(format!("decmpfs-pack-{label}-{}", std::process::id())); | ||
| std::fs::create_dir_all(&dir).expect("create scratch dir"); | ||
| dir | ||
| } | ||
| #[cfg(target_os = "macos")] | ||
| fn synthetic_macho_stub_with_linkedit( | ||
| first_section_offset: u32, | ||
| linkedit_fileoff: u64, | ||
| linkedit_body: &[u8], | ||
| ) -> Vec<u8> { | ||
| // Mirrors `inject`'s own test fixture: a minimal two-segment Mach-O | ||
| // (`__TEXT` anchoring the header-slack boundary, `__LINKEDIT` as the | ||
| // splice point) big enough for `inject_macho` to walk for real. | ||
| const MH_MAGIC_64: u32 = 0xfeed_facf; | ||
| const LC_SEGMENT_64: u32 = 0x19; | ||
| const MACH_HEADER_64_SIZE: usize = 32; | ||
| const SEGMENT_COMMAND_64_SIZE: usize = 72; | ||
| const NEW_LC_SIZE: usize = SEGMENT_COMMAND_64_SIZE + 80; // segment + one section_64 | ||
| let linkedit_lc_off = MACH_HEADER_64_SIZE + NEW_LC_SIZE; | ||
| let linkedit_len = linkedit_body.len() as u64; | ||
| let mut m = vec![0u8; linkedit_fileoff as usize + linkedit_body.len()]; | ||
| m[0..4].copy_from_slice(&MH_MAGIC_64.to_le_bytes()); | ||
| m[16..20].copy_from_slice(&2u32.to_le_bytes()); // ncmds: __TEXT, __LINKEDIT | ||
| m[20..24].copy_from_slice(&((NEW_LC_SIZE + SEGMENT_COMMAND_64_SIZE) as u32).to_le_bytes()); | ||
| let text = MACH_HEADER_64_SIZE; | ||
| m[text..text + 4].copy_from_slice(&LC_SEGMENT_64.to_le_bytes()); | ||
| m[text + 4..text + 8].copy_from_slice(&(NEW_LC_SIZE as u32).to_le_bytes()); | ||
| m[text + 8..text + 14].copy_from_slice(b"__TEXT"); | ||
| m[text + 64..text + 68].copy_from_slice(&1u32.to_le_bytes()); // nsects | ||
| let text_sect = text + SEGMENT_COMMAND_64_SIZE; | ||
| m[text_sect..text_sect + 6].copy_from_slice(b"__text"); | ||
| m[text_sect + 16..text_sect + 22].copy_from_slice(b"__TEXT"); | ||
| m[text_sect + 48..text_sect + 52].copy_from_slice(&first_section_offset.to_le_bytes()); | ||
| m[linkedit_lc_off..linkedit_lc_off + 4].copy_from_slice(&LC_SEGMENT_64.to_le_bytes()); | ||
| m[linkedit_lc_off + 4..linkedit_lc_off + 8] | ||
| .copy_from_slice(&(SEGMENT_COMMAND_64_SIZE as u32).to_le_bytes()); | ||
| m[linkedit_lc_off + 8..linkedit_lc_off + 18].copy_from_slice(b"__LINKEDIT"); | ||
| m[linkedit_lc_off + 24..linkedit_lc_off + 32] | ||
| .copy_from_slice(&(0x1_0000_0000u64 + linkedit_fileoff).to_le_bytes()); | ||
| m[linkedit_lc_off + 40..linkedit_lc_off + 48].copy_from_slice(&linkedit_fileoff.to_le_bytes()); | ||
| m[linkedit_lc_off + 48..linkedit_lc_off + 56].copy_from_slice(&linkedit_len.to_le_bytes()); | ||
| m[linkedit_fileoff as usize..linkedit_fileoff as usize + linkedit_body.len()] | ||
| .copy_from_slice(linkedit_body); | ||
| m | ||
| } | ||
| #[cfg(target_os = "macos")] | ||
| #[test] | ||
| fn pack_into_a_macho_stub_round_trips_through_read_self_section_bytes() { | ||
| let dir = scratch_dir("macho"); | ||
| let stub_path = dir.join("stub.bin"); | ||
| let src_path = dir.join("src.bin"); | ||
| let dest_path = dir.join("dest.bin"); | ||
| let stub = synthetic_macho_stub_with_linkedit(512, 600, b"LINKEDIT-CONTENT"); | ||
| std::fs::write(&stub_path, &stub).expect("write stub"); | ||
| let src_bytes = b"the original executable's bytes, repeated a bit to give zstd something to chew on. the original executable's bytes.".to_vec(); | ||
| std::fs::write(&src_path, &src_bytes).expect("write src"); | ||
| let outcome = pack_executable_with_stub(&stub_path, &src_path, &dest_path, &Gate::any()) | ||
| .expect("pack succeeds"); | ||
| let Some(after) = (match outcome { | ||
| PackOutcome::Packed { before, after } => { | ||
| assert_eq!(before, src_bytes.len() as u64); | ||
| Some(after) | ||
| } | ||
| PackOutcome::SkippedGate => None, | ||
| }) else { | ||
| panic!("Gate::any() must never skip"); | ||
| }; | ||
| assert_eq!( | ||
| after, | ||
| std::fs::metadata(&dest_path).expect("stat dest").len() | ||
| ); | ||
| let got = section::read_self_section_bytes(&dest_path).expect("section found"); | ||
| assert_eq!(got.content_hash, section::fnv1a64(&src_bytes)); | ||
| let decompressed = zstd::stream::decode_all(got.payload.as_slice()).expect("zstd decode"); | ||
| assert_eq!(decompressed, src_bytes); | ||
| use std::os::unix::fs::PermissionsExt; | ||
| let mode = std::fs::metadata(&dest_path) | ||
| .expect("stat dest") | ||
| .permissions() | ||
| .mode(); | ||
| assert_ne!(mode & 0o111, 0, "packed executable must be executable"); | ||
| std::fs::remove_dir_all(&dir).ok(); | ||
| } | ||
| #[test] | ||
| fn pack_into_an_elf_stub_appends_a_footer_that_round_trips() { | ||
| let dir = scratch_dir("elf"); | ||
| let stub_path = dir.join("stub.bin"); | ||
| let src_path = dir.join("src.bin"); | ||
| let dest_path = dir.join("dest.bin"); | ||
| // A minimal ELF-magic stub — `is_macho64` rejects it, so the packer routes | ||
| // it through `section::build_footer` + the append-only ELF/PE arm of | ||
| // `inject::inject_payload`, regardless of the host this test runs on. | ||
| let mut stub = vec![0u8; 64]; | ||
| stub[0..4].copy_from_slice(b"\x7fELF"); | ||
| std::fs::write(&stub_path, &stub).expect("write stub"); | ||
| let src_bytes = b"another synthetic executable payload for the ELF/PE footer path".to_vec(); | ||
| std::fs::write(&src_path, &src_bytes).expect("write src"); | ||
| let outcome = pack_executable_with_stub(&stub_path, &src_path, &dest_path, &Gate::any()) | ||
| .expect("pack succeeds"); | ||
| assert_eq!( | ||
| outcome, | ||
| PackOutcome::Packed { | ||
| before: src_bytes.len() as u64, | ||
| after: (stub.len() | ||
| + section::build_footer(0, &[]).len() | ||
| + zstd::stream::encode_all(src_bytes.as_slice(), 19) | ||
| .expect("zstd encode") | ||
| .len()) as u64, | ||
| } | ||
| ); | ||
| let dest_bytes = std::fs::read(&dest_path).expect("read dest"); | ||
| let raw_footer = section::find_footer(&dest_bytes).expect("footer found"); | ||
| // Wire format: `[zstd payload][content_hash u64 LE][payload_len u64 LE][MAGIC]`. | ||
| let payload_len = raw_footer | ||
| .len() | ||
| .checked_sub(24) | ||
| .expect("footer long enough"); | ||
| let payload = &raw_footer[0..payload_len]; | ||
| let hash_bytes: [u8; 8] = raw_footer[payload_len..payload_len + 8] | ||
| .try_into() | ||
| .expect("hash slice is 8 bytes"); | ||
| let content_hash = u64::from_le_bytes(hash_bytes); | ||
| assert_eq!(content_hash, section::fnv1a64(&src_bytes)); | ||
| let decompressed = zstd::stream::decode_all(payload).expect("zstd decode"); | ||
| assert_eq!(decompressed, src_bytes); | ||
| std::fs::remove_dir_all(&dir).ok(); | ||
| } | ||
| #[test] | ||
| fn a_gate_miss_skips_and_writes_nothing() { | ||
| let dir = scratch_dir("gate-miss"); | ||
| let stub_path = dir.join("stub.bin"); // never read: the gate rejects first. | ||
| let src_path = dir.join("src.bin"); | ||
| let dest_path = dir.join("reject.bin"); | ||
| std::fs::write(&src_path, b"some source bytes").expect("write src"); | ||
| let gate = Gate::new(Some("*.selected"), None).expect("gate parses"); | ||
| let outcome = pack_executable_with_stub(&stub_path, &src_path, &dest_path, &gate) | ||
| .expect("gate miss is not an error"); | ||
| assert_eq!(outcome, PackOutcome::SkippedGate); | ||
| assert!(!dest_path.exists(), "a gate miss must write nothing"); | ||
| std::fs::remove_dir_all(&dir).ok(); | ||
| } | ||
| #[test] | ||
| fn is_macho64_recognizes_only_the_64_bit_le_magic() { | ||
| assert!(is_macho64(&0xfeed_facfu32.to_le_bytes())); | ||
| assert!(!is_macho64(b"\x7fELF")); | ||
| assert!(!is_macho64(b"MZ")); | ||
| assert!(!is_macho64(b"sho")); | ||
| } | ||
| } |
| //! The runtime swap: read the packed stub's own payload, decompress, write the | ||
| //! real executable back to disk FS-compressed, atomically replace `argv[0]`, | ||
| //! re-sign (macOS), and `execve`. | ||
| //! | ||
| //! A running image can't be overwritten on Windows, so there the swap is | ||
| //! deferred: write the materialized binary alongside and schedule a | ||
| //! rename-on-next-start (MoveFileEx with MOVEFILE_DELAY_UNTIL_REBOOT is too | ||
| //! coarse; the runtime writes a `.pending` sibling and a tiny relauncher). | ||
| //! | ||
| //! Unix path: `std::os::unix::process::CommandExt::exec` replaces the process | ||
| //! image so control never returns on success. | ||
| #[cfg(windows)] | ||
| use std::path::Path; | ||
| use crate::Error; | ||
| #[cfg(windows)] | ||
| use crate::Gate; | ||
| use super::section::SectionData; | ||
| /// Read the current executable's own packed payload, if any. `Ok(None)` means | ||
| /// this binary is a plain executable (not a packed stub) — the caller runs its | ||
| /// normal `main`. | ||
| pub(crate) fn read_self_payload() -> Result<Option<SectionData>, Error> { | ||
| let exe = std::env::current_exe().map_err(|source| Error::Io { | ||
| context: "resolve current_exe to read the self payload", | ||
| source, | ||
| })?; | ||
| Ok(super::section::read_self_section_bytes(&exe)) | ||
| } | ||
| /// Decompress `section`'s zstd payload and verify it against `content_hash`. | ||
| /// Pure and unit-testable independently of `current_exe`/the filesystem. | ||
| fn decode_verified(section: &SectionData) -> Result<Vec<u8>, Error> { | ||
| let raw = zstd::stream::decode_all(section.payload.as_slice()).map_err(|source| Error::Io { | ||
| context: "zstd-decompress the self payload", | ||
| source, | ||
| })?; | ||
| if super::section::fnv1a64(&raw) != section.content_hash { | ||
| return Err(Error::Io { | ||
| context: "verify self payload integrity", | ||
| source: std::io::Error::other("content hash mismatch: the packed payload is corrupt"), | ||
| }); | ||
| } | ||
| Ok(raw) | ||
| } | ||
| /// Resolve `current_exe()` and the directory it lives in (the directory the | ||
| /// materialized binary must land in too, so the swap stays on one filesystem — | ||
| /// a same-filesystem rename is atomic and a same-filesystem `clonefile` is | ||
| /// available to the FS-compression writer). | ||
| fn resolve_exe_and_dir() -> Result<(std::path::PathBuf, std::path::PathBuf), Error> { | ||
| let exe = std::env::current_exe().map_err(|source| Error::Io { | ||
| context: "resolve current_exe for the runtime swap", | ||
| source, | ||
| })?; | ||
| let dir = exe | ||
| .parent() | ||
| .ok_or_else(|| Error::Io { | ||
| context: "resolve current_exe's parent directory for the runtime swap", | ||
| source: std::io::Error::other("current_exe has no parent directory"), | ||
| })? | ||
| .to_path_buf(); | ||
| Ok((exe, dir)) | ||
| } | ||
| /// Best-effort exclusive advisory lock over a stub's self-replace. Concurrent | ||
| /// first-loads of the SAME packed binary (a burst of parallel launches right | ||
| /// after install) would otherwise EACH run the expensive | ||
| /// decode+re-sign+FS-compress+rename; the lock lets one win while the rest wait, | ||
| /// then observe the completed swap and exec the real binary. Held for the | ||
| /// guard's lifetime: std's file-lock fd is `O_CLOEXEC`, so the lock releases at | ||
| /// `execve` (the re-exec'd real binary must not inherit it) and on any early | ||
| /// `return` (the guard drops → the `File` closes). A lock we can't create or | ||
| /// acquire (a read-only dir, an FS without advisory locks) yields an UNLOCKED | ||
| /// guard — the swap stays correct via the atomic rename, only the dedup is lost. | ||
| /// | ||
| /// Unix-only: Windows defers the swap to a `.decmpfs-pending` sibling and spawns | ||
| /// it (never overwrites the running image), so its launches are independent and | ||
| /// the per-write atomicity already covers concurrency — a lock there would | ||
| /// wrongly serialize concurrent launches. | ||
| #[cfg(unix)] | ||
| struct SelfReplaceLock { | ||
| // Kept alive to hold the OS lock; dropped (closed → unlocked) at scope end. | ||
| _file: Option<std::fs::File>, | ||
| } | ||
| #[cfg(unix)] | ||
| impl SelfReplaceLock { | ||
| fn acquire(dir: &std::path::Path, file_name: &str) -> Self { | ||
| let path = dir.join(format!(".{file_name}.decmpfs.lock")); | ||
| let file = std::fs::OpenOptions::new() | ||
| .create(true) | ||
| .truncate(false) | ||
| .write(true) | ||
| .open(&path) | ||
| .ok(); | ||
| if let Some(f) = file.as_ref() { | ||
| // Blocking exclusive lock; an Err leaves the guard effectively unlocked | ||
| // (we fall through — the atomic rename still guarantees correctness). | ||
| let _ = f.lock(); | ||
| } | ||
| Self { _file: file } | ||
| } | ||
| } | ||
| /// Materialize + swap + exec. Does not return on success (process image | ||
| /// replaced). `Ok(false)` means "this binary is not a packed stub, run your | ||
| /// normal main"; `Err` is a genuine I/O / integrity failure. | ||
| #[cfg(unix)] | ||
| pub(crate) fn materialize_and_exec(argv: &[String]) -> Result<bool, Error> { | ||
| use std::os::unix::process::CommandExt; | ||
| // Cheap pre-check: a plain executable (no payload) runs its normal main. | ||
| if read_self_payload()?.is_none() { | ||
| return Ok(false); | ||
| } | ||
| let (exe, dir) = resolve_exe_and_dir()?; | ||
| let file_name = exe | ||
| .file_name() | ||
| .ok_or_else(|| Error::Io { | ||
| context: "resolve current_exe's file name for the runtime swap", | ||
| source: std::io::Error::other("current_exe has no file name"), | ||
| })? | ||
| .to_string_lossy() | ||
| .into_owned(); | ||
| // Serialize concurrent first-loads of this stub (see SelfReplaceLock). Released | ||
| // at execve (O_CLOEXEC) or when this guard drops on an early return. | ||
| let _lock = SelfReplaceLock::acquire(&dir, &file_name); | ||
| // Re-read UNDER the lock: a process that went before us may have already | ||
| // swapped the on-disk exe, so its payload section is gone. If so, skip the | ||
| // whole materialize and exec the already-real binary directly. | ||
| let Some(section) = read_self_payload()? else { | ||
| let source = std::process::Command::new(&exe) | ||
| .args(argv.get(1..).unwrap_or(&[])) | ||
| .exec(); | ||
| return Err(Error::Io { | ||
| context: "exec the already-materialized binary", | ||
| source, | ||
| }); | ||
| }; | ||
| let raw = decode_verified(§ion)?; | ||
| let temp = dir.join(format!( | ||
| ".{file_name}.decmpfs-materializing-{}", | ||
| std::process::id() | ||
| )); | ||
| // Order matters: write the raw bytes, sign, THEN FS-compress. `codesign` | ||
| // rewrites the whole file (embedding the signature), which strips any decmpfs | ||
| // compression — so signing must run on the plain bytes and compress_file | ||
| // applies decmpfs last. decmpfs is transparent on read, so the signature the | ||
| // kernel hands the loader stays valid. | ||
| std::fs::write(&temp, &raw).map_err(|source| Error::Io { | ||
| context: "write the materialized binary before compressing", | ||
| source, | ||
| })?; | ||
| let mode = std::fs::metadata(&exe) | ||
| .map_err(|source| Error::Io { | ||
| context: "read current_exe's mode to preserve it on the materialized binary", | ||
| source, | ||
| })? | ||
| .permissions(); | ||
| std::fs::set_permissions(&temp, mode).map_err(|source| Error::Io { | ||
| context: "copy current_exe's mode onto the materialized binary", | ||
| source, | ||
| })?; | ||
| // Re-sign only a Mach-O payload — codesign can't sign a script or other | ||
| // non-Mach-O executable, and handing it one is a no-op at best. | ||
| #[cfg(target_os = "macos")] | ||
| if super::is_macho64(&raw) { | ||
| super::inject::resign(&temp).map_err(|message| Error::Io { | ||
| context: "re-sign the materialized binary", | ||
| source: std::io::Error::other(message), | ||
| })?; | ||
| } | ||
| // FS-compress in place, AFTER signing — the on-disk win the packer promised. | ||
| crate::compress_file(&temp)?; | ||
| std::fs::rename(&temp, &exe).map_err(|source| Error::Io { | ||
| context: "atomically rename the materialized binary over argv[0]", | ||
| source, | ||
| })?; | ||
| let source = std::process::Command::new(&exe) | ||
| .args(argv.get(1..).unwrap_or(&[])) | ||
| .exec(); | ||
| // `exec` only returns on failure — success replaces this process image. | ||
| Err(Error::Io { | ||
| context: "exec the materialized binary", | ||
| source, | ||
| }) | ||
| } | ||
| /// Windows can't overwrite its own running image, so the swap is deferred: the | ||
| /// materialized binary is written to a `.decmpfs-pending` sibling and launched | ||
| /// directly. The original stub on disk is untouched — the swap-over-original | ||
| /// completes on a later run (a follow-up can add a rename-on-reboot / relauncher | ||
| /// so the pending binary takes the original's name and the stub disappears; for | ||
| /// now every launch re-materializes and re-execs the pending copy). | ||
| #[cfg(windows)] | ||
| pub(crate) fn materialize_and_exec(argv: &[String]) -> Result<bool, Error> { | ||
| let Some(section) = read_self_payload()? else { | ||
| return Ok(false); | ||
| }; | ||
| let raw = decode_verified(§ion)?; | ||
| let (exe, _) = resolve_exe_and_dir()?; | ||
| let mut pending = exe.clone().into_os_string(); | ||
| pending.push(".decmpfs-pending"); | ||
| let pending = Path::new(&pending).to_path_buf(); | ||
| crate::compress_bytes(&pending, &raw, &Gate::any())?; | ||
| std::process::Command::new(&pending) | ||
| .args(argv.get(1..).unwrap_or(&[])) | ||
| .spawn() | ||
| .map_err(|source| Error::Io { | ||
| context: "spawn the pending materialized binary", | ||
| source, | ||
| })?; | ||
| Ok(true) | ||
| } | ||
| #[cfg(test)] | ||
| #[cfg_attr(coverage_nightly, coverage(off))] | ||
| mod tests { | ||
| use super::*; | ||
| #[test] | ||
| fn decode_verified_round_trips_a_valid_payload() { | ||
| let raw = b"hello from the materialized executable".to_vec(); | ||
| let compressed = zstd::stream::encode_all(raw.as_slice(), 3).expect("zstd encode"); | ||
| let section = SectionData { | ||
| content_hash: super::super::section::fnv1a64(&raw), | ||
| payload: compressed, | ||
| }; | ||
| let got = decode_verified(§ion).expect("decodes and verifies"); | ||
| assert_eq!(got, raw); | ||
| } | ||
| #[test] | ||
| fn decode_verified_rejects_a_corrupted_hash() { | ||
| let raw = b"hello from the materialized executable".to_vec(); | ||
| let compressed = zstd::stream::encode_all(raw.as_slice(), 3).expect("zstd encode"); | ||
| let section = SectionData { | ||
| // Flip a bit — the decompressed bytes no longer match this hash. | ||
| content_hash: super::super::section::fnv1a64(&raw) ^ 1, | ||
| payload: compressed, | ||
| }; | ||
| assert!(decode_verified(§ion).is_err()); | ||
| } | ||
| #[test] | ||
| fn decode_verified_rejects_a_non_zstd_payload() { | ||
| let section = SectionData { | ||
| content_hash: 0, | ||
| payload: b"not zstd at all".to_vec(), | ||
| }; | ||
| assert!(decode_verified(§ion).is_err()); | ||
| } | ||
| #[test] | ||
| fn read_self_payload_returns_none_for_a_plain_test_binary() { | ||
| // The cargo test binary is a plain executable, not a packed stub, so it | ||
| // carries no `SMOL/__DECMPFS` section or EOF footer. | ||
| assert!(read_self_payload().expect("reads current_exe").is_none()); | ||
| } | ||
| #[cfg(unix)] | ||
| #[test] | ||
| fn self_replace_lock_is_exclusive_and_releases_on_drop() { | ||
| let dir = std::env::temp_dir().join(format!("decmpfs-srlock-{}", std::process::id())); | ||
| std::fs::create_dir_all(&dir).unwrap(); | ||
| let name = "toolstub"; | ||
| let lock_path = dir.join(format!(".{name}.decmpfs.lock")); | ||
| { | ||
| let _guard = SelfReplaceLock::acquire(&dir, name); | ||
| // A distinct handle on the same lock file can't take it while the guard | ||
| // holds the exclusive lock (flock conflicts across open descriptions). | ||
| let other = std::fs::OpenOptions::new() | ||
| .create(true) | ||
| .truncate(false) | ||
| .write(true) | ||
| .open(&lock_path) | ||
| .unwrap(); | ||
| assert!( | ||
| other.try_lock().is_err(), | ||
| "lock is held exclusively while the guard is alive" | ||
| ); | ||
| } | ||
| // The guard dropped → the File closed → the lock is free again. | ||
| let other = std::fs::OpenOptions::new() | ||
| .create(true) | ||
| .truncate(false) | ||
| .write(true) | ||
| .open(&lock_path) | ||
| .unwrap(); | ||
| assert!( | ||
| other.try_lock().is_ok(), | ||
| "lock released when the guard drops" | ||
| ); | ||
| other.unlock().ok(); | ||
| std::fs::remove_dir_all(&dir).ok(); | ||
| } | ||
| #[cfg(unix)] | ||
| #[test] | ||
| fn self_replace_lock_falls_through_when_the_dir_is_unwritable() { | ||
| // A lock file we can't create yields an unlocked guard, no panic — the swap | ||
| // still works via the atomic rename, just without the dedup. | ||
| let guard = SelfReplaceLock::acquire(std::path::Path::new("/no/such/decmpfs/dir"), "x"); | ||
| drop(guard); | ||
| } | ||
| } |
| //! The `SMOL/__DECMPFS` section / EOF-footer wire format, shared by the packer | ||
| //! (write) and the runtime (read-self). One source of truth for the ABI. | ||
| //! | ||
| //! Section body (Mach-O, signable): `[MAGIC][content_hash u64 LE][zstd payload]`. | ||
| //! Footer (ELF/PE, appended): `[zstd payload][content_hash u64 LE][payload_len | ||
| //! u64 LE][MAGIC]` at EOF, so the runtime seeks the tail, reads `payload_len`, | ||
| //! and validates the trailing magic. | ||
| //! | ||
| //! Ported from napi-rs `crates/decmpfs/src/section.rs`; parse is hand-rolled, | ||
| //! length-guarded, no `object` crate (the crate stays dep-lean + panic=abort). | ||
| use std::path::Path; | ||
| /// Head of the payload. Distinguishes our section/footer from stray bytes and | ||
| /// fails closed on a malformed image. | ||
| pub(crate) const SECTION_MAGIC: &[u8; 8] = b"DCMPFSX1"; | ||
| /// The validated payload extracted from a packed stub. | ||
| #[allow(dead_code)] // fields read by the replace stage's consumer | ||
| pub(crate) struct SectionData { | ||
| /// FNV-1a of the RAW executable — names the materialized file + verifies decode. | ||
| pub content_hash: u64, | ||
| /// The zstd payload (the compressed raw executable). | ||
| pub payload: Vec<u8>, | ||
| } | ||
| /// Assemble the section body the packer injects (Mach-O path). | ||
| #[allow(dead_code)] // wired in the inject stage | ||
| pub(crate) fn build_section_payload(content_hash: u64, zstd_payload: &[u8]) -> Vec<u8> { | ||
| let mut out = Vec::with_capacity(16 + zstd_payload.len()); | ||
| out.extend_from_slice(SECTION_MAGIC); | ||
| out.extend_from_slice(&content_hash.to_le_bytes()); | ||
| out.extend_from_slice(zstd_payload); | ||
| out | ||
| } | ||
| /// Parse a Mach-O section body `[MAGIC][hash][payload]`. Pure, unit-testable. | ||
| #[cfg(any(target_os = "macos", test))] | ||
| pub(crate) fn parse_section_payload(raw: &[u8]) -> Option<SectionData> { | ||
| if raw.len() < 16 || &raw[0..8] != SECTION_MAGIC { | ||
| return None; | ||
| } | ||
| Some(SectionData { | ||
| content_hash: u64::from_le_bytes(raw[8..16].try_into().ok()?), | ||
| payload: raw[16..].to_vec(), | ||
| }) | ||
| } | ||
| /// Assemble the EOF footer the packer appends (ELF/PE path): the payload, then | ||
| /// its content hash, then its length (so the runtime can find the payload's | ||
| /// start by walking back from EOF), then the trailing magic. | ||
| #[allow(dead_code)] // wired in the inject stage | ||
| pub(crate) fn build_footer(content_hash: u64, zstd_payload: &[u8]) -> Vec<u8> { | ||
| let mut out = Vec::with_capacity(zstd_payload.len() + 24); | ||
| out.extend_from_slice(zstd_payload); | ||
| out.extend_from_slice(&content_hash.to_le_bytes()); | ||
| out.extend_from_slice(&(zstd_payload.len() as u64).to_le_bytes()); | ||
| out.extend_from_slice(SECTION_MAGIC); | ||
| out | ||
| } | ||
| /// Parse the footer region located by [`find_footer`]: trailing MAGIC, then | ||
| /// `payload_len`, then `content_hash`, then the payload fills everything before | ||
| /// it. Pure, unit-testable against [`build_footer`] without a real ELF/PE. | ||
| #[allow(dead_code)] // used on non-macOS hosts in production; exercised by tests everywhere | ||
| fn parse_footer_payload(raw: &[u8]) -> Option<SectionData> { | ||
| if raw.len() < 24 { | ||
| return None; | ||
| } | ||
| let magic_off = raw.len().checked_sub(8)?; | ||
| if &raw[magic_off..] != SECTION_MAGIC { | ||
| return None; | ||
| } | ||
| let len_off = magic_off.checked_sub(8)?; | ||
| let payload_len = u64::from_le_bytes(raw.get(len_off..len_off + 8)?.try_into().ok()?) as usize; | ||
| let hash_off = len_off.checked_sub(8)?; | ||
| if hash_off != payload_len { | ||
| return None; | ||
| } | ||
| Some(SectionData { | ||
| content_hash: u64::from_le_bytes(raw.get(hash_off..hash_off + 8)?.try_into().ok()?), | ||
| payload: raw.get(0..hash_off)?.to_vec(), | ||
| }) | ||
| } | ||
| /// FNV-1a (64-bit) — names the cache/materialize target without decoding. | ||
| #[allow(dead_code)] // wired in the replace stage | ||
| pub(crate) fn fnv1a64(bytes: &[u8]) -> u64 { | ||
| let mut hash: u64 = 0xcbf2_9ce4_8422_2325; | ||
| for &b in bytes { | ||
| hash ^= b as u64; | ||
| hash = hash.wrapping_mul(0x0000_0100_0000_01b3); | ||
| } | ||
| hash | ||
| } | ||
| /// Read `self_path` from disk and extract its packed payload: the Mach-O | ||
| /// `SMOL/__DECMPFS` section on macOS, the EOF footer everywhere else. `None` | ||
| /// if the file can't be read, has no such section/footer, or it's malformed — | ||
| /// the caller treats that as "not a packed stub". | ||
| #[allow(dead_code)] // wired in the replace stage | ||
| pub(crate) fn read_self_section_bytes(self_path: &Path) -> Option<SectionData> { | ||
| let bytes = std::fs::read(self_path).ok()?; | ||
| #[cfg(target_os = "macos")] | ||
| { | ||
| parse_section_payload(find_section(&bytes)?) | ||
| } | ||
| #[cfg(not(target_os = "macos"))] | ||
| { | ||
| parse_footer_payload(find_footer(&bytes)?) | ||
| } | ||
| } | ||
| /// A null-padded fixed-width name slot equals `want` (Mach-O's 16-byte | ||
| /// `sectname`/`segname`): the leading bytes match and the remainder is NUL. | ||
| #[cfg(target_os = "macos")] | ||
| fn name_eq(slot: &[u8], want: &[u8]) -> bool { | ||
| slot.len() >= want.len() | ||
| && &slot[..want.len()] == want | ||
| && slot[want.len()..].iter().all(|&b| b == 0) | ||
| } | ||
| // --------------------------------------------------------------------------- | ||
| // macOS — 64-bit little-endian Mach-O (every darwin target is x86_64/arm64, | ||
| // both LE). Walk load commands → SMOL segment → __DECMPFS section. | ||
| // --------------------------------------------------------------------------- | ||
| #[cfg(target_os = "macos")] | ||
| pub(crate) fn find_section(bytes: &[u8]) -> Option<&[u8]> { | ||
| const MH_MAGIC_64: u32 = 0xfeed_facf; | ||
| const LC_SEGMENT_64: u32 = 0x19; | ||
| if u32::from_le_bytes(bytes.get(0..4)?.try_into().ok()?) != MH_MAGIC_64 { | ||
| return None; | ||
| } | ||
| let ncmds = u32::from_le_bytes(bytes.get(16..20)?.try_into().ok()?); | ||
| // mach_header_64 is 32 bytes; load commands follow. | ||
| let mut off = 32usize; | ||
| for _ in 0..ncmds { | ||
| let cmd = u32::from_le_bytes(bytes.get(off..off + 4)?.try_into().ok()?); | ||
| let cmdsize = u32::from_le_bytes(bytes.get(off + 4..off + 8)?.try_into().ok()?) as usize; | ||
| if cmdsize < 8 { | ||
| return None; | ||
| } | ||
| if cmd == LC_SEGMENT_64 && name_eq(bytes.get(off + 8..off + 24)?, b"SMOL") { | ||
| // segment_command_64: cmd,cmdsize,segname[16],vmaddr,vmsize,fileoff, | ||
| // filesize,maxprot,initprot,nsects,flags — nsects at off+64, sections at off+72. | ||
| let nsects = u32::from_le_bytes(bytes.get(off + 64..off + 68)?.try_into().ok()?); | ||
| let mut soff = off + 72; | ||
| for _ in 0..nsects { | ||
| // section_64: sectname[16],segname[16],addr(8),size(8),offset(4),...(80 total). | ||
| if name_eq(bytes.get(soff..soff + 16)?, b"__DECMPFS") { | ||
| let size = u64::from_le_bytes(bytes.get(soff + 40..soff + 48)?.try_into().ok()?) as usize; | ||
| let offset = | ||
| u32::from_le_bytes(bytes.get(soff + 48..soff + 52)?.try_into().ok()?) as usize; | ||
| return bytes.get(offset..offset.checked_add(size)?); | ||
| } | ||
| soff = soff.checked_add(80)?; | ||
| } | ||
| } | ||
| off = off.checked_add(cmdsize)?; | ||
| } | ||
| None | ||
| } | ||
| // --------------------------------------------------------------------------- | ||
| // ELF/PE — no per-format object surgery needed: the payload rides an appended | ||
| // EOF footer `[payload][content_hash][payload_len][MAGIC]`. Read the fixed | ||
| // trailer back-to-front and slice the payload out by its declared length. | ||
| // --------------------------------------------------------------------------- | ||
| // Not target-gated: the trailer bytes carry no OS-specific structure, so the | ||
| // parser is portable. [`read_self_section_bytes`] is what restricts it to | ||
| // non-macOS hosts at the call site. | ||
| #[allow(dead_code)] // used on non-macOS hosts in production; exercised by tests everywhere | ||
| pub(crate) fn find_footer(bytes: &[u8]) -> Option<&[u8]> { | ||
| const TRAILER: usize = 24; // content_hash(8) + payload_len(8) + MAGIC(8) | ||
| if bytes.len() < TRAILER { | ||
| return None; | ||
| } | ||
| let magic_off = bytes.len().checked_sub(8)?; | ||
| if &bytes[magic_off..] != SECTION_MAGIC { | ||
| return None; | ||
| } | ||
| let len_off = magic_off.checked_sub(8)?; | ||
| let payload_len = u64::from_le_bytes(bytes.get(len_off..len_off + 8)?.try_into().ok()?) as usize; | ||
| let hash_off = len_off.checked_sub(8)?; | ||
| let payload_start = hash_off.checked_sub(payload_len)?; | ||
| bytes.get(payload_start..bytes.len()) | ||
| } | ||
| /// Build a minimal Mach-O image carrying one `SMOL/__DECMPFS` section whose | ||
| /// bytes are exactly `body`, so [`find_section`]'s load-command/section-header | ||
| /// arithmetic is validated against a known layout without a real binary. The | ||
| /// real-binary round-trip is covered by the packer-phase integration test. | ||
| #[cfg(all(test, target_os = "macos"))] | ||
| pub(crate) fn synthetic_object_with_section(body: &[u8]) -> Vec<u8> { | ||
| // header(32) + one LC_SEGMENT_64 (72 segment + 80 section = 152) = 184, then | ||
| // the section body appended at offset 184. | ||
| let mut m = vec![0u8; 184]; | ||
| m[0..4].copy_from_slice(&0xfeed_facfu32.to_le_bytes()); // MH_MAGIC_64 | ||
| m[16..20].copy_from_slice(&1u32.to_le_bytes()); // ncmds | ||
| m[32..36].copy_from_slice(&0x19u32.to_le_bytes()); // LC_SEGMENT_64 | ||
| m[36..40].copy_from_slice(&152u32.to_le_bytes()); // cmdsize | ||
| m[40..44].copy_from_slice(b"SMOL"); // segname | ||
| m[96..100].copy_from_slice(&1u32.to_le_bytes()); // nsects (off 32 + 64) | ||
| let s = 104; // sections start at off 32 + 72 | ||
| m[s..s + 9].copy_from_slice(b"__DECMPFS"); // sectname | ||
| m[s + 16..s + 20].copy_from_slice(b"SMOL"); // section's segname | ||
| m[s + 40..s + 48].copy_from_slice(&(body.len() as u64).to_le_bytes()); // size | ||
| m[s + 48..s + 52].copy_from_slice(&184u32.to_le_bytes()); // offset | ||
| m.extend_from_slice(body); | ||
| m | ||
| } | ||
| #[cfg(test)] | ||
| #[cfg_attr(coverage_nightly, coverage(off))] | ||
| mod tests { | ||
| use super::*; | ||
| #[test] | ||
| fn build_and_parse_section_payload_round_trip() { | ||
| let body = build_section_payload(0xfeed_face_dead_beef, b"the-zstd-bytes"); | ||
| let got = parse_section_payload(&body).expect("parses"); | ||
| assert_eq!(got.content_hash, 0xfeed_face_dead_beef); | ||
| assert_eq!(got.payload, b"the-zstd-bytes"); | ||
| // Empty payload is still a valid (header-only) body. | ||
| assert!(parse_section_payload(&build_section_payload(0, b"")).is_some()); | ||
| } | ||
| #[test] | ||
| fn parse_section_payload_rejects_bad_input() { | ||
| assert!(parse_section_payload(b"short").is_none()); | ||
| assert!(parse_section_payload(b"WRONGMAG\0\0\0\0\0\0\0\0").is_none()); | ||
| } | ||
| #[test] | ||
| fn build_and_find_footer_round_trip() { | ||
| let footer = build_footer(0xfeed_face_dead_beef, b"the-zstd-bytes"); | ||
| // The footer trails arbitrary leading bytes, exactly as it does at the end | ||
| // of a real ELF/PE executable. | ||
| let mut bytes = b"leading executable bytes before the footer".to_vec(); | ||
| bytes.extend_from_slice(&footer); | ||
| let raw = find_footer(&bytes).expect("footer found"); | ||
| let got = parse_footer_payload(raw).expect("parses"); | ||
| assert_eq!(got.content_hash, 0xfeed_face_dead_beef); | ||
| assert_eq!(got.payload, b"the-zstd-bytes"); | ||
| // Empty payload is still a valid (header-only) footer. | ||
| let empty = build_footer(0, b""); | ||
| assert!(parse_footer_payload(find_footer(&empty).expect("footer found")).is_some()); | ||
| } | ||
| #[test] | ||
| fn find_footer_rejects_malformed_or_short_input() { | ||
| assert!(find_footer(b"short").is_none()); | ||
| assert!(find_footer(&[0u8; 23]).is_none()); | ||
| // Trailing magic corrupted. | ||
| let mut bad_magic = build_footer(1, b"x"); | ||
| let last = bad_magic.len() - 1; | ||
| bad_magic[last] = b'!'; | ||
| assert!(find_footer(&bad_magic).is_none()); | ||
| // payload_len lies about the payload's true length. | ||
| let mut bad_len = build_footer(1, b"xyz"); | ||
| let len_off = bad_len.len() - 16; | ||
| bad_len[len_off..len_off + 8].copy_from_slice(&999u64.to_le_bytes()); | ||
| assert!(find_footer(&bad_len).is_none()); | ||
| } | ||
| #[test] | ||
| fn fnv1a64_known_vector() { | ||
| // FNV-1a 64-bit offset basis is the hash of the empty string. | ||
| assert_eq!(fnv1a64(b""), 0xcbf2_9ce4_8422_2325); | ||
| // Published FNV-1a 64-bit test vector for the single byte "a". | ||
| assert_eq!(fnv1a64(b"a"), 0xaf63_dc4c_8601_ec8c); | ||
| } | ||
| #[cfg(target_os = "macos")] | ||
| #[test] | ||
| fn find_section_reads_from_a_synthetic_macho() { | ||
| let body = build_section_payload(0x0123_4567_89ab_cdef, b"the-zstd-payload-bytes"); | ||
| let obj = synthetic_object_with_section(&body); | ||
| let raw = find_section(&obj).expect("section found"); | ||
| let got = parse_section_payload(raw).expect("parses"); | ||
| assert_eq!(got.content_hash, 0x0123_4567_89ab_cdef); | ||
| assert_eq!(got.payload, b"the-zstd-payload-bytes"); | ||
| } | ||
| #[cfg(target_os = "macos")] | ||
| #[test] | ||
| fn find_section_rejects_non_macho() { | ||
| assert!(find_section(b"not a binary at all").is_none()); | ||
| assert!(find_section(&[]).is_none()); | ||
| } | ||
| #[cfg(target_os = "macos")] | ||
| #[test] | ||
| fn read_self_section_bytes_reads_a_real_file() { | ||
| let body = build_section_payload(0x0102_0304_0506_0708, b"stub-payload"); | ||
| let obj = synthetic_object_with_section(&body); | ||
| let dir = std::env::temp_dir().join(format!("decmpfs-section-{}", std::process::id())); | ||
| std::fs::create_dir_all(&dir).expect("create temp dir"); | ||
| let path = dir.join("stub.bin"); | ||
| std::fs::write(&path, &obj).expect("write synthetic object"); | ||
| let got = read_self_section_bytes(&path).expect("section found"); | ||
| assert_eq!(got.content_hash, 0x0102_0304_0506_0708); | ||
| assert_eq!(got.payload, b"stub-payload"); | ||
| std::fs::remove_dir_all(&dir).ok(); | ||
| } | ||
| #[cfg(not(target_os = "macos"))] | ||
| #[test] | ||
| fn read_self_section_bytes_reads_a_footer_from_a_real_file() { | ||
| let mut bytes = b"leading executable bytes".to_vec(); | ||
| bytes.extend_from_slice(&build_footer(0x0102_0304_0506_0708, b"stub-payload")); | ||
| let dir = std::env::temp_dir().join(format!("decmpfs-footer-{}", std::process::id())); | ||
| std::fs::create_dir_all(&dir).expect("create temp dir"); | ||
| let path = dir.join("stub.bin"); | ||
| std::fs::write(&path, &bytes).expect("write synthetic footer file"); | ||
| let got = read_self_section_bytes(&path).expect("footer found"); | ||
| assert_eq!(got.content_hash, 0x0102_0304_0506_0708); | ||
| assert_eq!(got.payload, b"stub-payload"); | ||
| std::fs::remove_dir_all(&dir).ok(); | ||
| } | ||
| #[test] | ||
| fn read_self_section_bytes_rejects_missing_file() { | ||
| let missing = std::env::temp_dir().join("decmpfs-section-does-not-exist-at-all.bin"); | ||
| assert!(read_self_section_bytes(&missing).is_none()); | ||
| } | ||
| } |
+605
| //! `rm` — a fast recursive remove mirroring Node's `fs.rm` / `fs.rmSync`, | ||
| //! tuned for APFS/decmpfs. | ||
| //! | ||
| //! API parity (no extra knobs): options are exactly Node's `recursive`, `force`, | ||
| //! `maxRetries`, `retryDelay`. Semantics match `fs.rm`: | ||
| //! - a missing path throws unless `force`; | ||
| //! - a directory with `recursive: false` throws `EISDIR`; | ||
| //! - `recursive: true` does the `rm -rf`; `maxRetries`/`retryDelay` (linear | ||
| //! backoff on EBUSY/EMFILE/ENFILE/ENOTEMPTY/EPERM) apply ONLY when recursive, | ||
| //! as in Node. | ||
| //! | ||
| //! Safety: adapted from socket-lib `safeDelete`, MINUS its socket-owned | ||
| //! allowlist (temp / cacache / ~/.socket) — just the universal guard: removing | ||
| //! the current directory, one of its ancestors, or the filesystem root is | ||
| //! refused unless `force` is set (Node's own option, doubling as the override — | ||
| //! no extra knob). Descendants and unrelated siblings are unaffected. | ||
| //! | ||
| //! Speed: a decmpfs file unlinks like any other (its resource-fork xattr drops | ||
| //! with the inode), so DELETE has no compression angle. MEASURED on APFS (this | ||
| //! machine, ~12k files), `rm` is filesystem-metadata-bound — directory-entry | ||
| //! mutations serialize on the container lock, so BOTH a single `removefile(3)` | ||
| //! (~5% slower) and a parallel top-level fan-out (~15-20% slower) LOSE to | ||
| //! `std::fs::remove_dir_all`. So this is a correct Node-parity wrapper over | ||
| //! `remove_dir_all`, already at that floor — the DELETE win is parity, not a | ||
| //! codec trick (contrast WRITE: parallel LZVN, 6.5x). | ||
| use std::path::Path; | ||
| use crate::Error; | ||
| /// Node `fs.rm` options — same four fields, same defaults, nothing extra. | ||
| #[derive(Clone, Copy)] | ||
| pub struct RmOptions { | ||
| pub recursive: bool, | ||
| pub force: bool, | ||
| pub max_retries: u32, | ||
| pub retry_delay_ms: u64, | ||
| } | ||
| impl Default for RmOptions { | ||
| fn default() -> Self { | ||
| // Node defaults: recursive false, force false, maxRetries 0, retryDelay 100. | ||
| Self { | ||
| recursive: false, | ||
| force: false, | ||
| max_retries: 0, | ||
| retry_delay_ms: 100, | ||
| } | ||
| } | ||
| } | ||
| fn is_not_found(e: &std::io::Error) -> bool { | ||
| e.kind() == std::io::ErrorKind::NotFound | ||
| } | ||
| // The errno set Node retries in recursive mode. | ||
| #[cfg(unix)] | ||
| fn retryable(e: &std::io::Error) -> bool { | ||
| matches!( | ||
| e.raw_os_error(), | ||
| Some(c) | ||
| if c == libc::EBUSY | ||
| || c == libc::EMFILE | ||
| || c == libc::ENFILE | ||
| || c == libc::ENOTEMPTY | ||
| || c == libc::EPERM | ||
| ) | ||
| } | ||
| #[cfg(windows)] | ||
| fn retryable(e: &std::io::Error) -> bool { | ||
| // ACCESS_DENIED, SHARING_VIOLATION, LOCK_VIOLATION, DIR_NOT_EMPTY. | ||
| matches!(e.raw_os_error(), Some(5) | Some(32) | Some(33) | Some(145)) | ||
| } | ||
| /// Run one removal op, applying Node's force (swallow ENOENT) and — only when | ||
| /// recursive — the retry/backoff loop. | ||
| fn with_policy<F: FnMut() -> std::io::Result<()>>( | ||
| mut op: F, | ||
| opts: &RmOptions, | ||
| ) -> std::io::Result<()> { | ||
| let mut attempt: u32 = 0; | ||
| loop { | ||
| match op() { | ||
| Ok(()) => return Ok(()), | ||
| Err(e) if is_not_found(&e) && opts.force => return Ok(()), | ||
| Err(e) if opts.recursive && attempt < opts.max_retries && retryable(&e) => { | ||
| attempt += 1; | ||
| // Linear backoff: retryDelay ms longer each try (Node's wording). | ||
| std::thread::sleep(std::time::Duration::from_millis( | ||
| opts.retry_delay_ms.saturating_mul(u64::from(attempt)), | ||
| )); | ||
| } | ||
| Err(e) => return Err(e), | ||
| } | ||
| } | ||
| } | ||
| fn io(context: &'static str, source: std::io::Error) -> Error { | ||
| Error::Io { context, source } | ||
| } | ||
| /// What `path` is, from a no-follow `symlink_metadata`, collapsed to the three | ||
| /// cases `rm` dispatches on. Keeping the fs probe down to this small enum is what | ||
| /// lets the seam below be a trivial mock. | ||
| enum EntryKind { | ||
| Missing, | ||
| Dir, | ||
| Other, | ||
| } | ||
| /// The filesystem operations `rm` performs, behind a trait so every error arm is | ||
| /// unit-testable with a mock instead of a hard-to-provoke real fault. Prod uses | ||
| /// `OsRmFs` — thin `std::fs` / `std::env` delegations with no added logic, so the | ||
| /// logic that needs testing all lives in `rm_with` / `guard_cwd_and_root`. | ||
| #[cfg_attr(test, mockall::automock)] | ||
| trait RmFs { | ||
| fn kind(&self, path: &Path) -> std::io::Result<EntryKind>; | ||
| fn remove_file(&self, path: &Path) -> std::io::Result<()>; | ||
| fn remove_tree(&self, path: &Path) -> std::io::Result<()>; | ||
| fn cwd(&self) -> std::io::Result<std::path::PathBuf>; | ||
| fn canonicalize(&self, path: &Path) -> std::io::Result<std::path::PathBuf>; | ||
| } | ||
| struct OsRmFs; | ||
| impl RmFs for OsRmFs { | ||
| fn kind(&self, path: &Path) -> std::io::Result<EntryKind> { | ||
| match std::fs::symlink_metadata(path) { | ||
| Ok(md) if md.is_dir() => Ok(EntryKind::Dir), | ||
| Ok(_) => Ok(EntryKind::Other), | ||
| Err(e) if is_not_found(&e) => Ok(EntryKind::Missing), | ||
| Err(e) => Err(e), | ||
| } | ||
| } | ||
| fn remove_file(&self, path: &Path) -> std::io::Result<()> { | ||
| std::fs::remove_file(path) | ||
| } | ||
| /// One recursive delete of a subtree. MEASURED on APFS (this machine, 14 cores, | ||
| /// ~12k files): neither a single `removefile(3)` (~4-5% slower) nor a parallel | ||
| /// top-level fan-out (~15-20% slower) beats `std::fs::remove_dir_all` — | ||
| /// directory-entry mutations serialize on the container's metadata lock, so | ||
| /// `rm` is filesystem-bound and `remove_dir_all` (openat + unlinkat, no path | ||
| /// re-resolution) is already at that floor. Unlike WRITE (parallel LZVN = 6.5x), | ||
| /// DELETE has no userspace codec win, so the simplest correct call is the fast | ||
| /// one, on every platform. | ||
| fn remove_tree(&self, path: &Path) -> std::io::Result<()> { | ||
| std::fs::remove_dir_all(path) | ||
| } | ||
| // Real Io seam: the safe-delete guard needs the process cwd to refuse deleting | ||
| // it or its ancestors (socket-lib safeDelete model). The seam is mocked in | ||
| // tests, so this is the one production read of the cwd. A fleet cascade added | ||
| // std::env::current_dir to clippy's disallowed_methods. | ||
| #[allow(clippy::disallowed_methods)] | ||
| fn cwd(&self) -> std::io::Result<std::path::PathBuf> { | ||
| std::env::current_dir() | ||
| } | ||
| fn canonicalize(&self, path: &Path) -> std::io::Result<std::path::PathBuf> { | ||
| std::fs::canonicalize(path) | ||
| } | ||
| } | ||
| /// PURE safe-delete guard (socket-lib `safeDelete` model, minus the socket-owned | ||
| /// allowlist): is `target` the current directory, an ANCESTOR of it, or the | ||
| /// filesystem root? Deleting any of those is almost always a mistake. `cwd` is | ||
| /// injected so the policy is unit-testable without touching the process cwd. A | ||
| /// sibling or a descendant of cwd is allowed. | ||
| fn is_cwd_ancestor_or_root(target: &Path, cwd: &Path) -> bool { | ||
| // A path with no parent is a filesystem root ("/", "C:\"). | ||
| if target.parent().is_none() { | ||
| return true; | ||
| } | ||
| // `target` is cwd or an ancestor of cwd iff cwd is prefixed by target. | ||
| cwd == target || cwd.starts_with(target) | ||
| } | ||
| /// Refuse to remove the cwd, one of its ancestors, or the root — unless `force`. | ||
| /// This is the safe-delete guard adapted from socket-lib: NO socket-specific | ||
| /// allowlist (temp / cacache / ~/.socket), just the universal ancestor + root | ||
| /// protection, with Node's own `force` as the override (no extra option). | ||
| fn guard_cwd_and_root<F: RmFs>(fs: &F, path: &Path, opts: &RmOptions) -> Result<(), Error> { | ||
| if opts.force { | ||
| return Ok(()); | ||
| } | ||
| // Resolve real paths for the comparison; a missing target (canonicalize fails) | ||
| // falls back to its given path — it can't be an ancestor of cwd anyway. | ||
| let target = fs.canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); | ||
| let cwd = fs | ||
| .cwd() | ||
| .and_then(|c| fs.canonicalize(&c)) | ||
| .unwrap_or_default(); | ||
| if is_cwd_ancestor_or_root(&target, &cwd) { | ||
| return Err(io( | ||
| "refusing to remove the current directory, an ancestor, or the root — pass force to override", | ||
| std::io::Error::from(std::io::ErrorKind::PermissionDenied), | ||
| )); | ||
| } | ||
| Ok(()) | ||
| } | ||
| /// Node `fs.rm(path, options)`. A file/symlink is a single unlink. A directory | ||
| /// needs `recursive` (else `EISDIR`, as in Node); recursive delete is | ||
| /// `std::fs::remove_dir_all` — MEASURED as the floor on APFS. | ||
| pub fn rm(path: &Path, opts: &RmOptions) -> Result<(), Error> { | ||
| rm_with(&OsRmFs, path, opts) | ||
| } | ||
| /// The generic core: `rm` over an injectable filesystem so tests exercise every | ||
| /// arm with a mock. Prod calls it with `OsRmFs`. | ||
| fn rm_with<F: RmFs>(fs: &F, path: &Path, opts: &RmOptions) -> Result<(), Error> { | ||
| guard_cwd_and_root(fs, path, opts)?; | ||
| match fs.kind(path) { | ||
| Ok(EntryKind::Missing) if opts.force => Ok(()), | ||
| Ok(EntryKind::Missing) => Err(Error::NotFound(path.to_path_buf())), | ||
| Ok(EntryKind::Other) => with_policy(|| fs.remove_file(path), opts).map_err(|e| io("unlink", e)), | ||
| // Node throws EISDIR for a directory without recursive. | ||
| Ok(EntryKind::Dir) if !opts.recursive => Err(io( | ||
| "path is a directory (pass recursive)", | ||
| std::io::Error::from_raw_os_error(eisdir()), | ||
| )), | ||
| Ok(EntryKind::Dir) => { | ||
| with_policy(|| fs.remove_tree(path), opts).map_err(|e| io("remove tree", e)) | ||
| } | ||
| Err(e) => Err(io("lstat", e)), | ||
| } | ||
| } | ||
| #[cfg(unix)] | ||
| fn eisdir() -> i32 { | ||
| libc::EISDIR | ||
| } | ||
| #[cfg(windows)] | ||
| fn eisdir() -> i32 { | ||
| // ERROR_DIRECTORY — "The directory name is invalid" (closest Win32 analog). | ||
| 267 | ||
| } | ||
| #[cfg(test)] | ||
| #[cfg_attr(coverage_nightly, coverage(off))] | ||
| mod tests { | ||
| use super::*; | ||
| fn seed_tree(root: &Path, dirs: usize, per: usize) { | ||
| std::fs::create_dir_all(root).unwrap(); | ||
| for d in 0..dirs { | ||
| let sub = root.join(format!("pkg-{d}")); | ||
| std::fs::create_dir_all(sub.join("nested")).unwrap(); | ||
| for f in 0..per { | ||
| std::fs::write(sub.join(format!("f{f}.js")), b"module.exports=1\n").unwrap(); | ||
| std::fs::write(sub.join("nested").join(format!("g{f}.js")), b"x\n").unwrap(); | ||
| } | ||
| } | ||
| } | ||
| #[test] | ||
| fn matches_node_rm_semantics() { | ||
| let root = std::env::temp_dir().join(format!("decmpfs-rm-{}", std::process::id())); | ||
| let _ = std::fs::remove_dir_all(&root); | ||
| seed_tree(&root, 4, 3); | ||
| // recursive:false on a directory throws (EISDIR parity). | ||
| assert!(rm(&root, &RmOptions::default()).is_err()); | ||
| // a symlink is unlinked, not followed. | ||
| let keep = std::env::temp_dir().join(format!("decmpfs-rm-keep-{}", std::process::id())); | ||
| std::fs::write(&keep, b"keep").unwrap(); | ||
| #[cfg(unix)] | ||
| std::os::unix::fs::symlink(&keep, root.join("link")).unwrap(); | ||
| let rf = RmOptions { | ||
| recursive: true, | ||
| force: true, | ||
| ..RmOptions::default() | ||
| }; | ||
| rm(&root, &rf).unwrap(); | ||
| assert!(!root.exists(), "tree cleared"); | ||
| assert!(keep.exists(), "symlink target must survive"); | ||
| // force: a missing path is Ok; without force it's NotFound. | ||
| rm(&root, &rf).unwrap(); | ||
| assert!(matches!( | ||
| rm(&root, &RmOptions::default()), | ||
| Err(Error::NotFound(_)) | ||
| )); | ||
| // a single file. | ||
| let f = std::env::temp_dir().join(format!("decmpfs-rm-one-{}", std::process::id())); | ||
| std::fs::write(&f, b"x").unwrap(); | ||
| rm(&f, &RmOptions::default()).unwrap(); | ||
| assert!(!f.exists()); | ||
| let _ = std::fs::remove_file(&keep); | ||
| } | ||
| #[test] | ||
| fn safe_guard_blocks_cwd_ancestors_and_root() { | ||
| use std::path::Path; | ||
| let cwd = Path::new("/a/b/c"); | ||
| // cwd itself, an ancestor, and the root are refused. | ||
| assert!(is_cwd_ancestor_or_root(Path::new("/a/b/c"), cwd), "cwd"); | ||
| assert!(is_cwd_ancestor_or_root(Path::new("/a/b"), cwd), "ancestor"); | ||
| assert!(is_cwd_ancestor_or_root(Path::new("/a"), cwd), "ancestor"); | ||
| assert!(is_cwd_ancestor_or_root(Path::new("/"), cwd), "root"); | ||
| // a descendant of cwd and an unrelated sibling are allowed. | ||
| assert!( | ||
| !is_cwd_ancestor_or_root(Path::new("/a/b/c/build"), cwd), | ||
| "descendant allowed" | ||
| ); | ||
| assert!( | ||
| !is_cwd_ancestor_or_root(Path::new("/a/b/other"), cwd), | ||
| "sibling allowed" | ||
| ); | ||
| } | ||
| // Retry/backoff + force policy — the fs op is injected as a closure, so these | ||
| // branches are covered WITHOUT touching disk (DI is the mock). | ||
| #[cfg(unix)] | ||
| #[test] | ||
| fn with_policy_covers_retry_force_and_non_retryable() { | ||
| use std::io::Error as IoErr; | ||
| // retryDelay 0 keeps the test instant. | ||
| let recursive = RmOptions { | ||
| recursive: true, | ||
| force: false, | ||
| max_retries: 3, | ||
| retry_delay_ms: 0, | ||
| }; | ||
| // A retryable errno (EBUSY) under recursive: retried until it succeeds. | ||
| let mut tries = 0; | ||
| let ok = with_policy( | ||
| || { | ||
| tries += 1; | ||
| if tries < 3 { | ||
| Err(IoErr::from_raw_os_error(libc::EBUSY)) | ||
| } else { | ||
| Ok(()) | ||
| } | ||
| }, | ||
| &recursive, | ||
| ); | ||
| assert!(ok.is_ok()); | ||
| assert_eq!(tries, 3, "retried twice then succeeded"); | ||
| // Same error, NOT recursive: Node ignores retries → fails on the first try. | ||
| let mut n = 0; | ||
| let non_recursive = RmOptions { | ||
| recursive: false, | ||
| ..recursive | ||
| }; | ||
| let err = with_policy( | ||
| || { | ||
| n += 1; | ||
| Err::<(), _>(IoErr::from_raw_os_error(libc::EBUSY)) | ||
| }, | ||
| &non_recursive, | ||
| ); | ||
| assert!(err.is_err()); | ||
| assert_eq!(n, 1, "no retry when not recursive"); | ||
| // force swallows a missing path. | ||
| let forced = with_policy( | ||
| || Err(IoErr::from(std::io::ErrorKind::NotFound)), | ||
| &RmOptions { | ||
| force: true, | ||
| ..RmOptions::default() | ||
| }, | ||
| ); | ||
| assert!(forced.is_ok()); | ||
| // A non-retryable errno surfaces immediately even under recursive. | ||
| let mut k = 0; | ||
| let hard = with_policy( | ||
| || { | ||
| k += 1; | ||
| Err::<(), _>(IoErr::from_raw_os_error(libc::EACCES)) | ||
| }, | ||
| &recursive, | ||
| ); | ||
| assert!(hard.is_err()); | ||
| assert_eq!(k, 1, "non-retryable is not retried"); | ||
| // retryable() classifies the errno set. | ||
| assert!(retryable(&IoErr::from_raw_os_error(libc::ENOTEMPTY))); | ||
| assert!(!retryable(&IoErr::from_raw_os_error(libc::EACCES))); | ||
| } | ||
| #[test] | ||
| // The test must read the real cwd to prove the guard refuses deleting it. | ||
| #[allow(clippy::disallowed_methods)] | ||
| fn rm_refuses_cwd_without_force_but_force_overrides_the_guard() { | ||
| // Removing the real cwd is blocked by the guard (this does NOT delete it). | ||
| let cwd = std::env::current_dir().unwrap(); | ||
| assert!( | ||
| rm(&cwd, &RmOptions::default()).is_err(), | ||
| "guard must refuse removing the cwd" | ||
| ); | ||
| // force bypasses the guard — proven WITHOUT touching cwd: a fresh temp file | ||
| // (not an ancestor) removes fine, and force is the documented override. | ||
| let f = std::env::temp_dir().join(format!("decmpfs-guard-{}", std::process::id())); | ||
| std::fs::write(&f, b"x").unwrap(); | ||
| let forced = RmOptions { | ||
| force: true, | ||
| ..RmOptions::default() | ||
| }; | ||
| rm(&f, &forced).unwrap(); | ||
| assert!(!f.exists()); | ||
| } | ||
| // A path far from cwd, so the guard lets rm_with reach its dispatch. | ||
| fn mock_target() -> std::path::PathBuf { | ||
| std::path::PathBuf::from("/mock-target/x") | ||
| } | ||
| // Wire a MockRmFs so guard_cwd_and_root passes: canonicalize is identity and | ||
| // cwd is an unrelated dir, so `mock_target` is neither cwd nor an ancestor. | ||
| fn mock_guard_passes(m: &mut MockRmFs) { | ||
| m.expect_canonicalize().returning(|p| Ok(p.to_path_buf())); | ||
| m.expect_cwd() | ||
| .returning(|| Ok(std::path::PathBuf::from("/mock-cwd"))); | ||
| } | ||
| fn eacces() -> std::io::Error { | ||
| std::io::Error::from(std::io::ErrorKind::PermissionDenied) | ||
| } | ||
| // rm_with over a mocked filesystem — each dispatch + error arm without disk. | ||
| #[test] | ||
| fn rm_with_surfaces_a_non_not_found_kind_error() { | ||
| let mut m = MockRmFs::new(); | ||
| mock_guard_passes(&mut m); | ||
| m.expect_kind().returning(|_| Err(eacces())); | ||
| assert!(matches!( | ||
| rm_with(&m, &mock_target(), &RmOptions::default()), | ||
| Err(Error::Io { | ||
| context: "lstat", | ||
| .. | ||
| }) | ||
| )); | ||
| } | ||
| #[test] | ||
| fn rm_with_missing_is_force_ok_else_not_found() { | ||
| let mut ok = MockRmFs::new(); | ||
| ok.expect_kind().returning(|_| Ok(EntryKind::Missing)); | ||
| let forced = RmOptions { | ||
| force: true, | ||
| ..RmOptions::default() | ||
| }; | ||
| // force short-circuits the guard AND swallows the missing path. | ||
| assert!(rm_with(&ok, &mock_target(), &forced).is_ok()); | ||
| let mut miss = MockRmFs::new(); | ||
| mock_guard_passes(&mut miss); | ||
| miss.expect_kind().returning(|_| Ok(EntryKind::Missing)); | ||
| assert!(matches!( | ||
| rm_with(&miss, &mock_target(), &RmOptions::default()), | ||
| Err(Error::NotFound(_)) | ||
| )); | ||
| } | ||
| #[test] | ||
| fn rm_with_dir_needs_recursive_then_removes_tree() { | ||
| let mut eisdir = MockRmFs::new(); | ||
| mock_guard_passes(&mut eisdir); | ||
| eisdir.expect_kind().returning(|_| Ok(EntryKind::Dir)); | ||
| assert!(matches!( | ||
| rm_with(&eisdir, &mock_target(), &RmOptions::default()), | ||
| Err(Error::Io { context, .. }) if context.contains("directory") | ||
| )); | ||
| let mut tree_err = MockRmFs::new(); | ||
| mock_guard_passes(&mut tree_err); | ||
| tree_err.expect_kind().returning(|_| Ok(EntryKind::Dir)); | ||
| tree_err.expect_remove_tree().returning(|_| Err(eacces())); | ||
| let rf = RmOptions { | ||
| recursive: true, | ||
| ..RmOptions::default() | ||
| }; | ||
| assert!(matches!( | ||
| rm_with(&tree_err, &mock_target(), &rf), | ||
| Err(Error::Io { | ||
| context: "remove tree", | ||
| .. | ||
| }) | ||
| )); | ||
| let mut tree_ok = MockRmFs::new(); | ||
| mock_guard_passes(&mut tree_ok); | ||
| tree_ok.expect_kind().returning(|_| Ok(EntryKind::Dir)); | ||
| tree_ok.expect_remove_tree().returning(|_| Ok(())); | ||
| assert!(rm_with(&tree_ok, &mock_target(), &rf).is_ok()); | ||
| } | ||
| #[test] | ||
| fn rm_with_file_unlink_ok_and_error() { | ||
| let mut ok = MockRmFs::new(); | ||
| mock_guard_passes(&mut ok); | ||
| ok.expect_kind().returning(|_| Ok(EntryKind::Other)); | ||
| ok.expect_remove_file().returning(|_| Ok(())); | ||
| assert!(rm_with(&ok, &mock_target(), &RmOptions::default()).is_ok()); | ||
| let mut err = MockRmFs::new(); | ||
| mock_guard_passes(&mut err); | ||
| err.expect_kind().returning(|_| Ok(EntryKind::Other)); | ||
| err.expect_remove_file().returning(|_| Err(eacces())); | ||
| assert!(matches!( | ||
| rm_with(&err, &mock_target(), &RmOptions::default()), | ||
| Err(Error::Io { | ||
| context: "unlink", | ||
| .. | ||
| }) | ||
| )); | ||
| } | ||
| #[test] | ||
| fn guard_falls_back_when_canonicalize_and_cwd_fail() { | ||
| let mut m = MockRmFs::new(); | ||
| // Both the target canonicalize and cwd() fail → the guard uses the raw path | ||
| // and an empty cwd (the unwrap_or_else / unwrap_or_default fallbacks). A | ||
| // non-ancestor path still proceeds to the dispatch. | ||
| m.expect_canonicalize().returning(|_| Err(eacces())); | ||
| m.expect_cwd().returning(|| Err(eacces())); | ||
| m.expect_kind().returning(|_| Ok(EntryKind::Missing)); | ||
| assert!(matches!( | ||
| rm_with(&m, &mock_target(), &RmOptions::default()), | ||
| Err(Error::NotFound(_)) | ||
| )); | ||
| } | ||
| #[test] | ||
| fn guard_blocks_when_target_is_an_ancestor_of_cwd() { | ||
| let mut m = MockRmFs::new(); | ||
| // target "/" is the root → an ancestor of any cwd → refused (no force). | ||
| m.expect_canonicalize().returning(|p| Ok(p.to_path_buf())); | ||
| m.expect_cwd() | ||
| .returning(|| Ok(std::path::PathBuf::from("/mock-cwd"))); | ||
| assert!(matches!( | ||
| rm_with(&m, std::path::Path::new("/"), &RmOptions::default()), | ||
| Err(Error::Io { context, .. }) if context.contains("refusing") | ||
| )); | ||
| } | ||
| #[test] | ||
| fn os_rmfs_kind_surfaces_a_non_not_found_error() { | ||
| // A path whose parent is a FILE makes symlink_metadata fail non-NotFound | ||
| // (ENOTDIR), exercising OsRmFs::kind's real error passthrough. | ||
| let dir = tempfile::tempdir().unwrap(); | ||
| let file = dir.path().join("f"); | ||
| std::fs::write(&file, b"x").unwrap(); | ||
| let bogus = file.join("child"); | ||
| assert!(rm(&bogus, &RmOptions::default()).is_err()); | ||
| } | ||
| #[test] | ||
| fn os_rmfs_removes_a_real_tree_and_file() { | ||
| // Covers OsRmFs's happy delegations (kind Dir/Other, remove_tree/remove_file) | ||
| // against a real temp fs with auto-cleanup. | ||
| let dir = tempfile::tempdir().unwrap(); | ||
| let sub = dir.path().join("sub"); | ||
| std::fs::create_dir_all(sub.join("deep")).unwrap(); | ||
| std::fs::write(sub.join("a.txt"), b"a").unwrap(); | ||
| let rf = RmOptions { | ||
| recursive: true, | ||
| ..RmOptions::default() | ||
| }; | ||
| rm(&sub, &rf).unwrap(); | ||
| assert!(!sub.exists()); | ||
| let f = dir.path().join("solo"); | ||
| std::fs::write(&f, b"x").unwrap(); | ||
| rm(&f, &RmOptions::default()).unwrap(); | ||
| assert!(!f.exists()); | ||
| } | ||
| // Opt-in perf probe: parallel rm vs std::fs::remove_dir_all on a big tree. | ||
| // cargo test -p decmpfs rmrf_probe -- --ignored --nocapture | ||
| #[test] | ||
| #[ignore] | ||
| fn rmrf_probe() { | ||
| let base = std::env::temp_dir().join(format!("decmpfs-rmrf-{}", std::process::id())); | ||
| let a = base.join("parallel"); | ||
| let b = base.join("std"); | ||
| for d in [&a, &b] { | ||
| seed_tree(d, 300, 20); | ||
| } | ||
| let cores = std::thread::available_parallelism() | ||
| .map(|n| n.get()) | ||
| .unwrap_or(1); | ||
| let rf = RmOptions { | ||
| recursive: true, | ||
| force: true, | ||
| ..RmOptions::default() | ||
| }; | ||
| let t0 = std::time::Instant::now(); | ||
| rm(&a, &rf).unwrap(); | ||
| let par = t0.elapsed().as_secs_f64() * 1e3; | ||
| let t1 = std::time::Instant::now(); | ||
| std::fs::remove_dir_all(&b).unwrap(); | ||
| let base_ms = t1.elapsed().as_secs_f64() * 1e3; | ||
| eprintln!( | ||
| "rmrf ~12k files — decmpfs::rm ({cores} cores avail): {par:.1} ms | std::fs::remove_dir_all: {base_ms:.1} ms" | ||
| ); | ||
| let _ = std::fs::remove_dir_all(&base); | ||
| } | ||
| } |
+317
| //! Incremental, atomic writes into the platform's transparent compression. | ||
| use std::io::Write; | ||
| use std::path::{Path, PathBuf}; | ||
| use crate::{backend, verify, Error, Gate, Outcome, SkipReason, Support, UnsupportedReason}; | ||
| enum PlainOutcome { | ||
| Skipped(SkipReason), | ||
| Unsupported(UnsupportedReason), | ||
| } | ||
| enum StreamInner { | ||
| #[cfg(target_os = "macos")] | ||
| Macos(backend::StreamingWriter), | ||
| #[cfg(target_os = "macos")] | ||
| MacosBuffered(Vec<u8>), | ||
| File(std::fs::File), | ||
| Closed, | ||
| } | ||
| /// An atomic incremental writer for OS-transparent filesystem compression. | ||
| /// | ||
| /// The expected logical length is required before the first byte. macOS uses it | ||
| /// to reserve the decmpfs block-offset table; btrfs and NTFS use it to enforce | ||
| /// complete publication. Dropping or aborting the writer removes its sibling | ||
| /// temp, leaving any existing destination untouched. | ||
| pub struct DecmpfsWriter { | ||
| target: PathBuf, | ||
| temp: PathBuf, | ||
| expected_len: u64, | ||
| written: u64, | ||
| inner: StreamInner, | ||
| plain_outcome: Option<PlainOutcome>, | ||
| finished: bool, | ||
| } | ||
| fn stream_error(context: &'static str, kind: std::io::ErrorKind) -> Error { | ||
| Error::Io { | ||
| context, | ||
| source: std::io::Error::from(kind), | ||
| } | ||
| } | ||
| fn unique_temp(path: &Path) -> Result<PathBuf, Error> { | ||
| static STREAM_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); | ||
| let dir = path.parent().ok_or_else(|| { | ||
| stream_error( | ||
| "stream target has no parent", | ||
| std::io::ErrorKind::InvalidInput, | ||
| ) | ||
| })?; | ||
| let name = path.file_name().map_or_else( | ||
| || std::borrow::Cow::Borrowed("stream"), | ||
| |n| n.to_string_lossy(), | ||
| ); | ||
| let seq = STREAM_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); | ||
| let nanos = std::time::SystemTime::now() | ||
| .duration_since(std::time::UNIX_EPOCH) | ||
| .map(|duration| duration.as_nanos()) | ||
| .unwrap_or(0); | ||
| Ok(dir.join(format!( | ||
| ".{name}.decmpfs-stream-{}-{nanos}-{seq}.tmp", | ||
| std::process::id() | ||
| ))) | ||
| } | ||
| fn create_plain(temp: &Path) -> Result<std::fs::File, Error> { | ||
| std::fs::OpenOptions::new() | ||
| .read(true) | ||
| .write(true) | ||
| .create_new(true) | ||
| .open(temp) | ||
| .map_err(|source| Error::Io { | ||
| context: "create streaming temp", | ||
| source, | ||
| }) | ||
| } | ||
| fn publish(temp: &Path, target: &Path) -> Result<(), Error> { | ||
| #[cfg(target_os = "windows")] | ||
| { | ||
| backend::publish_stream(temp, target) | ||
| } | ||
| #[cfg(not(target_os = "windows"))] | ||
| { | ||
| std::fs::rename(temp, target).map_err(|source| Error::Io { | ||
| context: "publish streaming temp", | ||
| source, | ||
| }) | ||
| } | ||
| } | ||
| impl DecmpfsWriter { | ||
| /// Open an incremental writer. The destination is published only by | ||
| /// [`finish`](Self::finish) after exactly `expected_len` bytes arrive. | ||
| pub fn create(path: &Path, expected_len: u64, gate: &Gate) -> Result<Self, Error> { | ||
| let normalized = path.to_string_lossy().replace('\\', "/"); | ||
| let temp = unique_temp(path)?; | ||
| if !gate.matches(&normalized, expected_len) { | ||
| return Ok(Self { | ||
| target: path.to_path_buf(), | ||
| temp: temp.clone(), | ||
| expected_len, | ||
| written: 0, | ||
| inner: StreamInner::File(create_plain(&temp)?), | ||
| plain_outcome: Some(PlainOutcome::Skipped(SkipReason::GateExcluded)), | ||
| finished: false, | ||
| }); | ||
| } | ||
| let probe = path.parent().unwrap_or(path); | ||
| let support = | ||
| backend::detect(probe).unwrap_or(Support::Unsupported(UnsupportedReason::Filesystem)); | ||
| match support { | ||
| Support::Supported => { | ||
| #[cfg(target_os = "macos")] | ||
| let inner = match usize::try_from(expected_len) { | ||
| Ok(len) if len <= backend::STREAMING_THRESHOLD => { | ||
| StreamInner::MacosBuffered(Vec::with_capacity(len)) | ||
| } | ||
| Ok(len) => StreamInner::Macos(backend::StreamingWriter::new(&temp, len)?), | ||
| Err(_) => StreamInner::File(create_plain(&temp)?), | ||
| }; | ||
| #[cfg(any(target_os = "linux", target_os = "windows"))] | ||
| let (inner, plain_outcome) = { | ||
| let file = create_plain(&temp)?; | ||
| match backend::prepare_stream(&file) { | ||
| Ok(()) => (StreamInner::File(file), None), | ||
| Err(_) => { | ||
| drop(file); | ||
| let _ = std::fs::remove_file(&temp); | ||
| ( | ||
| StreamInner::File(create_plain(&temp)?), | ||
| Some(PlainOutcome::Skipped(SkipReason::IntegrityRevert)), | ||
| ) | ||
| } | ||
| } | ||
| }; | ||
| #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] | ||
| let (inner, plain_outcome) = ( | ||
| StreamInner::File(create_plain(&temp)?), | ||
| Some(PlainOutcome::Unsupported(UnsupportedReason::PlatformBuild)), | ||
| ); | ||
| #[cfg(target_os = "macos")] | ||
| let plain_outcome = if usize::try_from(expected_len).is_ok() { | ||
| None | ||
| } else { | ||
| Some(PlainOutcome::Skipped(SkipReason::TooLarge)) | ||
| }; | ||
| Ok(Self { | ||
| target: path.to_path_buf(), | ||
| temp, | ||
| expected_len, | ||
| written: 0, | ||
| inner, | ||
| plain_outcome, | ||
| finished: false, | ||
| }) | ||
| } | ||
| Support::AlreadyCompressed => Ok(Self { | ||
| target: path.to_path_buf(), | ||
| temp: temp.clone(), | ||
| expected_len, | ||
| written: 0, | ||
| inner: StreamInner::File(create_plain(&temp)?), | ||
| plain_outcome: Some(PlainOutcome::Unsupported(UnsupportedReason::Filesystem)), | ||
| finished: false, | ||
| }), | ||
| Support::Unsupported(reason) => Ok(Self { | ||
| target: path.to_path_buf(), | ||
| temp: temp.clone(), | ||
| expected_len, | ||
| written: 0, | ||
| inner: StreamInner::File(create_plain(&temp)?), | ||
| plain_outcome: Some(PlainOutcome::Unsupported(reason)), | ||
| finished: false, | ||
| }), | ||
| } | ||
| } | ||
| /// Consume one raw chunk without retaining it after the current filesystem | ||
| /// compression block has been completed. | ||
| pub fn write_chunk(&mut self, buf: &[u8]) -> Result<(), Error> { | ||
| let next = self | ||
| .written | ||
| .checked_add(buf.len() as u64) | ||
| .filter(|&len| len <= self.expected_len) | ||
| .ok_or_else(|| { | ||
| stream_error( | ||
| "stream exceeds expected length", | ||
| std::io::ErrorKind::InvalidData, | ||
| ) | ||
| })?; | ||
| match &mut self.inner { | ||
| #[cfg(target_os = "macos")] | ||
| StreamInner::Macos(writer) => writer.write_all(buf)?, | ||
| #[cfg(target_os = "macos")] | ||
| StreamInner::MacosBuffered(buffer) => buffer.extend_from_slice(buf), | ||
| StreamInner::File(file) => file.write_all(buf).map_err(|source| Error::Io { | ||
| context: "write streaming temp", | ||
| source, | ||
| })?, | ||
| StreamInner::Closed => { | ||
| return Err(stream_error( | ||
| "write closed stream", | ||
| std::io::ErrorKind::BrokenPipe, | ||
| )); | ||
| } | ||
| } | ||
| self.written = next; | ||
| Ok(()) | ||
| } | ||
| /// Remove the incomplete sibling temp and leave the destination untouched. | ||
| pub fn abort(mut self) -> Result<(), Error> { | ||
| self.inner = StreamInner::Closed; | ||
| self.finished = true; | ||
| match std::fs::remove_file(&self.temp) { | ||
| Ok(()) => Ok(()), | ||
| Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(()), | ||
| Err(source) => Err(Error::Io { | ||
| context: "abort streaming temp", | ||
| source, | ||
| }), | ||
| } | ||
| } | ||
| /// Flush, verify the received length, and atomically publish the destination. | ||
| pub fn finish(mut self) -> Result<Outcome, Error> { | ||
| if self.written != self.expected_len { | ||
| return Err(stream_error( | ||
| "finish incomplete stream", | ||
| std::io::ErrorKind::UnexpectedEof, | ||
| )); | ||
| } | ||
| #[cfg(target_os = "macos")] | ||
| let macos_compressed = match &mut self.inner { | ||
| StreamInner::Macos(writer) => Some(writer.finish()?), | ||
| StreamInner::MacosBuffered(buffer) => { | ||
| backend::apply_bytes(&self.temp, buffer, None)?; | ||
| Some(backend::is_already_compressed(&self.temp).unwrap_or(false)) | ||
| } | ||
| _ => None, | ||
| }; | ||
| #[cfg(not(target_os = "macos"))] | ||
| let macos_compressed: Option<bool> = None; | ||
| if let StreamInner::File(file) = &mut self.inner { | ||
| file | ||
| .flush() | ||
| .and_then(|()| file.sync_all()) | ||
| .map_err(|source| Error::Io { | ||
| context: "finish streaming temp", | ||
| source, | ||
| })?; | ||
| } | ||
| self.inner = StreamInner::Closed; | ||
| publish(&self.temp, &self.target)?; | ||
| self.finished = true; | ||
| if let Some(plain) = self.plain_outcome.take() { | ||
| return Ok(match plain { | ||
| PlainOutcome::Skipped(reason) => Outcome::Skipped { reason }, | ||
| PlainOutcome::Unsupported(reason) => Outcome::Unsupported { reason }, | ||
| }); | ||
| } | ||
| if macos_compressed == Some(false) { | ||
| return Ok(Outcome::NoGain { | ||
| before: self.expected_len, | ||
| after: verify::on_disk_bytes(&self.target)?, | ||
| }); | ||
| } | ||
| let after = verify::on_disk_bytes(&self.target)?; | ||
| let signal = backend::compressed_on_disk(&self.target).ok().flatten(); | ||
| if signal.unwrap_or(after < self.expected_len) { | ||
| Ok(Outcome::Compressed { | ||
| before: self.expected_len, | ||
| after, | ||
| }) | ||
| } else { | ||
| Ok(Outcome::NoGain { | ||
| before: self.expected_len, | ||
| after, | ||
| }) | ||
| } | ||
| } | ||
| } | ||
| impl Write for DecmpfsWriter { | ||
| fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> { | ||
| self.write_chunk(buf).map_err(std::io::Error::other)?; | ||
| Ok(buf.len()) | ||
| } | ||
| fn flush(&mut self) -> std::io::Result<()> { | ||
| match &mut self.inner { | ||
| #[cfg(target_os = "macos")] | ||
| StreamInner::Macos(_) => Ok(()), | ||
| #[cfg(target_os = "macos")] | ||
| StreamInner::MacosBuffered(_) => Ok(()), | ||
| StreamInner::File(file) => file.flush(), | ||
| StreamInner::Closed => Err(std::io::Error::from(std::io::ErrorKind::BrokenPipe)), | ||
| } | ||
| } | ||
| } | ||
| impl Drop for DecmpfsWriter { | ||
| fn drop(&mut self) { | ||
| if !self.finished { | ||
| self.inner = StreamInner::Closed; | ||
| let _ = std::fs::remove_file(&self.temp); | ||
| } | ||
| } | ||
| } |
| //! End-to-end proof of the self-replacing runtime (feature `exe`, unix): | ||
| //! pack a real payload into the `decmpfs-stub` binary, RUN the packed stub, and | ||
| //! assert it (a) produced the payload's own output, (b) left the on-disk file | ||
| //! FS-compressed when the filesystem supports it, and (c) on a second run takes | ||
| //! the plain-exec path (the stub is gone — the file IS the payload now). | ||
| //! | ||
| //! Cargo sets `CARGO_BIN_EXE_decmpfs-stub` for integration tests to the built | ||
| //! stub path; the `exe` feature is what builds that bin, so this file is empty | ||
| //! without it. | ||
| #![cfg(all(unix, feature = "exe"))] | ||
| use std::os::unix::fs::PermissionsExt; | ||
| use std::path::Path; | ||
| use std::process::Command; | ||
| fn on_disk_bytes(path: &Path) -> u64 { | ||
| use std::os::unix::fs::MetadataExt; | ||
| std::fs::metadata(path) | ||
| .expect("stat") | ||
| .blocks() | ||
| .saturating_mul(512) | ||
| } | ||
| #[test] | ||
| fn packed_stub_materializes_then_execs_payload_and_compresses_when_supported() { | ||
| let stub = env!("CARGO_BIN_EXE_decmpfs-stub"); | ||
| let dir = std::env::temp_dir().join(format!("decmpfs-e2e-{}", std::process::id())); | ||
| std::fs::create_dir_all(&dir).expect("scratch dir"); | ||
| // The payload is a shell script that echoes a marker + its args, padded with | ||
| // a big, highly-compressible comment — large enough (~1.6 MB) that the | ||
| // on-disk win is unambiguous against APFS's block rounding. | ||
| let payload = dir.join("payload.sh"); | ||
| let filler = "# ".to_string() + &"decmpfs ".repeat(200_000); | ||
| std::fs::write( | ||
| &payload, | ||
| format!("#!/bin/sh\n{filler}\necho \"MATERIALIZED $*\"\n"), | ||
| ) | ||
| .expect("write payload"); | ||
| std::fs::set_permissions(&payload, std::fs::Permissions::from_mode(0o755)) | ||
| .expect("chmod payload"); | ||
| // Pack it into a copy of the stub. | ||
| let packed = dir.join("packed"); | ||
| let outcome = decmpfs::exe::pack_executable_with_stub( | ||
| Path::new(stub), | ||
| &payload, | ||
| &packed, | ||
| &decmpfs::Gate::any(), | ||
| ) | ||
| .expect("pack succeeds"); | ||
| assert!( | ||
| matches!(outcome, decmpfs::exe::PackOutcome::Packed { .. }), | ||
| "Gate::any() must pack, got {outcome:?}", | ||
| ); | ||
| let compression_supported = matches!( | ||
| decmpfs::probe(&packed), | ||
| Ok(decmpfs::Support::Supported | decmpfs::Support::AlreadyCompressed) | ||
| ); | ||
| // First run: the stub materializes the payload over itself and execs it. | ||
| let first = Command::new(&packed) | ||
| .arg("hello") | ||
| .output() | ||
| .expect("run packed stub"); | ||
| assert!( | ||
| first.status.success(), | ||
| "first run failed: {}", | ||
| String::from_utf8_lossy(&first.stderr), | ||
| ); | ||
| assert_eq!( | ||
| String::from_utf8_lossy(&first.stdout).trim(), | ||
| "MATERIALIZED hello", | ||
| "first run must exec the materialized payload", | ||
| ); | ||
| // The file on disk is now the payload. APFS and btrfs should compress it; | ||
| // unsupported filesystems such as a stock Linux CI runner's ext4 still | ||
| // exercise the materialize-and-exec fallback and intentionally land plain. | ||
| let logical = std::fs::metadata(&packed).expect("stat").len(); | ||
| let physical = on_disk_bytes(&packed); | ||
| if compression_supported { | ||
| assert!( | ||
| physical < logical, | ||
| "materialized file should be FS-compressed: {physical} on disk vs {logical} logical", | ||
| ); | ||
| } | ||
| // Second run: the file IS the payload now (no packed section), so the stub | ||
| // runtime is never re-entered — it just runs as the plain executable. | ||
| let second = Command::new(&packed) | ||
| .arg("again") | ||
| .output() | ||
| .expect("run materialized file"); | ||
| assert!(second.status.success()); | ||
| assert_eq!( | ||
| String::from_utf8_lossy(&second.stdout).trim(), | ||
| "MATERIALIZED again", | ||
| ); | ||
| // The now-plain file carries no packed payload. | ||
| assert!( | ||
| !decmpfs::exe::contains_payload(&packed), | ||
| "after materialize the file carries no packed payload", | ||
| ); | ||
| std::fs::remove_dir_all(&dir).ok(); | ||
| } |
| { | ||
| "git": { | ||
| "sha1": "55fb470a720c03e8cf4feb76c600b7cc1a277838" | ||
| "sha1": "9e4134a23d4805e2e2c4b739f33847728f600dd4" | ||
| }, | ||
| "path_in_vcs": "" | ||
| "path_in_vcs": "crates/decmpfs" | ||
| } |
+345
-1
@@ -6,2 +6,35 @@ # This file is automatically @generated by Cargo. | ||
| [[package]] | ||
| name = "anstyle" | ||
| version = "1.0.14" | ||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" | ||
| [[package]] | ||
| name = "autocfg" | ||
| version = "1.5.1" | ||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" | ||
| [[package]] | ||
| name = "bit-set" | ||
| version = "0.8.0" | ||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" | ||
| dependencies = [ | ||
| "bit-vec", | ||
| ] | ||
| [[package]] | ||
| name = "bit-vec" | ||
| version = "0.8.0" | ||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" | ||
| [[package]] | ||
| name = "bitflags" | ||
| version = "2.13.0" | ||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" | ||
| [[package]] | ||
| name = "block-buffer" | ||
@@ -54,6 +87,9 @@ version = "0.10.4" | ||
| name = "decmpfs" | ||
| version = "0.1.0" | ||
| version = "0.1.2" | ||
| dependencies = [ | ||
| "libc", | ||
| "mockall", | ||
| "proptest", | ||
| "sha2", | ||
| "tempfile", | ||
| "windows-sys", | ||
@@ -74,2 +110,24 @@ "zstd", | ||
| [[package]] | ||
| name = "downcast" | ||
| version = "0.11.0" | ||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" | ||
| [[package]] | ||
| name = "errno" | ||
| version = "0.3.14" | ||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" | ||
| dependencies = [ | ||
| "libc", | ||
| "windows-sys", | ||
| ] | ||
| [[package]] | ||
| name = "fastrand" | ||
| version = "2.4.1" | ||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" | ||
| [[package]] | ||
| name = "find-msvc-tools" | ||
@@ -81,2 +139,23 @@ version = "0.1.9" | ||
| [[package]] | ||
| name = "fnv" | ||
| version = "1.0.7" | ||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" | ||
| [[package]] | ||
| name = "fragile" | ||
| version = "2.1.0" | ||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| checksum = "8878864ba14bb86e818a412bfd6f18f9eabd4ec0f008a28e8f7eb61db532fcf9" | ||
| dependencies = [ | ||
| "futures-core", | ||
| ] | ||
| [[package]] | ||
| name = "futures-core" | ||
| version = "0.3.32" | ||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" | ||
| [[package]] | ||
| name = "generic-array" | ||
@@ -120,2 +199,49 @@ version = "0.14.7" | ||
| [[package]] | ||
| name = "linux-raw-sys" | ||
| version = "0.12.1" | ||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" | ||
| [[package]] | ||
| name = "mockall" | ||
| version = "0.15.0" | ||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| checksum = "1a6ceddfe3ce334925e96bf420fdb2dcee5bed6c632a168ece622676dadeaf8a" | ||
| dependencies = [ | ||
| "cfg-if", | ||
| "downcast", | ||
| "fragile", | ||
| "mockall_derive", | ||
| "predicates", | ||
| "predicates-tree", | ||
| ] | ||
| [[package]] | ||
| name = "mockall_derive" | ||
| version = "0.15.0" | ||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| checksum = "9cfe16fbe8a314aeec0b861ac24e60b1e123e97634bab045475b9d6a18416fd8" | ||
| dependencies = [ | ||
| "cfg-if", | ||
| "proc-macro2", | ||
| "quote", | ||
| "syn", | ||
| ] | ||
| [[package]] | ||
| name = "num-traits" | ||
| version = "0.2.19" | ||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" | ||
| dependencies = [ | ||
| "autocfg", | ||
| ] | ||
| [[package]] | ||
| name = "once_cell" | ||
| version = "1.21.4" | ||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" | ||
| [[package]] | ||
| name = "pkg-config" | ||
@@ -127,2 +253,80 @@ version = "0.3.33" | ||
| [[package]] | ||
| name = "ppv-lite86" | ||
| version = "0.2.21" | ||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" | ||
| dependencies = [ | ||
| "zerocopy", | ||
| ] | ||
| [[package]] | ||
| name = "predicates" | ||
| version = "3.1.4" | ||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" | ||
| dependencies = [ | ||
| "anstyle", | ||
| "predicates-core", | ||
| ] | ||
| [[package]] | ||
| name = "predicates-core" | ||
| version = "1.0.10" | ||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" | ||
| [[package]] | ||
| name = "predicates-tree" | ||
| version = "1.0.13" | ||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" | ||
| dependencies = [ | ||
| "predicates-core", | ||
| "termtree", | ||
| ] | ||
| [[package]] | ||
| name = "proc-macro2" | ||
| version = "1.0.106" | ||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" | ||
| dependencies = [ | ||
| "unicode-ident", | ||
| ] | ||
| [[package]] | ||
| name = "proptest" | ||
| version = "1.11.0" | ||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" | ||
| dependencies = [ | ||
| "bit-set", | ||
| "bit-vec", | ||
| "bitflags", | ||
| "num-traits", | ||
| "rand", | ||
| "rand_chacha", | ||
| "rand_xorshift", | ||
| "regex-syntax", | ||
| "rusty-fork", | ||
| "tempfile", | ||
| "unarray", | ||
| ] | ||
| [[package]] | ||
| name = "quick-error" | ||
| version = "1.2.3" | ||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" | ||
| [[package]] | ||
| name = "quote" | ||
| version = "1.0.46" | ||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" | ||
| dependencies = [ | ||
| "proc-macro2", | ||
| ] | ||
| [[package]] | ||
| name = "r-efi" | ||
@@ -134,2 +338,71 @@ version = "5.3.0" | ||
| [[package]] | ||
| name = "rand" | ||
| version = "0.9.5" | ||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" | ||
| dependencies = [ | ||
| "rand_chacha", | ||
| "rand_core", | ||
| ] | ||
| [[package]] | ||
| name = "rand_chacha" | ||
| version = "0.9.0" | ||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" | ||
| dependencies = [ | ||
| "ppv-lite86", | ||
| "rand_core", | ||
| ] | ||
| [[package]] | ||
| name = "rand_core" | ||
| version = "0.9.5" | ||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" | ||
| dependencies = [ | ||
| "getrandom", | ||
| ] | ||
| [[package]] | ||
| name = "rand_xorshift" | ||
| version = "0.4.0" | ||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" | ||
| dependencies = [ | ||
| "rand_core", | ||
| ] | ||
| [[package]] | ||
| name = "regex-syntax" | ||
| version = "0.8.11" | ||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" | ||
| [[package]] | ||
| name = "rustix" | ||
| version = "1.1.4" | ||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" | ||
| dependencies = [ | ||
| "bitflags", | ||
| "errno", | ||
| "libc", | ||
| "linux-raw-sys", | ||
| "windows-sys", | ||
| ] | ||
| [[package]] | ||
| name = "rusty-fork" | ||
| version = "0.3.1" | ||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" | ||
| dependencies = [ | ||
| "fnv", | ||
| "quick-error", | ||
| "tempfile", | ||
| "wait-timeout", | ||
| ] | ||
| [[package]] | ||
| name = "sha2" | ||
@@ -152,2 +425,32 @@ version = "0.10.9" | ||
| [[package]] | ||
| name = "syn" | ||
| version = "2.0.118" | ||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" | ||
| dependencies = [ | ||
| "proc-macro2", | ||
| "quote", | ||
| "unicode-ident", | ||
| ] | ||
| [[package]] | ||
| name = "tempfile" | ||
| version = "3.27.0" | ||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" | ||
| dependencies = [ | ||
| "fastrand", | ||
| "getrandom", | ||
| "once_cell", | ||
| "rustix", | ||
| "windows-sys", | ||
| ] | ||
| [[package]] | ||
| name = "termtree" | ||
| version = "0.5.1" | ||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" | ||
| [[package]] | ||
| name = "typenum" | ||
@@ -159,2 +462,14 @@ version = "1.20.1" | ||
| [[package]] | ||
| name = "unarray" | ||
| version = "0.1.4" | ||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" | ||
| [[package]] | ||
| name = "unicode-ident" | ||
| version = "1.0.24" | ||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" | ||
| [[package]] | ||
| name = "version_check" | ||
@@ -166,2 +481,11 @@ version = "0.9.5" | ||
| [[package]] | ||
| name = "wait-timeout" | ||
| version = "0.2.1" | ||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" | ||
| dependencies = [ | ||
| "libc", | ||
| ] | ||
| [[package]] | ||
| name = "wasip2" | ||
@@ -255,2 +579,22 @@ version = "1.0.4+wasi-0.2.12" | ||
| [[package]] | ||
| name = "zerocopy" | ||
| version = "0.8.55" | ||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" | ||
| dependencies = [ | ||
| "zerocopy-derive", | ||
| ] | ||
| [[package]] | ||
| name = "zerocopy-derive" | ||
| version = "0.8.55" | ||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" | ||
| dependencies = [ | ||
| "proc-macro2", | ||
| "quote", | ||
| "syn", | ||
| ] | ||
| [[package]] | ||
| name = "zstd" | ||
@@ -257,0 +601,0 @@ version = "0.13.3" |
+33
-3
@@ -15,4 +15,4 @@ # THIS FILE IS AUTOMATICALLY GENERATED BY CARGO | ||
| name = "decmpfs" | ||
| version = "0.1.0" | ||
| build = false | ||
| version = "0.1.2" | ||
| build = "build.rs" | ||
| autolib = false | ||
@@ -24,3 +24,3 @@ autobins = false | ||
| description = "Apply OS-level transparent filesystem compression (APFS decmpfs / btrfs / NTFS) to a file in place." | ||
| readme = "README.md" | ||
| readme = false | ||
| keywords = [ | ||
@@ -43,2 +43,6 @@ "apfs", | ||
| default = [] | ||
| exe = [ | ||
| "dep:zstd", | ||
| "dep:sha2", | ||
| ] | ||
@@ -49,2 +53,7 @@ [lib] | ||
| [[bin]] | ||
| name = "decmpfs-stub" | ||
| path = "src/bin/decmpfs-stub.rs" | ||
| required-features = ["exe"] | ||
| [[test]] | ||
@@ -54,2 +63,6 @@ name = "btrfs" | ||
| [[test]] | ||
| name = "exe_selfreplace" | ||
| path = "tests/exe_selfreplace.rs" | ||
| [dependencies.sha2] | ||
@@ -63,2 +76,11 @@ version = "=0.10.9" | ||
| [dev-dependencies.mockall] | ||
| version = "=0.15.0" | ||
| [dev-dependencies.proptest] | ||
| version = "=1.11.0" | ||
| [dev-dependencies.tempfile] | ||
| version = "=3.27.0" | ||
| [target.'cfg(any(target_os = "linux", target_os = "macos"))'.dependencies.libc] | ||
@@ -75,1 +97,9 @@ version = "=0.2.186" | ||
| ] | ||
| [lints.rust.unexpected_cfgs] | ||
| level = "warn" | ||
| priority = 0 | ||
| check-cfg = [ | ||
| "cfg(coverage)", | ||
| "cfg(coverage_nightly)", | ||
| ] |
+59
-4
@@ -247,5 +247,54 @@ //! `addon` feature — unwrap a napi `--compress` hybrid back to the raw `.node`. | ||
| #[cfg(test)] | ||
| #[cfg_attr(coverage_nightly, coverage(off))] | ||
| mod tests { | ||
| use proptest::prelude::*; | ||
| use super::*; | ||
| proptest! { | ||
| // Tier 1 round-trip: any raw addon, wrapped into a well-formed pressed-data | ||
| // section, decodes back to the exact original bytes (header parse + zstd inflate | ||
| // + SHA-512 gate is the identity over a validly-framed blob). | ||
| #[test] | ||
| fn decode_round_trips_arbitrary_payload( | ||
| raw in prop::collection::vec(any::<u8>(), 1..8192), | ||
| has_config in any::<bool>(), | ||
| ) { | ||
| let section = synth_section(&raw, has_config); | ||
| let decoded = decode_pressed_data(§ion); | ||
| prop_assert_eq!(decoded.as_deref(), Some(raw.as_slice())); | ||
| } | ||
| // The decoder never panics on arbitrary bytes — the offset arithmetic, size | ||
| // guards, and zstd frame decode must fail closed to a graceful `None`. | ||
| #[test] | ||
| fn decode_never_panics(data in prop::collection::vec(any::<u8>(), 0..4096)) { | ||
| let _ = decode_pressed_data(&data); | ||
| } | ||
| // The container walk + decode never panics on arbitrary bytes. | ||
| #[test] | ||
| fn unwrap_never_panics(data in prop::collection::vec(any::<u8>(), 0..4096)) { | ||
| let _ = unwrap_if_hybrid(&data); | ||
| } | ||
| // Tamper-evidence: a single flipped byte anywhere in a valid section either still | ||
| // decodes to the EXACT original (the flip landed in an ignored field — cache key | ||
| // / platform metadata) or is rejected with `None`. It can NEVER yield different | ||
| // bytes — the SHA-512 gate makes a silently-wrong decode impossible. | ||
| #[test] | ||
| fn tampering_never_yields_wrong_bytes( | ||
| raw in prop::collection::vec(any::<u8>(), 1..2048), | ||
| idx in any::<prop::sample::Index>(), | ||
| xor in 1u8..=255, | ||
| ) { | ||
| let mut section = synth_section(&raw, false); | ||
| let i = idx.index(section.len()); | ||
| section[i] ^= xor; | ||
| if let Some(out) = decode_pressed_data(§ion) { | ||
| prop_assert_eq!(out, raw); | ||
| } | ||
| } | ||
| } | ||
| /// Build a valid pressed-data section blob from `raw` (the addon) so the header | ||
@@ -278,3 +327,6 @@ /// parse + zstd decode + SHA-512 check round-trip without a real binary. | ||
| let section = synth_section(&raw, false); | ||
| assert_eq!(decode_pressed_data(§ion).as_deref(), Some(raw.as_slice())); | ||
| assert_eq!( | ||
| decode_pressed_data(§ion).as_deref(), | ||
| Some(raw.as_slice()) | ||
| ); | ||
| } | ||
@@ -286,3 +338,6 @@ | ||
| let section = synth_section(&raw, true); | ||
| assert_eq!(decode_pressed_data(§ion).as_deref(), Some(raw.as_slice())); | ||
| assert_eq!( | ||
| decode_pressed_data(§ion).as_deref(), | ||
| Some(raw.as_slice()) | ||
| ); | ||
| } | ||
@@ -334,3 +389,3 @@ | ||
| bin[16..20].copy_from_slice(&1u32.to_le_bytes()); // ncmds = 1 | ||
| // segment_command_64 at offset 32 | ||
| // segment_command_64 at offset 32 | ||
| let seg = 32; | ||
@@ -341,3 +396,3 @@ bin[seg..seg + 4].copy_from_slice(&LC_SEGMENT_64.to_le_bytes()); | ||
| bin[seg + 64..seg + 68].copy_from_slice(&1u32.to_le_bytes()); // nsects = 1 | ||
| // section_64 at offset seg + 72 | ||
| // section_64 at offset seg + 72 | ||
| let sect = seg + 72; | ||
@@ -344,0 +399,0 @@ bin[sect..sect + 14].copy_from_slice(b"__PRESSED_DATA"); |
+81
-2
@@ -261,5 +261,78 @@ //! The install-time gate: decide whether a given file should be OS-compressed. | ||
| #[cfg(test)] | ||
| #[cfg_attr(coverage_nightly, coverage(off))] | ||
| mod tests { | ||
| use proptest::prelude::*; | ||
| use super::*; | ||
| proptest! { | ||
| // Tier 1 — the size-predicate parser never panics on ANY string. `spec` is a | ||
| // manifest-supplied value (`compress = ">= 1MB"`), so arbitrary text reaches it; | ||
| // a graceful `Err(GateParseError)` is the only non-`Ok` outcome. | ||
| #[test] | ||
| fn size_predicate_parse_never_panics(s in ".*") { | ||
| let _ = SizePredicate::parse(&s); | ||
| } | ||
| // Round-trip: a bare-number spec parses back to the exact threshold under both | ||
| // operators (formatting the struct's value then reparsing is the identity). | ||
| #[test] | ||
| fn bare_number_spec_round_trips(n in any::<u64>()) { | ||
| prop_assert_eq!( | ||
| SizePredicate::parse(&format!(">{n}")), | ||
| Ok(SizePredicate::GreaterThan(n)) | ||
| ); | ||
| prop_assert_eq!( | ||
| SizePredicate::parse(&format!(">= {n}")), | ||
| Ok(SizePredicate::AtLeast(n)) | ||
| ); | ||
| } | ||
| // Oracle: a decimal-unit literal equals `number * 1000^k` whenever it can't | ||
| // overflow (bounded so the product stays inside u64). | ||
| #[test] | ||
| fn decimal_unit_scales_like_the_oracle(n in 0u64..=1_000_000) { | ||
| prop_assert_eq!(parse_size(&format!("{n}KB")), Ok(n * 1_000)); | ||
| prop_assert_eq!(parse_size(&format!("{n}MB")), Ok(n * 1_000_000)); | ||
| prop_assert_eq!(parse_size(&format!("{n}kib")), Ok(n * 1024)); | ||
| } | ||
| // `Gate::matches` never panics on an untrusted path text of any bytes/length — | ||
| // the path segment can carry a downloaded package's name. | ||
| #[test] | ||
| fn gate_matches_never_panics(text in ".*", len in any::<u64>()) { | ||
| let _ = Gate::default().matches(&text, len); | ||
| let _ = Gate::any().matches(&text, len); | ||
| } | ||
| // Oracle: a metachar-free pattern is a plain literal — it matches iff the text | ||
| // is byte-identical. | ||
| #[test] | ||
| fn literal_glob_matches_iff_equal( | ||
| lit in "[a-zA-Z0-9._-]{0,32}", | ||
| text in "[a-zA-Z0-9._-]{0,32}", | ||
| ) { | ||
| prop_assert_eq!(glob_match(&lit, &text), lit == text); | ||
| } | ||
| // `**` is the match-everything pattern: it accepts any path text, including one | ||
| // that spans separators. | ||
| #[test] | ||
| fn double_star_matches_any_path(text in ".*") { | ||
| prop_assert!(glob_match("**", &text)); | ||
| } | ||
| // The matcher terminates and never panics on arbitrary pattern AND text. The | ||
| // pattern alphabet is bounded (few metachars, short) so this Tier-1 property | ||
| // can't wander into the algorithmic-complexity search that Tier 2 (the | ||
| // `gate_glob` fuzz target) owns. | ||
| #[test] | ||
| fn glob_match_never_panics( | ||
| pattern in "[a-z/.?*]{0,12}", | ||
| text in "[a-z/.]{0,24}", | ||
| ) { | ||
| let _ = glob_match(&pattern, &text); | ||
| } | ||
| } | ||
| #[test] | ||
@@ -286,4 +359,7 @@ fn parses_units_case_insensitively() { | ||
| assert_eq!(parse_size("10PB"), Err(GateParseError::Unit)); | ||
| assert_eq!(parse_size("99999999999999999999GB"), Err(GateParseError::Number)); | ||
| assert_eq!( | ||
| parse_size("99999999999999999999GB"), | ||
| Err(GateParseError::Number) | ||
| ); | ||
| assert_eq!( | ||
| parse_size("18446744073709551615KB"), | ||
@@ -324,3 +400,6 @@ Err(GateParseError::Overflow) | ||
| fn glob_matches_node_addons_anywhere() { | ||
| assert!(glob_match("**/*.node", "node_modules/foo/build/Release/addon.node")); | ||
| assert!(glob_match( | ||
| "**/*.node", | ||
| "node_modules/foo/build/Release/addon.node" | ||
| )); | ||
| assert!(glob_match("**/*.node", "addon.node")); | ||
@@ -327,0 +406,0 @@ assert!(glob_match("*.node", "addon.node")); |
+615
-19
@@ -22,2 +22,7 @@ //! `decmpfs` — apply the operating system's transparent per-file compression to a file | ||
| #![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))] | ||
| // On a nightly `cargo llvm-cov` run, cargo-llvm-cov sets `coverage_nightly`, | ||
| // enabling `#[coverage(off)]` so test-only code is dropped from the report and it | ||
| // reflects PRODUCTION coverage. A no-op on stable (the cfg is unset), so ordinary | ||
| // builds and `cargo test` are unaffected. | ||
| #![cfg_attr(coverage_nightly, feature(coverage_attribute))] | ||
@@ -226,2 +231,170 @@ use std::path::Path; | ||
| /// What a [`copy_file`] did — a SUCCESS shape, same contract as [`Outcome`]: | ||
| /// `Err` is reserved for genuine I/O failures; the copy itself always lands. | ||
| #[derive(Debug, Clone, PartialEq, Eq)] | ||
| pub enum CopyOutcome { | ||
| /// Copy-on-write clone (`clonefile` / `FICLONE`) — the extents are shared, | ||
| /// so the source's compression state carries over at zero cost. | ||
| Cloned { compressed: bool }, | ||
| /// Byte copy plus one-pass recompression at the destination (a compressed | ||
| /// source that could not be cloned — cross-volume, non-reflink FS). | ||
| CopiedCompressed { before: u64, after: u64 }, | ||
| /// Byte copy landed plain: the source was plain, the destination FS has no | ||
| /// transparent compression, or a fail-soft skip (`skipped` carries the | ||
| /// reason when the source WAS compressed but the state could not follow). | ||
| CopiedPlain { skipped: Option<SkipReason> }, | ||
| } | ||
| /// Attempt a copy-on-write clone of `src` at `dest` (`clonefile(2)` on macOS, | ||
| /// the `FICLONE` ioctl on Linux) — the zero-cost way to copy a compressed file | ||
| /// WITH its compression. `Ok(true)` = cloned; `Ok(false)` = this pairing can't | ||
| /// clone (cross-volume, non-reflink FS, an existing destination on macOS, | ||
| /// Windows) and the caller decides the fallback — [`copy_file`] is the | ||
| /// clone-then-fallback composition, and a Node-`COPYFILE_FICLONE_FORCE`-shaped | ||
| /// caller turns `false` into its error. | ||
| pub fn try_clone_file(src: &Path, dest: &Path) -> Result<bool, Error> { | ||
| if !src.exists() { | ||
| return Err(Error::NotFound(src.to_path_buf())); | ||
| } | ||
| Os.clone_file(src, dest) | ||
| } | ||
| /// Copy `src` to `dest` preserving transparent filesystem compression — the | ||
| /// `fs.copyFile` the OS never shipped. A plain byte copy silently re-inflates | ||
| /// a compressed file (the kernel hands every reader the full logical bytes, | ||
| /// and that is what gets written back out); this copy keeps the on-disk | ||
| /// savings. | ||
| /// | ||
| /// Strategy, in order: | ||
| /// 1. A copy-on-write clone (`clonefile(2)` on macOS, the `FICLONE` ioctl on | ||
| /// Linux) shares the extents, so a compressed source stays compressed at | ||
| /// zero cost — and a plain source clones plain. | ||
| /// 2. When cloning is impossible (cross-volume, non-reflink FS, Windows), the | ||
| /// logical bytes are copied and, if the source was compressed, the | ||
| /// destination is written back compressed via the same guarded one-pass | ||
| /// path as [`compress_bytes`]. | ||
| /// 3. A plain source is copied plain — a copy never changes compression state. | ||
| /// | ||
| /// `fs.copyFile` parity: an existing `dest` is replaced, and the source's | ||
| /// permission bits carry over. Fail-soft like the rest of the crate: on any | ||
| /// backend skip the plain copy still stands, reported in the outcome. | ||
| pub fn copy_file(src: &Path, dest: &Path) -> Result<CopyOutcome, Error> { | ||
| copy_file_with(&Os, src, dest) | ||
| } | ||
| /// `copy_file` over an injectable [`Backend`] — production always threads | ||
| /// [`Os`]; tests drive the clone and fallback arms with fakes. | ||
| fn copy_file_with<B: Backend>(backend: &B, src: &Path, dest: &Path) -> Result<CopyOutcome, Error> { | ||
| if !src.exists() { | ||
| return Err(Error::NotFound(src.to_path_buf())); | ||
| } | ||
| if dest.exists() { | ||
| // A copy onto itself (same path, a hardlink, a symlink alias) is a no-op | ||
| // success — otherwise the replace step below would clobber the copy's own | ||
| // source. The extents are 100% shared, which is what `Cloned` states. | ||
| if is_same_file(src, dest) { | ||
| return Ok(CopyOutcome::Cloned { | ||
| compressed: backend.is_already_compressed(src).unwrap_or(false), | ||
| }); | ||
| } | ||
| // clonefile refuses an existing destination; replace-by-default is the | ||
| // `fs.copyFile` contract this mirrors. | ||
| std::fs::remove_file(dest).map_err(|source| Error::Io { | ||
| context: "replace existing destination", | ||
| source, | ||
| })?; | ||
| } | ||
| // On a filesystem with no compression signal this is a capability gap, not a | ||
| // failure — treat it as "plain source" and keep copying. | ||
| let compressed_src = backend.is_already_compressed(src).unwrap_or(false); | ||
| if backend.clone_file(src, dest)? { | ||
| return Ok(CopyOutcome::Cloned { | ||
| compressed: compressed_src, | ||
| }); | ||
| } | ||
| // A normal read hands back the full logical bytes regardless of the | ||
| // source's on-disk representation. | ||
| let content = std::fs::read(src).map_err(|source| Error::Io { | ||
| context: "read copy source", | ||
| source, | ||
| })?; | ||
| let mode = std::fs::metadata(src).ok().map(|meta| meta.permissions()); | ||
| if !compressed_src { | ||
| plain_write(dest, &content)?; | ||
| if let Some(mode) = mode { | ||
| let _ = std::fs::set_permissions(dest, mode); | ||
| } | ||
| return Ok(CopyOutcome::CopiedPlain { skipped: None }); | ||
| } | ||
| let outcome = compress_bytes_with(backend, dest, &content, &Gate::any())?; | ||
| if let Some(mode) = mode { | ||
| let _ = std::fs::set_permissions(dest, mode); | ||
| } | ||
| Ok(match outcome { | ||
| Outcome::Compressed { before, after } | Outcome::NoGain { before, after } => { | ||
| CopyOutcome::CopiedCompressed { before, after } | ||
| } | ||
| // Unreachable from a fresh destination (compress_bytes maps an | ||
| // already-compressed detect to a plain write), kept total + truthful. | ||
| Outcome::AlreadyCompressed { before } => CopyOutcome::CopiedCompressed { | ||
| before, | ||
| after: before, | ||
| }, | ||
| Outcome::Unsupported { .. } => CopyOutcome::CopiedPlain { skipped: None }, | ||
| Outcome::Skipped { reason } => CopyOutcome::CopiedPlain { | ||
| skipped: Some(reason), | ||
| }, | ||
| }) | ||
| } | ||
| /// True when both paths name the same underlying file — same dev+inode on | ||
| /// unix, or same volume serial + file index on Windows. Guards [`copy_file`]'s | ||
| /// replace-by-default from removing its own source. | ||
| #[cfg(unix)] | ||
| fn is_same_file(a: &Path, b: &Path) -> bool { | ||
| use std::os::unix::fs::MetadataExt; | ||
| match (std::fs::metadata(a), std::fs::metadata(b)) { | ||
| (Ok(meta_a), Ok(meta_b)) => meta_a.dev() == meta_b.dev() && meta_a.ino() == meta_b.ino(), | ||
| _ => false, | ||
| } | ||
| } | ||
| #[cfg(windows)] | ||
| fn is_same_file(a: &Path, b: &Path) -> bool { | ||
| use std::os::windows::io::AsRawHandle; | ||
| use windows_sys::Win32::Storage::FileSystem::{ | ||
| GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION, | ||
| }; | ||
| fn identity(path: &Path) -> Option<(u32, u64)> { | ||
| let file = std::fs::File::open(path).ok()?; | ||
| let mut info = unsafe { std::mem::zeroed::<BY_HANDLE_FILE_INFORMATION>() }; | ||
| if unsafe { GetFileInformationByHandle(file.as_raw_handle() as _, &mut info) } == 0 { | ||
| return None; | ||
| } | ||
| Some(( | ||
| info.dwVolumeSerialNumber, | ||
| (u64::from(info.nFileIndexHigh) << 32) | u64::from(info.nFileIndexLow), | ||
| )) | ||
| } | ||
| match (identity(a), identity(b)) { | ||
| (Some(id_a), Some(id_b)) => id_a == id_b, | ||
| // Preserve the exact-path fast path if a file-information query is denied. | ||
| _ => match (std::fs::canonicalize(a), std::fs::canonicalize(b)) { | ||
| (Ok(canon_a), Ok(canon_b)) => canon_a == canon_b, | ||
| _ => false, | ||
| }, | ||
| } | ||
| } | ||
| #[cfg(not(any(unix, windows)))] | ||
| fn is_same_file(a: &Path, b: &Path) -> bool { | ||
| match (std::fs::canonicalize(a), std::fs::canonicalize(b)) { | ||
| (Ok(canon_a), Ok(canon_b)) => canon_a == canon_b, | ||
| _ => false, | ||
| } | ||
| } | ||
| /// Fail-soft plain atomic write: sibling temp + fsync + rename. The never-break-the | ||
@@ -261,9 +434,67 @@ /// -install floor under every `compress_bytes` fallback. | ||
| /// Filesystem-compression state of a path — one call that coalesces the | ||
| /// otherwise-separate size + backend-signal reads (the compress/copy paths | ||
| /// previously did a `stat` AND an `lstat`/attr read per file). Follows symlinks: | ||
| /// compression is a property of the target file, never a symlink. | ||
| pub struct Stat { | ||
| /// FS-compressed on disk. Uses the backend's authoritative signal where it has | ||
| /// one (`UF_COMPRESSED` on APFS, FIEMAP-encoded extents on btrfs, the | ||
| /// compressed attribute on NTFS); elsewhere inferred from allocated < logical. | ||
| pub compressed: bool, | ||
| /// Apparent (logical) size — constant whether or not the file is compressed. | ||
| pub logical: u64, | ||
| /// Allocated (physical) bytes on disk — where the compression win shows. | ||
| pub physical: u64, | ||
| } | ||
| /// Inspect the FS-compression state of `path` (see [`Stat`]). | ||
| pub fn stat(path: &Path) -> Result<Stat, Error> { | ||
| stat_with(&Os, path) | ||
| } | ||
| /// [`stat`] over an injectable [`Backend`] so the no-signal (allocated-bytes) | ||
| /// inference arm is testable without a real filesystem. | ||
| fn stat_with<B: Backend>(backend: &B, path: &Path) -> Result<Stat, Error> { | ||
| let meta = std::fs::metadata(path).map_err(|source| Error::Io { | ||
| context: "stat", | ||
| source, | ||
| })?; | ||
| let logical = meta.len(); | ||
| // One metadata read yields both size + allocation on unix (the coalesce); | ||
| // Windows needs GetCompressedFileSizeW for the post-compression allocation. | ||
| #[cfg(unix)] | ||
| let physical = { | ||
| use std::os::unix::fs::MetadataExt; | ||
| meta.blocks().saturating_mul(512) | ||
| }; | ||
| #[cfg(not(unix))] | ||
| let physical = verify::on_disk_bytes(path)?; | ||
| // Prefer the backend's authoritative signal; fall back to the | ||
| // allocated-vs-logical inference when there is no signal (e.g. NTFS) OR the | ||
| // probe isn't supported on this filesystem (e.g. FIEMAP on tmpfs) — a stat is | ||
| // an inspection and must never fail over a best-effort compression check. | ||
| let compressed = match backend.compressed_on_disk(path) { | ||
| Ok(Some(signal)) => signal, | ||
| Ok(None) | Err(_) => logical > 0 && physical < logical, | ||
| }; | ||
| Ok(Stat { | ||
| compressed, | ||
| logical, | ||
| physical, | ||
| }) | ||
| } | ||
| #[cfg(feature = "addon")] | ||
| pub mod addon; | ||
| #[cfg(feature = "exe")] | ||
| pub mod exe; | ||
| mod gate; | ||
| mod remove; | ||
| mod safety; | ||
| mod stream; | ||
| mod verify; | ||
| pub use gate::{Gate, GateParseError, SizePredicate, DEFAULT_GLOB}; | ||
| pub use remove::{rm, RmOptions}; | ||
| pub use stream::DecmpfsWriter; | ||
@@ -289,6 +520,11 @@ #[cfg(target_os = "linux")] | ||
| /// cost in a release build). | ||
| #[cfg_attr(test, mockall::automock)] | ||
| pub(crate) trait Backend { | ||
| fn detect(&self, path: &Path) -> Result<Support, Error>; | ||
| fn is_already_compressed(&self, path: &Path) -> Result<bool, Error>; | ||
| fn apply_inplace(&self, path: &Path) -> Result<(), Error>; | ||
| /// Compress `path` in place. `snapshot` is the already-read file contents (the | ||
| /// caller holds them for rollback); backends that rewrite via temp+rename reuse | ||
| /// it instead of reading the file a second time, and backends that flag the | ||
| /// existing file in place (Windows) ignore it. | ||
| fn apply_inplace(&self, path: &Path, snapshot: &[u8]) -> Result<(), Error>; | ||
| fn apply_bytes( | ||
@@ -301,2 +537,8 @@ &self, | ||
| fn compressed_on_disk(&self, path: &Path) -> Result<Option<bool>, Error>; | ||
| /// Copy-on-write clone. `Ok(false)` = "cannot clone here" → the caller falls | ||
| /// back to a byte copy. Defaulted so fakes without a clone path exercise the | ||
| /// fallback arms. | ||
| fn clone_file(&self, _src: &Path, _dest: &Path) -> Result<bool, Error> { | ||
| Ok(false) | ||
| } | ||
| } | ||
@@ -314,4 +556,4 @@ | ||
| } | ||
| fn apply_inplace(&self, path: &Path) -> Result<(), Error> { | ||
| backend::apply_inplace(path) | ||
| fn apply_inplace(&self, path: &Path, snapshot: &[u8]) -> Result<(), Error> { | ||
| backend::apply_inplace(path, snapshot) | ||
| } | ||
@@ -329,2 +571,5 @@ fn apply_bytes( | ||
| } | ||
| fn clone_file(&self, src: &Path, dest: &Path) -> Result<bool, Error> { | ||
| backend::clone_file(src, dest) | ||
| } | ||
| } | ||
@@ -337,14 +582,15 @@ | ||
| pub(crate) detect: Support, | ||
| /// `None` → apply succeeds; `Some(errno)` → apply fails with that OS error. | ||
| pub(crate) apply_errno: Option<i32>, | ||
| /// `None` → apply succeeds; `Some(kind)` → apply fails portably with that kind. | ||
| pub(crate) apply_error: Option<std::io::ErrorKind>, | ||
| } | ||
| #[cfg(test)] | ||
| #[cfg_attr(coverage_nightly, coverage(off))] | ||
| impl FakeBackend { | ||
| fn apply_result(&self) -> Result<(), Error> { | ||
| match self.apply_errno { | ||
| match self.apply_error { | ||
| None => Ok(()), | ||
| Some(errno) => Err(Error::Io { | ||
| Some(kind) => Err(Error::Io { | ||
| context: "fake apply", | ||
| source: std::io::Error::from_raw_os_error(errno), | ||
| source: std::io::Error::from(kind), | ||
| }), | ||
@@ -356,2 +602,3 @@ } | ||
| #[cfg(test)] | ||
| #[cfg_attr(coverage_nightly, coverage(off))] | ||
| impl Backend for FakeBackend { | ||
@@ -364,3 +611,3 @@ fn detect(&self, _path: &Path) -> Result<Support, Error> { | ||
| } | ||
| fn apply_inplace(&self, _path: &Path) -> Result<(), Error> { | ||
| fn apply_inplace(&self, _path: &Path, _snapshot: &[u8]) -> Result<(), Error> { | ||
| self.apply_result() | ||
@@ -381,4 +628,4 @@ } | ||
| #[cfg(test)] | ||
| #[cfg_attr(coverage_nightly, coverage(off))] | ||
| mod tests { | ||
@@ -389,2 +636,5 @@ use super::*; | ||
| let dir = std::env::temp_dir().join(format!("decmpfs-{tag}-{}", std::process::id())); | ||
| // A pid-recycled leftover FILE at this path makes create_dir_all fail | ||
| // with AlreadyExists; clear it so the scratch dir always materializes. | ||
| let _ = std::fs::remove_file(&dir); | ||
| std::fs::create_dir_all(&dir).unwrap(); | ||
@@ -493,6 +743,3 @@ dir | ||
| assert!( | ||
| matches!( | ||
| out, | ||
| Ok(Outcome::Compressed { .. } | Outcome::NoGain { .. }) | ||
| ), | ||
| matches!(out, Ok(Outcome::Compressed { .. } | Outcome::NoGain { .. })), | ||
| "one-pass APFS write → applied, got {out:?}" | ||
@@ -614,3 +861,6 @@ ); | ||
| let out = compress_bytes(&target, &fake_addon(), &Gate::any()); | ||
| assert!(out.is_err(), "cannot write a file over a directory, got {out:?}"); | ||
| assert!( | ||
| out.is_err(), | ||
| "cannot write a file over a directory, got {out:?}" | ||
| ); | ||
| assert!(target.is_dir(), "the directory is left intact"); | ||
@@ -620,2 +870,50 @@ std::fs::remove_dir_all(&dir).ok(); | ||
| #[test] | ||
| fn stat_reports_size_and_uncompressed_for_a_plain_file() { | ||
| let dir = scratch("stat-plain"); | ||
| let path = dir.join("f"); | ||
| std::fs::write(&path, vec![0u8; 4096]).unwrap(); | ||
| let s = stat(&path).unwrap(); | ||
| assert_eq!(s.logical, 4096, "logical == the written bytes"); | ||
| assert!(s.physical > 0, "allocated bytes reported"); | ||
| assert!( | ||
| !s.compressed, | ||
| "a freshly-written plain file is not FS-compressed" | ||
| ); | ||
| std::fs::remove_dir_all(&dir).ok(); | ||
| } | ||
| #[test] | ||
| fn stat_reflects_a_compressed_file_where_supported() { | ||
| let dir = scratch("stat-comp"); | ||
| let path = dir.join("addon.node"); | ||
| let content = vec![0xABu8; 128 * 1024]; | ||
| let outcome = compress_bytes(&path, &content, &Gate::any()).unwrap(); | ||
| let s = stat(&path).unwrap(); | ||
| assert_eq!( | ||
| s.logical, | ||
| content.len() as u64, | ||
| "logical == the written bytes" | ||
| ); | ||
| assert_eq!( | ||
| std::fs::read(&path).unwrap(), | ||
| content, | ||
| "content round-trips" | ||
| ); | ||
| // Where the FS actually compressed (APFS / btrfs / NTFS), stat must reflect | ||
| // it; on an unsupported FS the outcome isn't Compressed and we only assert | ||
| // the size + content invariants above. | ||
| if matches!(outcome, Outcome::Compressed { .. }) { | ||
| assert!( | ||
| s.compressed, | ||
| "a Compressed outcome → stat reports compressed" | ||
| ); | ||
| assert!( | ||
| s.physical < s.logical, | ||
| "allocation shrank below the logical size" | ||
| ); | ||
| } | ||
| std::fs::remove_dir_all(&dir).ok(); | ||
| } | ||
| // A read-only parent dir: the guarded backend write hits EACCES (classify_skip → | ||
@@ -652,3 +950,3 @@ // Skipped), then the plain-write fallback also can't write → `Err`. Root bypasses | ||
| detect: Support::AlreadyCompressed, | ||
| apply_errno: None, | ||
| apply_error: None, | ||
| }; | ||
@@ -670,6 +968,9 @@ assert!(matches!( | ||
| detect: Support::Unsupported(UnsupportedReason::Filesystem), | ||
| apply_errno: None, | ||
| apply_error: None, | ||
| }; | ||
| let out = compress_bytes_with(&backend, &path, &content, &Gate::any()); | ||
| assert!(matches!(out, Ok(Outcome::Unsupported { .. })), "got {out:?}"); | ||
| assert!( | ||
| matches!(out, Ok(Outcome::Unsupported { .. })), | ||
| "got {out:?}" | ||
| ); | ||
| assert_eq!(std::fs::read(&path).unwrap(), content, "bytes landed plain"); | ||
@@ -688,3 +989,3 @@ std::fs::remove_dir_all(&dir).ok(); | ||
| detect: Support::Supported, | ||
| apply_errno: Some(13), // EACCES | ||
| apply_error: Some(std::io::ErrorKind::PermissionDenied), | ||
| }; | ||
@@ -704,2 +1005,297 @@ let out = compress_bytes_with(&backend, &path, &content, &Gate::any()); | ||
| } | ||
| #[test] | ||
| fn copy_file_errors_when_the_source_is_missing() { | ||
| let dir = scratch("copy-missing"); | ||
| let out = copy_file(&dir.join("absent.node"), &dir.join("dest.node")); | ||
| assert!(matches!(out, Err(Error::NotFound(_)))); | ||
| std::fs::remove_dir_all(&dir).ok(); | ||
| } | ||
| /// A fallback fake: no clone path (trait default), reports the source | ||
| /// compressed, and its apply actually writes — so the guarded one-pass copy | ||
| /// arm runs end to end and classifies via the backend signal. | ||
| struct RecompressingFake; | ||
| impl Backend for RecompressingFake { | ||
| fn detect(&self, _path: &Path) -> Result<Support, Error> { | ||
| Ok(Support::Supported) | ||
| } | ||
| fn is_already_compressed(&self, _path: &Path) -> Result<bool, Error> { | ||
| Ok(true) | ||
| } | ||
| fn apply_inplace(&self, _path: &Path, _snapshot: &[u8]) -> Result<(), Error> { | ||
| Ok(()) | ||
| } | ||
| fn apply_bytes( | ||
| &self, | ||
| path: &Path, | ||
| content: &[u8], | ||
| _mode: Option<std::fs::Permissions>, | ||
| ) -> Result<(), Error> { | ||
| std::fs::write(path, content).map_err(|source| Error::Io { | ||
| context: "fake write", | ||
| source, | ||
| }) | ||
| } | ||
| fn compressed_on_disk(&self, _path: &Path) -> Result<Option<bool>, Error> { | ||
| Ok(Some(true)) | ||
| } | ||
| } | ||
| #[test] | ||
| fn copy_file_recompresses_at_the_destination_when_it_cannot_clone() { | ||
| let dir = scratch("copy-recompress"); | ||
| let src = dir.join("src.node"); | ||
| let dest = dir.join("dest.node"); | ||
| let content = fake_addon(); | ||
| std::fs::write(&src, &content).unwrap(); | ||
| let out = copy_file_with(&RecompressingFake, &src, &dest).unwrap(); | ||
| assert!( | ||
| matches!(out, CopyOutcome::CopiedCompressed { .. }), | ||
| "got {out:?}" | ||
| ); | ||
| assert_eq!( | ||
| std::fs::read(&dest).unwrap(), | ||
| content, | ||
| "bytes are identical" | ||
| ); | ||
| std::fs::remove_dir_all(&dir).ok(); | ||
| } | ||
| #[test] | ||
| fn copy_file_with_mock_backend_takes_the_clone_fast_path() { | ||
| // mockall MockBackend mocks the fs backend seam (no real syscalls); tempfile | ||
| // gives a real, isolated, auto-cleaned src fixture. clone_file → true | ||
| // short-circuits copy_file_with to the zero-cost Cloned outcome. | ||
| let dir = tempfile::tempdir().unwrap(); | ||
| let src = dir.path().join("a.node"); | ||
| std::fs::write(&src, b"native").unwrap(); | ||
| let dest = dir.path().join("b.node"); | ||
| let mut backend = MockBackend::new(); | ||
| backend | ||
| .expect_is_already_compressed() | ||
| .returning(|_| Ok(true)); | ||
| backend.expect_clone_file().returning(|_, _| Ok(true)); | ||
| let out = copy_file_with(&backend, &src, &dest).unwrap(); | ||
| assert!( | ||
| matches!(out, CopyOutcome::Cloned { compressed: true }), | ||
| "clone fast-path → Cloned; got {out:?}" | ||
| ); | ||
| } | ||
| #[test] | ||
| fn copy_file_copies_a_plain_source_plain_and_replaces_the_destination() { | ||
| let dir = scratch("copy-plain"); | ||
| let src = dir.join("src.node"); | ||
| let dest = dir.join("dest.node"); | ||
| let content = fake_addon(); | ||
| std::fs::write(&src, &content).unwrap(); | ||
| std::fs::write(&dest, b"stale destination").unwrap(); | ||
| let backend = FakeBackend { | ||
| detect: Support::Supported, | ||
| apply_error: None, | ||
| }; | ||
| let out = copy_file_with(&backend, &src, &dest).unwrap(); | ||
| assert_eq!(out, CopyOutcome::CopiedPlain { skipped: None }); | ||
| assert_eq!( | ||
| std::fs::read(&dest).unwrap(), | ||
| content, | ||
| "destination replaced" | ||
| ); | ||
| std::fs::remove_dir_all(&dir).ok(); | ||
| } | ||
| #[test] | ||
| fn copy_file_lands_plain_and_reports_the_skip_when_recompression_fails() { | ||
| struct SkippingFake; | ||
| impl Backend for SkippingFake { | ||
| fn detect(&self, _path: &Path) -> Result<Support, Error> { | ||
| Ok(Support::Supported) | ||
| } | ||
| fn is_already_compressed(&self, _path: &Path) -> Result<bool, Error> { | ||
| Ok(true) | ||
| } | ||
| fn apply_inplace(&self, _path: &Path, _snapshot: &[u8]) -> Result<(), Error> { | ||
| Ok(()) | ||
| } | ||
| fn apply_bytes( | ||
| &self, | ||
| _path: &Path, | ||
| _content: &[u8], | ||
| _mode: Option<std::fs::Permissions>, | ||
| ) -> Result<(), Error> { | ||
| Err(Error::Io { | ||
| context: "fake apply", | ||
| source: std::io::Error::from(std::io::ErrorKind::PermissionDenied), | ||
| }) | ||
| } | ||
| fn compressed_on_disk(&self, _path: &Path) -> Result<Option<bool>, Error> { | ||
| Ok(Some(false)) | ||
| } | ||
| } | ||
| let dir = scratch("copy-skip"); | ||
| let src = dir.join("src.node"); | ||
| let dest = dir.join("dest.node"); | ||
| let content = fake_addon(); | ||
| std::fs::write(&src, &content).unwrap(); | ||
| let out = copy_file_with(&SkippingFake, &src, &dest).unwrap(); | ||
| assert!( | ||
| matches!(out, CopyOutcome::CopiedPlain { skipped: Some(_) }), | ||
| "got {out:?}" | ||
| ); | ||
| assert_eq!(std::fs::read(&dest).unwrap(), content, "bytes still landed"); | ||
| std::fs::remove_dir_all(&dir).ok(); | ||
| } | ||
| #[test] | ||
| fn copy_file_onto_itself_is_a_noop_reported_as_cloned() { | ||
| let dir = scratch("copy-self"); | ||
| let src = dir.join("src.node"); | ||
| let content = fake_addon(); | ||
| std::fs::write(&src, &content).unwrap(); | ||
| let backend = FakeBackend { | ||
| detect: Support::Supported, | ||
| apply_error: None, | ||
| }; | ||
| let out = copy_file_with(&backend, &src, &src).unwrap(); | ||
| assert!(matches!(out, CopyOutcome::Cloned { .. }), "got {out:?}"); | ||
| assert_eq!(std::fs::read(&src).unwrap(), content, "source untouched"); | ||
| std::fs::remove_dir_all(&dir).ok(); | ||
| } | ||
| #[test] | ||
| fn is_same_file_sees_hardlinks_and_distinct_files() { | ||
| let dir = scratch("same-file"); | ||
| let a = dir.join("a.node"); | ||
| let b = dir.join("b.node"); | ||
| std::fs::write(&a, b"bytes").unwrap(); | ||
| std::fs::write(&b, b"bytes").unwrap(); | ||
| assert!(is_same_file(&a, &a), "identical path"); | ||
| assert!(!is_same_file(&a, &b), "distinct files"); | ||
| let link = dir.join("a-link.node"); | ||
| std::fs::hard_link(&a, &link).unwrap(); | ||
| assert!(is_same_file(&a, &link), "hardlink shares the inode"); | ||
| assert!( | ||
| !is_same_file(&a, &dir.join("absent.node")), | ||
| "a missing path is never the same file" | ||
| ); | ||
| std::fs::remove_dir_all(&dir).ok(); | ||
| } | ||
| #[test] | ||
| fn copy_file_onto_a_hardlink_is_a_noop_reported_as_cloned() { | ||
| let dir = scratch("copy-hardlink"); | ||
| let src = dir.join("src.node"); | ||
| let dest = dir.join("dest.node"); | ||
| let content = fake_addon(); | ||
| std::fs::write(&src, &content).unwrap(); | ||
| std::fs::hard_link(&src, &dest).unwrap(); | ||
| let out = copy_file(&src, &dest).unwrap(); | ||
| assert!(matches!(out, CopyOutcome::Cloned { .. }), "got {out:?}"); | ||
| assert_eq!(std::fs::read(&src).unwrap(), content, "source untouched"); | ||
| assert_eq!(std::fs::read(&dest).unwrap(), content, "hardlink untouched"); | ||
| std::fs::remove_dir_all(&dir).ok(); | ||
| } | ||
| #[test] | ||
| fn copy_file_errors_when_the_destination_cannot_be_replaced() { | ||
| let dir = scratch("copy-dest-dir"); | ||
| let src = dir.join("src.node"); | ||
| std::fs::write(&src, fake_addon()).unwrap(); | ||
| // A directory at `dest` makes the replace step's remove_file fail. | ||
| let dest = dir.join("dest.node"); | ||
| std::fs::create_dir(&dest).unwrap(); | ||
| let backend = FakeBackend { | ||
| detect: Support::Supported, | ||
| apply_error: None, | ||
| }; | ||
| let out = copy_file_with(&backend, &src, &dest); | ||
| assert!( | ||
| matches!( | ||
| out, | ||
| Err(Error::Io { | ||
| context: "replace existing destination", | ||
| .. | ||
| }) | ||
| ), | ||
| "got {out:?}" | ||
| ); | ||
| std::fs::remove_dir_all(&dir).ok(); | ||
| } | ||
| #[cfg(unix)] | ||
| #[test] | ||
| fn copy_file_errors_when_the_source_is_unreadable() { | ||
| use std::os::unix::fs::PermissionsExt; | ||
| let dir = scratch("copy-unreadable"); | ||
| let src = dir.join("src.node"); | ||
| let dest = dir.join("dest.node"); | ||
| std::fs::write(&src, fake_addon()).unwrap(); | ||
| std::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o000)).unwrap(); | ||
| let backend = FakeBackend { | ||
| detect: Support::Supported, | ||
| apply_error: None, | ||
| }; | ||
| let out = copy_file_with(&backend, &src, &dest); | ||
| std::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o644)).ok(); | ||
| assert!( | ||
| matches!( | ||
| out, | ||
| Err(Error::Io { | ||
| context: "read copy source", | ||
| .. | ||
| }) | ||
| ), | ||
| "got {out:?}" | ||
| ); | ||
| std::fs::remove_dir_all(&dir).ok(); | ||
| } | ||
| #[test] | ||
| fn try_clone_file_errors_when_the_source_is_missing() { | ||
| let dir = scratch("clone-missing"); | ||
| let out = try_clone_file(&dir.join("absent.node"), &dir.join("dest.node")); | ||
| assert!(matches!(out, Err(Error::NotFound(_)))); | ||
| std::fs::remove_dir_all(&dir).ok(); | ||
| } | ||
| #[cfg(target_os = "macos")] | ||
| #[test] | ||
| fn try_clone_file_clones_on_apfs_and_declines_an_existing_destination() { | ||
| let dir = scratch("clone-try"); | ||
| let src = dir.join("src.node"); | ||
| let dest = dir.join("dest.node"); | ||
| std::fs::write(&src, fake_addon()).unwrap(); | ||
| assert!(try_clone_file(&src, &dest).unwrap(), "fresh clone"); | ||
| // clonefile refuses an existing destination — reported as cannot-clone, | ||
| // never an error. | ||
| assert!(!try_clone_file(&src, &dest).unwrap()); | ||
| std::fs::remove_dir_all(&dir).ok(); | ||
| } | ||
| #[cfg(target_os = "macos")] | ||
| #[test] | ||
| fn copy_file_clones_a_compressed_source_on_apfs() { | ||
| let dir = scratch("copy-clone"); | ||
| let src = dir.join("src.node"); | ||
| let dest = dir.join("dest.node"); | ||
| let content = fake_addon(); | ||
| let wrote = compress_bytes(&src, &content, &Gate::any()).unwrap(); | ||
| // Only meaningful when the scratch volume actually compressed the source. | ||
| if !matches!(wrote, Outcome::Compressed { .. }) { | ||
| std::fs::remove_dir_all(&dir).ok(); | ||
| return; | ||
| } | ||
| let out = copy_file(&src, &dest).unwrap(); | ||
| assert_eq!(out, CopyOutcome::Cloned { compressed: true }); | ||
| assert!(backend::is_already_compressed(&dest).unwrap()); | ||
| assert_eq!( | ||
| std::fs::read(&dest).unwrap(), | ||
| content, | ||
| "bytes are identical" | ||
| ); | ||
| std::fs::remove_dir_all(&dir).ok(); | ||
| } | ||
| } |
+157
-9
@@ -89,2 +89,6 @@ //! Linux backend — btrfs (Stage 1); bcachefs later. ext4/xfs/ZFS cannot be done. | ||
| pub(crate) fn prepare_stream(file: &std::fs::File) -> Result<(), Error> { | ||
| request_codec(file.as_raw_fd()) | ||
| } | ||
| pub(crate) fn detect(path: &Path) -> Result<Support, Error> { | ||
@@ -174,11 +178,28 @@ if statfs_type(path)? == BTRFS_SUPER_MAGIC { | ||
| pub(crate) fn apply_inplace(path: &Path) -> Result<(), Error> { | ||
| let data = std::fs::read(path).map_err(|source| Error::Io { | ||
| context: "read", | ||
| source, | ||
| })?; | ||
| pub(crate) fn apply_inplace(path: &Path, snapshot: &[u8]) -> Result<(), Error> { | ||
| // `snapshot` is the file's bytes the caller already read for rollback — reuse | ||
| // it instead of a second full read. | ||
| let mode = std::fs::metadata(path).map(|m| m.permissions()).ok(); | ||
| apply_bytes(path, &data, mode) | ||
| apply_bytes(path, snapshot, mode) | ||
| } | ||
| /// A collision-resistant sibling temp path for the atomic write. PID + | ||
| /// wall-clock nanos + a process-local counter means two threads/processes | ||
| /// compressing the SAME destination never derive the same temp name (a PID-only | ||
| /// name with create+truncate would interleave their writes, or silently adopt a | ||
| /// crashed run's stale temp). Paired with `create_new`, a collision is detected | ||
| /// rather than truncated through. Mirrors the macOS backend's scheme. | ||
| fn unique_tmp(dir: &Path, name: &str) -> std::path::PathBuf { | ||
| static TMP_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); | ||
| let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); | ||
| let nanos = std::time::SystemTime::now() | ||
| .duration_since(std::time::UNIX_EPOCH) | ||
| .map(|d| d.as_nanos()) | ||
| .unwrap_or(0); | ||
| dir.join(format!( | ||
| ".{name}.decmpfs-{}-{nanos}-{seq}.tmp", | ||
| std::process::id() | ||
| )) | ||
| } | ||
| /// Write `content` to `path` as a fresh btrfs-compressed file in ONE pass: create | ||
@@ -204,3 +225,3 @@ /// the sibling temp, request the codec on the EMPTY file (so the bytes compress as | ||
| ); | ||
| let tmp = dir.join(format!(".{name}.decmpfs-{}.tmp", std::process::id())); | ||
| let tmp = unique_tmp(dir, &name); | ||
@@ -211,4 +232,3 @@ let result = (|| { | ||
| .write(true) | ||
| .create(true) | ||
| .truncate(true) | ||
| .create_new(true) | ||
| .open(&tmp) | ||
@@ -237,2 +257,11 @@ .map_err(|source| Error::Io { | ||
| } | ||
| // Preserve ownership across the rewrite. As root (a global npm install, a | ||
| // Docker build) the temp is owned by the current euid, so the rename would | ||
| // otherwise change the file's owner. Match the original's uid/gid. | ||
| // Best-effort: a no-op for a new path and for a non-root process (chown to | ||
| // another owner is EPERM — but then the file was already ours). | ||
| if let Ok(meta) = std::fs::metadata(path) { | ||
| use std::os::unix::fs::MetadataExt; | ||
| let _ = std::os::unix::fs::chown(&tmp, Some(meta.uid()), Some(meta.gid())); | ||
| } | ||
| } | ||
@@ -254,1 +283,120 @@ | ||
| } | ||
| /// Copy-on-write clone via the `FICLONE` ioctl (btrfs / XFS reflink) — shares | ||
| /// the extents AND their compression property, so a compressed source stays | ||
| /// compressed at zero cost. `Ok(false)` means "cannot clone here" | ||
| /// (cross-device, non-reflink FS, …) and the caller falls back to a byte copy; | ||
| /// a failed clone removes the empty destination it created. | ||
| pub(crate) fn clone_file(src: &Path, dest: &Path) -> Result<bool, Error> { | ||
| use std::os::fd::AsRawFd; | ||
| let src_file = match std::fs::File::open(src) { | ||
| Ok(file) => file, | ||
| Err(_) => return Ok(false), | ||
| }; | ||
| let dest_file = match std::fs::File::create(dest) { | ||
| Ok(file) => file, | ||
| Err(_) => return Ok(false), | ||
| }; | ||
| // FICLONE = _IOW(0x94, 9, int) — stable since Linux 4.5. | ||
| const FICLONE: libc::c_ulong = 0x4004_9409; | ||
| let cloned = unsafe { libc::ioctl(dest_file.as_raw_fd(), FICLONE, src_file.as_raw_fd()) } == 0; | ||
| if !cloned { | ||
| drop(dest_file); | ||
| let _ = std::fs::remove_file(dest); | ||
| } | ||
| Ok(cloned) | ||
| } | ||
| #[cfg(test)] | ||
| #[cfg_attr(coverage_nightly, coverage(off))] | ||
| mod tests { | ||
| use super::*; | ||
| fn scratch(tag: &str) -> std::path::PathBuf { | ||
| let dir = std::env::temp_dir().join(format!("decmpfs-linux-{tag}-{}", std::process::id())); | ||
| std::fs::create_dir_all(&dir).unwrap(); | ||
| dir | ||
| } | ||
| // btrfs is required to exercise apply_bytes / is_already_compressed; ext4/tmpfs | ||
| // CI runners report Unsupported and the compression path is never reached. | ||
| fn on_btrfs(dir: &Path) -> bool { | ||
| let probe = dir.join(".btrfs-probe"); | ||
| std::fs::write(&probe, b"x").unwrap(); | ||
| let yes = matches!(detect(&probe), Ok(Support::Supported)); | ||
| std::fs::remove_file(&probe).ok(); | ||
| yes | ||
| } | ||
| #[test] | ||
| fn unique_tmp_never_collides_and_is_well_formed() { | ||
| let dir = Path::new("/tmp"); | ||
| let a = unique_tmp(dir, "addon.node"); | ||
| let b = unique_tmp(dir, "addon.node"); | ||
| assert_ne!(a, b, "successive temps must differ (the seq counter)"); | ||
| for p in [&a, &b] { | ||
| let f = p.file_name().unwrap().to_string_lossy(); | ||
| assert!( | ||
| f.starts_with(".addon.node.decmpfs-"), | ||
| "unexpected temp name: {f}" | ||
| ); | ||
| assert!(f.ends_with(".tmp"), "unexpected temp name: {f}"); | ||
| assert_eq!(p.parent().unwrap(), dir); | ||
| } | ||
| } | ||
| #[test] | ||
| fn detect_is_ok_on_a_regular_temp_path() { | ||
| // btrfs → Supported, ext4/tmpfs → Unsupported; only a real I/O error Errs. | ||
| assert!(detect(&std::env::temp_dir()).is_ok()); | ||
| } | ||
| #[test] | ||
| fn a_fresh_plain_file_is_not_already_compressed() { | ||
| let dir = scratch("iac"); | ||
| let path = dir.join("f"); | ||
| std::fs::write(&path, b"plain").unwrap(); | ||
| assert!(!is_already_compressed(&path).unwrap_or(false)); | ||
| std::fs::remove_dir_all(&dir).ok(); | ||
| } | ||
| #[test] | ||
| fn clone_file_declines_a_missing_source() { | ||
| let dir = scratch("clone"); | ||
| assert!(!clone_file(&dir.join("missing"), &dir.join("dest")).unwrap()); | ||
| assert!(!dir.join("dest").exists(), "the empty dest is cleaned up"); | ||
| std::fs::remove_dir_all(&dir).ok(); | ||
| } | ||
| #[test] | ||
| fn apply_bytes_round_trips_and_concurrent_writers_converge_on_btrfs() { | ||
| let dir = scratch("rt"); | ||
| if !on_btrfs(&dir) { | ||
| std::fs::remove_dir_all(&dir).ok(); | ||
| return; | ||
| } | ||
| let content = vec![0xEEu8; 128 * 1024]; | ||
| let dest = dir.join("addon.node"); | ||
| // Eight writers racing on the SAME destination must all converge to identical | ||
| // bytes — the create_new + unique-temp scheme prevents interleaved writes. | ||
| std::thread::scope(|s| { | ||
| for _ in 0..8 { | ||
| let dest = dest.clone(); | ||
| let content = content.clone(); | ||
| s.spawn(move || { | ||
| let _ = apply_bytes(&dest, &content, None); | ||
| }); | ||
| } | ||
| }); | ||
| assert_eq!( | ||
| std::fs::read(&dest).unwrap(), | ||
| content, | ||
| "bytes survive the race" | ||
| ); | ||
| assert!( | ||
| is_already_compressed(&dest).unwrap(), | ||
| "landed btrfs-compressed" | ||
| ); | ||
| std::fs::remove_dir_all(&dir).ok(); | ||
| } | ||
| } |
+1251
-95
| //! macOS backend — APFS/HFS+ decmpfs transparent compression. | ||
| //! | ||
| //! decmpfs is an undocumented kernel ABI; afsctool and `ditto --hfsCompression` are | ||
| //! the references. We write the LZVN resource-fork variant (compression type 8): the | ||
| //! kernel decompresses on read(), so the file keeps its logical size + stays a | ||
| //! loadable native binary. LZVN comes from the system libcompression framework — no | ||
| //! Rust codec dep, so the macOS stub stays tiny. | ||
| //! the references. We write the LZVN (type 8) and LZFSE (type 12) resource-fork | ||
| //! variants: the kernel decompresses on read(), so the file keeps its logical size | ||
| //! and stays a loadable native binary. Both codecs come from the system | ||
| //! libcompression library, so the macOS stub gains no Rust codec dependency. | ||
| //! | ||
| //! Codec choice is LZVN, not LZFSE (type 12) or ZLIB (type 4): for a load-once | ||
| //! `.node` we favor decode speed over ratio, and LZVN is the fastest of the three | ||
| //! to decompress (LZFSE wins ~10-15% on size but decodes slower). LZVN has shipped | ||
| //! in libcompression since 10.11, so there's no availability fallback to make — the | ||
| //! only fallback path is the ephemeral cache when decmpfs can't be applied at all. | ||
| //! Common `.node` files take the speed-first LZVN path, with LZFSE as a no-gain | ||
| //! fallback. Large assets stream ratio-first LZFSE blocks directly into the named | ||
| //! resource fork, avoiding both a multi-gigabyte output allocation and `setxattr`'s | ||
| //! `E2BIG` ceiling. LZVN is the last fallback there for unusual codec-specific data. | ||
| //! | ||
| //! Layout written (verified by the kernel-roundtrip test): | ||
| //! xattr com.apple.decmpfs = [magic u32 LE][type=8 LZVN u32 LE][rawSize u64 LE] | ||
| //! xattr com.apple.ResourceFork = [(numBlocks+1) u32 LE offset table][LZVN blocks] | ||
| //! Built on a sibling temp (empty data fork + those xattrs + UF_COMPRESSED), then | ||
| //! atomically renamed over the original — never an in-place truncate, so a crash | ||
| //! can't leave a 0-byte file. | ||
| //! xattr com.apple.decmpfs = [magic u32 LE][type=8/12 u32 LE][rawSize u64 LE] | ||
| //! xattr com.apple.ResourceFork = [(numBlocks+1) u32 LE offsets][codec blocks] | ||
| //! A winning fork is built on an empty sibling temp with those xattrs and | ||
| //! UF_COMPRESSED; an expanding fork becomes an ordinary sibling data fork. Either | ||
| //! form is atomically renamed over the original — never an in-place truncate, so | ||
| //! a crash can't leave a 0-byte file. | ||
@@ -29,15 +29,52 @@ use std::os::fd::AsRawFd; | ||
| const DECMPFS_MAGIC: u32 = 0x636d_7066; // 'cmpf' (XNU sys/decmpfs.h); LE on disk = "fpmc" | ||
| // Type 8 = LZVN-in-resource-fork — what `ditto --hfsCompression` writes and the | ||
| // kernel reliably reads. The resource fork is a flat offset table (NOT the zlib | ||
| // type-4 resource-fork-with-map format). | ||
| const CMP_LZVN_RESOURCE_FORK: u32 = 8; | ||
| const BLOCK: usize = 0x1_0000; // 64 KiB | ||
| const XATTR_NOFOLLOW: libc::c_int = 0x0001; | ||
| // decmpfs resource-fork offsets cap at u32; stay well under 4 GiB so the offsets | ||
| // and the kernel's own checks never overflow. | ||
| const MAX_RAW: u64 = 3_900_000_000; | ||
| const COMPRESSION_LZVN: i32 = 0x900; | ||
| const COMPRESSION_LZFSE: i32 = 0x801; | ||
| // 2026-07-16 — The data supports keeping a 64 MiB in-memory fast path for now. | ||
| // The sampled Darwin ARM64 Vite ecosystem topped out at SWC 36.563 MiB, followed | ||
| // by Rolldown 15.6–17.9 MiB, Oxlint 14.4 MiB, Lightning CSS 8.1 MiB, and Oxc | ||
| // bindings at 1.9–5.7 MiB. Keeping <=64 MiB on parallel LZVN + one `setxattr` | ||
| // leaves 27 MiB of headroom above that observed upper tail; larger assets stream | ||
| // to `..namedfork/rsrc` so peak output memory stays bounded. Re-benchmark this | ||
| // boundary as native-addon sizes or the streaming implementation changes. | ||
| pub(crate) const STREAMING_THRESHOLD: usize = 64 * 1024 * 1024; | ||
| fn should_stream_resource_fork(raw_len: usize, threshold: usize) -> bool { | ||
| raw_len > threshold | ||
| } | ||
| #[derive(Clone, Copy, Debug, PartialEq, Eq)] | ||
| enum Codec { | ||
| Lzvn, | ||
| Lzfse, | ||
| } | ||
| impl Codec { | ||
| const fn compression_type(self) -> u32 { | ||
| match self { | ||
| Self::Lzvn => 8, | ||
| Self::Lzfse => 12, | ||
| } | ||
| } | ||
| const fn algorithm(self) -> i32 { | ||
| match self { | ||
| Self::Lzvn => COMPRESSION_LZVN, | ||
| Self::Lzfse => COMPRESSION_LZFSE, | ||
| } | ||
| } | ||
| } | ||
| #[link(name = "compression")] | ||
| extern "C" { | ||
| fn compression_decode_buffer( | ||
| dst_buffer: *mut u8, | ||
| dst_size: usize, | ||
| src_buffer: *const u8, | ||
| src_size: usize, | ||
| scratch_buffer: *mut u8, | ||
| algorithm: i32, | ||
| ) -> usize; | ||
| fn compression_encode_buffer( | ||
@@ -54,13 +91,7 @@ dst_buffer: *mut u8, | ||
| /// Reject files past the decmpfs u32-offset ceiling (→ Skipped(TooLarge) via | ||
| /// safety::classify_skip on EFBIG). Pure, so the limit is testable without a 4 GiB | ||
| /// file. | ||
| fn within_decmpfs_limit(len: u64) -> Result<(), Error> { | ||
| if len > MAX_RAW { | ||
| return Err(Error::Io { | ||
| context: "file too large for decmpfs", | ||
| source: std::io::Error::from_raw_os_error(libc::EFBIG), | ||
| }); | ||
| fn resource_fork_too_large() -> Error { | ||
| Error::Io { | ||
| context: "decmpfs resource fork exceeds u32 offsets", | ||
| source: std::io::Error::from_raw_os_error(libc::EFBIG), | ||
| } | ||
| Ok(()) | ||
| } | ||
@@ -126,7 +157,6 @@ | ||
| /// LZVN-encode `src` into a kernel-decodable block. libcompression emits a valid | ||
| /// Encode `src` into one kernel-decodable block. libcompression emits a valid | ||
| /// frame even for incompressible input (slightly larger than `src`), so every | ||
| /// block decodes the same way — there is no bare "stored" block. Returns `None` | ||
| /// only if encoding fails outright (treated as a hard error upstream). | ||
| fn compress_block(src: &[u8], scratch: &mut [u8]) -> Option<Vec<u8>> { | ||
| /// block decodes the same way. `None` means the codec declined outright. | ||
| fn compress_block_with_codec(src: &[u8], scratch: &mut [u8], codec: Codec) -> Option<Vec<u8>> { | ||
| // Headroom for the worst case (incompressible data expands a little). | ||
@@ -141,3 +171,3 @@ let mut dst = vec![0u8; src.len() + src.len() / 16 + 1024]; | ||
| scratch.as_mut_ptr(), | ||
| COMPRESSION_LZVN, | ||
| codec.algorithm(), | ||
| ) | ||
@@ -152,22 +182,128 @@ }; | ||
| // The compatibility wrapper keeps the focused LZVN unit tests terse. | ||
| #[cfg(test)] | ||
| fn compress_block(src: &[u8], scratch: &mut [u8]) -> Option<Vec<u8>> { | ||
| compress_block_with_codec(src, scratch, Codec::Lzvn) | ||
| } | ||
| #[derive(Debug, PartialEq, Eq)] | ||
| enum ResourceForkPlan { | ||
| /// The fork would not make the file smaller. Keep the ordinary data fork. | ||
| Plain, | ||
| /// The encoded fork is smaller and every offset fits the on-disk u32 table. | ||
| Compressed { table_len: usize, total_len: usize }, | ||
| } | ||
| fn resource_fork_table_len(num_blocks: usize) -> Result<usize, Error> { | ||
| num_blocks | ||
| .checked_add(1) | ||
| .and_then(|entries| entries.checked_mul(std::mem::size_of::<u32>())) | ||
| .ok_or_else(resource_fork_too_large) | ||
| } | ||
| /// Decide from lengths alone whether the fork is useful and representable. The | ||
| /// raw length in `com.apple.decmpfs` is u64; only offsets inside the resource | ||
| /// fork are u32. This allows a raw file beyond the old 3.9 GB cutoff whenever its | ||
| /// encoded fork is smaller than both the raw file and `u32::MAX`. | ||
| fn plan_resource_fork( | ||
| raw_len: usize, | ||
| num_blocks: usize, | ||
| encoded_len: usize, | ||
| ) -> Result<ResourceForkPlan, Error> { | ||
| let table_len = resource_fork_table_len(num_blocks)?; | ||
| let total_len = table_len | ||
| .checked_add(encoded_len) | ||
| .ok_or_else(resource_fork_too_large)?; | ||
| if total_len >= raw_len { | ||
| return Ok(ResourceForkPlan::Plain); | ||
| } | ||
| if total_len > u32::MAX as usize { | ||
| return Err(resource_fork_too_large()); | ||
| } | ||
| Ok(ResourceForkPlan::Compressed { | ||
| table_len, | ||
| total_len, | ||
| }) | ||
| } | ||
| fn compress_blocks(raw: &[u8], codec: Codec) -> Option<Vec<Vec<u8>>> { | ||
| let num_blocks = raw.len().div_ceil(BLOCK).max(1); | ||
| let scratch_len = unsafe { compression_encode_scratch_buffer_size(codec.algorithm()) }; | ||
| // The 64 KiB blocks are independent, so fan them across cores. Each worker | ||
| // owns its libcompression scratch buffer. Contiguous regions keep the output | ||
| // in block order without a sort. | ||
| let workers = if std::env::var_os("DECMPFS_SERIAL").is_some() { | ||
| 1 | ||
| } else { | ||
| std::thread::available_parallelism() | ||
| .map(|n| n.get()) | ||
| .unwrap_or(1) | ||
| .min(num_blocks) | ||
| }; | ||
| if workers <= 1 || num_blocks < 8 { | ||
| let mut scratch = vec![0u8; scratch_len]; | ||
| return raw | ||
| .chunks(BLOCK) | ||
| .map(|chunk| compress_block_with_codec(chunk, &mut scratch, codec)) | ||
| .collect(); | ||
| } | ||
| let bytes_per_worker = num_blocks.div_ceil(workers) * BLOCK; | ||
| let parts: Vec<Option<Vec<Vec<u8>>>> = std::thread::scope(|scope| { | ||
| let handles: Vec<_> = raw | ||
| .chunks(bytes_per_worker) | ||
| .map(|region| { | ||
| scope.spawn(move || { | ||
| let mut scratch = vec![0u8; scratch_len]; | ||
| region | ||
| .chunks(BLOCK) | ||
| .map(|chunk| compress_block_with_codec(chunk, &mut scratch, codec)) | ||
| .collect::<Option<Vec<Vec<u8>>>>() | ||
| }) | ||
| }) | ||
| .collect(); | ||
| handles | ||
| .into_iter() | ||
| .map(|handle| handle.join().ok().flatten()) | ||
| .collect() | ||
| }); | ||
| let mut out = Vec::with_capacity(num_blocks); | ||
| for part in parts { | ||
| out.extend(part?); | ||
| } | ||
| Some(out) | ||
| } | ||
| /// Build the com.apple.ResourceFork blob for `raw` in the LZVN/LZFSE decmpfs | ||
| /// layout (what `ditto` writes): `(numBlocks+1)` u32 LE offsets, then the blocks. | ||
| /// `offset[0]` = table size; `offset[i+1]` = end of block i; last = total size. | ||
| fn build_resource_fork(raw: &[u8]) -> Option<Vec<u8>> { | ||
| /// `Ok(None)` means this codec did not shrink the input. | ||
| fn build_resource_fork_with_codec(raw: &[u8], codec: Codec) -> Result<Option<Vec<u8>>, Error> { | ||
| let num_blocks = raw.len().div_ceil(BLOCK).max(1); | ||
| let scratch_len = unsafe { compression_encode_scratch_buffer_size(COMPRESSION_LZVN) }; | ||
| let mut scratch = vec![0u8; scratch_len]; | ||
| let Some(blocks) = compress_blocks(raw, codec) else { | ||
| return Ok(None); | ||
| }; | ||
| let blocks = raw | ||
| .chunks(BLOCK) | ||
| .map(|chunk| compress_block(chunk, &mut scratch)) | ||
| .collect::<Option<Vec<Vec<u8>>>>()?; | ||
| let encoded_len = blocks | ||
| .iter() | ||
| .try_fold(0usize, |sum, block| sum.checked_add(block.len())) | ||
| .ok_or_else(resource_fork_too_large)?; | ||
| let ResourceForkPlan::Compressed { | ||
| table_len, | ||
| total_len, | ||
| } = plan_resource_fork(raw.len(), num_blocks, encoded_len)? | ||
| else { | ||
| return Ok(None); | ||
| }; | ||
| let table_len = (num_blocks + 1) * 4; | ||
| let mut out = Vec::with_capacity(table_len + blocks.iter().map(Vec::len).sum::<usize>()); | ||
| let mut out = Vec::with_capacity(total_len); | ||
| // Offset table: numBlocks+1 entries. offset[i] is where block i starts. | ||
| let mut offset = table_len as u32; | ||
| let mut offset = u32::try_from(table_len).map_err(|_| resource_fork_too_large())?; | ||
| out.extend_from_slice(&offset.to_le_bytes()); | ||
| for block in &blocks { | ||
| offset += block.len() as u32; | ||
| offset = offset | ||
| .checked_add(u32::try_from(block.len()).map_err(|_| resource_fork_too_large())?) | ||
| .ok_or_else(resource_fork_too_large)?; | ||
| out.extend_from_slice(&offset.to_le_bytes()); | ||
@@ -178,5 +314,664 @@ } | ||
| } | ||
| Some(out) | ||
| debug_assert_eq!(out.len(), total_len); | ||
| Ok(Some(out)) | ||
| } | ||
| /// The existing speed-first LZVN builder, retained as a focused test seam. | ||
| #[cfg(test)] | ||
| fn build_resource_fork(raw: &[u8]) -> Result<Option<Vec<u8>>, Error> { | ||
| build_resource_fork_with_codec(raw, Codec::Lzvn) | ||
| } | ||
| struct InMemoryResourceFork { | ||
| codec: Codec, | ||
| bytes: Vec<u8>, | ||
| } | ||
| /// Common addons prefer LZVN decode speed. Only a no-gain LZVN attempt pays for | ||
| /// the stronger LZFSE pass. | ||
| fn build_in_memory_resource_fork(raw: &[u8]) -> Result<Option<InMemoryResourceFork>, Error> { | ||
| for codec in [Codec::Lzvn, Codec::Lzfse] { | ||
| if let Some(bytes) = build_resource_fork_with_codec(raw, codec)? { | ||
| return Ok(Some(InMemoryResourceFork { codec, bytes })); | ||
| } | ||
| } | ||
| Ok(None) | ||
| } | ||
| /// Stream one codec into the temp file's named resource fork. One single-slot | ||
| /// channel per worker keeps at most one encoded block per core waiting for the | ||
| /// ordered writer, so output memory is bounded while libcompression stays | ||
| /// parallel. `Ok(false)` means no gain, an unrepresentable u32 fork, or a codec | ||
| /// decline; the caller may retry the original bytes with another codec. | ||
| fn write_streaming_resource_fork(path: &Path, raw: &[u8], codec: Codec) -> Result<bool, Error> { | ||
| use std::io::{Seek, Write}; | ||
| use std::sync::atomic::{AtomicBool, Ordering}; | ||
| let num_blocks = raw.len().div_ceil(BLOCK).max(1); | ||
| let table_len = resource_fork_table_len(num_blocks)?; | ||
| if table_len >= raw.len() || table_len > u32::MAX as usize { | ||
| return Ok(false); | ||
| } | ||
| let fork_path = path.join("..namedfork").join("rsrc"); | ||
| let mut file = std::fs::OpenOptions::new() | ||
| .write(true) | ||
| .create(true) | ||
| .truncate(true) | ||
| .open(fork_path) | ||
| .map_err(|source| Error::Io { | ||
| context: "open resource fork", | ||
| source, | ||
| })?; | ||
| file.set_len(table_len as u64).map_err(|source| Error::Io { | ||
| context: "reserve resource-fork table", | ||
| source, | ||
| })?; | ||
| file | ||
| .seek(std::io::SeekFrom::Start(table_len as u64)) | ||
| .map_err(|source| Error::Io { | ||
| context: "seek resource-fork payload", | ||
| source, | ||
| })?; | ||
| // Coalesce codec blocks into larger writes. Besides syscall overhead, one | ||
| // write per 64 KiB block makes APFS allocate several extra MiB of extents on | ||
| // a multi-gigabyte fork even when its logical compressed bytes are identical. | ||
| let mut writer = std::io::BufWriter::with_capacity(1 << 20, file); | ||
| let workers = if std::env::var_os("DECMPFS_SERIAL").is_some() { | ||
| 1 | ||
| } else { | ||
| std::thread::available_parallelism() | ||
| .map(|n| n.get()) | ||
| .unwrap_or(1) | ||
| .min(num_blocks) | ||
| }; | ||
| let scratch_len = unsafe { compression_encode_scratch_buffer_size(codec.algorithm()) }; | ||
| let cancelled = AtomicBool::new(false); | ||
| let mut offsets = Vec::with_capacity(num_blocks + 1); | ||
| offsets.push(u32::try_from(table_len).map_err(|_| resource_fork_too_large())?); | ||
| let mut offset = table_len; | ||
| let won = std::thread::scope(|scope| -> Result<bool, Error> { | ||
| let mut receivers = Vec::with_capacity(workers); | ||
| for worker in 0..workers { | ||
| let (sender, receiver) = std::sync::mpsc::sync_channel(1); | ||
| receivers.push(receiver); | ||
| let cancelled = &cancelled; | ||
| scope.spawn(move || { | ||
| let mut scratch = vec![0u8; scratch_len]; | ||
| let mut block_index = worker; | ||
| while block_index < num_blocks && !cancelled.load(Ordering::Relaxed) { | ||
| let start = block_index * BLOCK; | ||
| let end = start.saturating_add(BLOCK).min(raw.len()); | ||
| let encoded = compress_block_with_codec(&raw[start..end], &mut scratch, codec); | ||
| if sender.send(encoded).is_err() { | ||
| break; | ||
| } | ||
| block_index += workers; | ||
| } | ||
| }); | ||
| } | ||
| let result = (|| -> Result<bool, Error> { | ||
| for block_index in 0..num_blocks { | ||
| let Some(block) = receivers[block_index % workers].recv().ok().flatten() else { | ||
| return Ok(false); | ||
| }; | ||
| let Some(next_offset) = offset.checked_add(block.len()) else { | ||
| return Ok(false); | ||
| }; | ||
| // The fork can only grow from here. Stop early rather than writing a | ||
| // multi-gigabyte losing candidate before trying the fallback codec. | ||
| if next_offset >= raw.len() || next_offset > u32::MAX as usize { | ||
| return Ok(false); | ||
| } | ||
| writer.write_all(&block).map_err(|source| Error::Io { | ||
| context: "write resource-fork block", | ||
| source, | ||
| })?; | ||
| offset = next_offset; | ||
| offsets.push(u32::try_from(offset).map_err(|_| resource_fork_too_large())?); | ||
| } | ||
| Ok(true) | ||
| })(); | ||
| cancelled.store(true, Ordering::Relaxed); | ||
| drop(receivers); | ||
| result | ||
| })?; | ||
| if !won { | ||
| return Ok(false); | ||
| } | ||
| debug_assert_eq!(offsets.len(), num_blocks + 1); | ||
| let mut table = Vec::with_capacity(table_len); | ||
| for offset in offsets { | ||
| table.extend_from_slice(&offset.to_le_bytes()); | ||
| } | ||
| debug_assert_eq!(table.len(), table_len); | ||
| writer | ||
| .seek(std::io::SeekFrom::Start(0)) | ||
| .and_then(|_| writer.write_all(&table)) | ||
| .and_then(|_| writer.flush()) | ||
| .map_err(|source| Error::Io { | ||
| context: "finish resource fork", | ||
| source, | ||
| })?; | ||
| writer.get_ref().sync_all().map_err(|source| Error::Io { | ||
| context: "sync resource fork", | ||
| source, | ||
| })?; | ||
| Ok(true) | ||
| } | ||
| /// Large assets favor ratio-first LZFSE so we do not write and discard a huge | ||
| /// LZVN candidate like Gemini's. LZVN remains a codec-specific fallback. | ||
| fn build_streaming_resource_fork(path: &Path, raw: &[u8]) -> Result<Option<Codec>, Error> { | ||
| for codec in [Codec::Lzfse, Codec::Lzvn] { | ||
| if write_streaming_resource_fork(path, raw, codec)? { | ||
| return Ok(Some(codec)); | ||
| } | ||
| } | ||
| Ok(None) | ||
| } | ||
| enum StreamingState { | ||
| Encoding(StreamingEncoding), | ||
| Plain(std::fs::File), | ||
| Closed, | ||
| } | ||
| struct StreamingEncoding { | ||
| file: std::fs::File, | ||
| fork: std::io::BufWriter<std::fs::File>, | ||
| scratch: Vec<u8>, | ||
| partial: Vec<u8>, | ||
| offsets: Vec<u32>, | ||
| encoded_offset: usize, | ||
| } | ||
| /// Incremental macOS writer used by the public streaming API. Raw input is held | ||
| /// only until the current 64 KiB block is complete; winning LZFSE blocks land | ||
| /// directly in the named resource fork. If the fork stops winning, its completed | ||
| /// blocks are decoded into a plain sibling and subsequent input streams there. | ||
| pub(crate) struct StreamingWriter { | ||
| path: std::path::PathBuf, | ||
| expected_len: usize, | ||
| written: usize, | ||
| state: StreamingState, | ||
| complete: bool, | ||
| } | ||
| impl StreamingEncoding { | ||
| fn write_block(&mut self, raw: &[u8], expected_len: usize) -> Result<bool, Error> { | ||
| let Some(encoded) = compress_block_with_codec(raw, &mut self.scratch, Codec::Lzfse) else { | ||
| return Ok(false); | ||
| }; | ||
| // Verify every encoder result while the matching raw block is still in | ||
| // memory. The final kernel oracle then only has to prove the decmpfs layout. | ||
| let mut decoded = vec![0u8; raw.len()]; | ||
| let decoded_len = unsafe { | ||
| compression_decode_buffer( | ||
| decoded.as_mut_ptr(), | ||
| decoded.len(), | ||
| encoded.as_ptr(), | ||
| encoded.len(), | ||
| std::ptr::null_mut(), | ||
| Codec::Lzfse.algorithm(), | ||
| ) | ||
| }; | ||
| if decoded_len != raw.len() || decoded != raw { | ||
| return Ok(false); | ||
| } | ||
| let Some(next_offset) = self.encoded_offset.checked_add(encoded.len()) else { | ||
| return Ok(false); | ||
| }; | ||
| if next_offset >= expected_len || next_offset > u32::MAX as usize { | ||
| return Ok(false); | ||
| } | ||
| use std::io::Write; | ||
| self.fork.write_all(&encoded).map_err(|source| Error::Io { | ||
| context: "write streaming resource-fork block", | ||
| source, | ||
| })?; | ||
| self.encoded_offset = next_offset; | ||
| self | ||
| .offsets | ||
| .push(u32::try_from(next_offset).map_err(|_| resource_fork_too_large())?); | ||
| Ok(true) | ||
| } | ||
| } | ||
| fn streaming_fallback_path(path: &Path) -> std::path::PathBuf { | ||
| static FALLBACK_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); | ||
| let seq = FALLBACK_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); | ||
| let name = path.file_name().map_or_else( | ||
| || std::borrow::Cow::Borrowed("stream"), | ||
| |n| n.to_string_lossy(), | ||
| ); | ||
| path.with_file_name(format!(".{name}.plain-{}-{seq}.tmp", std::process::id())) | ||
| } | ||
| fn decode_streaming_prefix( | ||
| path: &Path, | ||
| encoding: &mut StreamingEncoding, | ||
| current: &[u8], | ||
| expected_len: usize, | ||
| ) -> Result<(std::path::PathBuf, std::fs::File), Error> { | ||
| use std::io::{Read, Seek, Write}; | ||
| encoding.fork.flush().map_err(|source| Error::Io { | ||
| context: "flush streaming resource fork", | ||
| source, | ||
| })?; | ||
| encoding | ||
| .fork | ||
| .get_ref() | ||
| .sync_all() | ||
| .map_err(|source| Error::Io { | ||
| context: "sync streaming resource fork", | ||
| source, | ||
| })?; | ||
| let fork_path = path.join("..namedfork").join("rsrc"); | ||
| let mut fork = std::fs::File::open(fork_path).map_err(|source| Error::Io { | ||
| context: "open streaming resource fork for fallback", | ||
| source, | ||
| })?; | ||
| let fallback = streaming_fallback_path(path); | ||
| let mut plain = std::fs::OpenOptions::new() | ||
| .read(true) | ||
| .write(true) | ||
| .create_new(true) | ||
| .open(&fallback) | ||
| .map_err(|source| Error::Io { | ||
| context: "create streaming plain fallback", | ||
| source, | ||
| })?; | ||
| let decoded = (|| -> Result<(), Error> { | ||
| for (block_index, pair) in encoding.offsets.windows(2).enumerate() { | ||
| let start = pair[0] as u64; | ||
| let encoded_len = (pair[1] - pair[0]) as usize; | ||
| let mut encoded = vec![0u8; encoded_len]; | ||
| fork | ||
| .seek(std::io::SeekFrom::Start(start)) | ||
| .and_then(|_| fork.read_exact(&mut encoded)) | ||
| .map_err(|source| Error::Io { | ||
| context: "read streaming resource fork for fallback", | ||
| source, | ||
| })?; | ||
| let raw_len = expected_len | ||
| .saturating_sub(block_index.saturating_mul(BLOCK)) | ||
| .min(BLOCK); | ||
| let mut raw = vec![0u8; raw_len]; | ||
| let raw_len = unsafe { | ||
| compression_decode_buffer( | ||
| raw.as_mut_ptr(), | ||
| raw.len(), | ||
| encoded.as_ptr(), | ||
| encoded.len(), | ||
| std::ptr::null_mut(), | ||
| Codec::Lzfse.algorithm(), | ||
| ) | ||
| }; | ||
| if raw_len != raw.len() { | ||
| return Err(Error::Io { | ||
| context: "decode streaming resource fork for fallback", | ||
| source: std::io::Error::from(std::io::ErrorKind::InvalidData), | ||
| }); | ||
| } | ||
| plain.write_all(&raw).map_err(|source| Error::Io { | ||
| context: "write streaming plain fallback", | ||
| source, | ||
| })?; | ||
| } | ||
| plain.write_all(current).map_err(|source| Error::Io { | ||
| context: "write current streaming fallback block", | ||
| source, | ||
| }) | ||
| })(); | ||
| if let Err(err) = decoded { | ||
| drop(plain); | ||
| let _ = std::fs::remove_file(&fallback); | ||
| return Err(err); | ||
| } | ||
| Ok((fallback, plain)) | ||
| } | ||
| fn streaming_kernel_matches( | ||
| path: &Path, | ||
| encoding: &StreamingEncoding, | ||
| expected_len: usize, | ||
| ) -> Result<bool, Error> { | ||
| use std::io::{Read, Seek}; | ||
| let mut logical = match std::fs::File::open(path) { | ||
| Ok(file) => file, | ||
| Err(_) => return Ok(false), | ||
| }; | ||
| let fork_path = path.join("..namedfork").join("rsrc"); | ||
| let mut fork = std::fs::File::open(fork_path).map_err(|source| Error::Io { | ||
| context: "open finished streaming resource fork", | ||
| source, | ||
| })?; | ||
| for (block_index, pair) in encoding.offsets.windows(2).enumerate() { | ||
| let encoded_len = (pair[1] - pair[0]) as usize; | ||
| let mut encoded = vec![0u8; encoded_len]; | ||
| fork | ||
| .seek(std::io::SeekFrom::Start(pair[0] as u64)) | ||
| .and_then(|_| fork.read_exact(&mut encoded)) | ||
| .map_err(|source| Error::Io { | ||
| context: "read finished streaming resource fork", | ||
| source, | ||
| })?; | ||
| let raw_len = expected_len | ||
| .saturating_sub(block_index.saturating_mul(BLOCK)) | ||
| .min(BLOCK); | ||
| let mut decoded = vec![0u8; raw_len]; | ||
| let decoded_len = unsafe { | ||
| compression_decode_buffer( | ||
| decoded.as_mut_ptr(), | ||
| decoded.len(), | ||
| encoded.as_ptr(), | ||
| encoded.len(), | ||
| std::ptr::null_mut(), | ||
| Codec::Lzfse.algorithm(), | ||
| ) | ||
| }; | ||
| if decoded_len != raw_len { | ||
| return Ok(false); | ||
| } | ||
| let mut kernel = vec![0u8; raw_len]; | ||
| if logical.read_exact(&mut kernel).is_err() || kernel != decoded { | ||
| return Ok(false); | ||
| } | ||
| } | ||
| let mut extra = [0u8; 1]; | ||
| Ok(logical.read(&mut extra).is_ok_and(|len| len == 0)) | ||
| } | ||
| impl StreamingWriter { | ||
| pub(crate) fn new(path: &Path, expected_len: usize) -> Result<Self, Error> { | ||
| let num_blocks = expected_len.div_ceil(BLOCK).max(1); | ||
| let table_len = resource_fork_table_len(num_blocks)?; | ||
| if expected_len == 0 || table_len >= expected_len || table_len > u32::MAX as usize { | ||
| let file = std::fs::OpenOptions::new() | ||
| .read(true) | ||
| .write(true) | ||
| .create_new(true) | ||
| .open(path) | ||
| .map_err(|source| Error::Io { | ||
| context: "create streaming plain temp", | ||
| source, | ||
| })?; | ||
| return Ok(Self { | ||
| path: path.to_path_buf(), | ||
| expected_len, | ||
| written: 0, | ||
| state: StreamingState::Plain(file), | ||
| complete: false, | ||
| }); | ||
| } | ||
| let file = std::fs::OpenOptions::new() | ||
| .read(true) | ||
| .write(true) | ||
| .create_new(true) | ||
| .open(path) | ||
| .map_err(|source| Error::Io { | ||
| context: "create streaming decmpfs temp", | ||
| source, | ||
| })?; | ||
| let fork_file = (|| -> Result<std::fs::File, Error> { | ||
| let fork_path = path.join("..namedfork").join("rsrc"); | ||
| let mut fork_file = std::fs::OpenOptions::new() | ||
| .read(true) | ||
| .write(true) | ||
| .create(true) | ||
| .truncate(true) | ||
| .open(fork_path) | ||
| .map_err(|source| Error::Io { | ||
| context: "open streaming resource fork", | ||
| source, | ||
| })?; | ||
| use std::io::Seek; | ||
| fork_file | ||
| .set_len(table_len as u64) | ||
| .map_err(|source| Error::Io { | ||
| context: "reserve streaming resource-fork table", | ||
| source, | ||
| })?; | ||
| fork_file | ||
| .seek(std::io::SeekFrom::Start(table_len as u64)) | ||
| .map_err(|source| Error::Io { | ||
| context: "seek streaming resource-fork payload", | ||
| source, | ||
| })?; | ||
| Ok(fork_file) | ||
| })(); | ||
| let fork_file = match fork_file { | ||
| Ok(fork_file) => fork_file, | ||
| Err(error) => { | ||
| drop(file); | ||
| let _ = std::fs::remove_file(path); | ||
| return Err(error); | ||
| } | ||
| }; | ||
| let scratch_len = unsafe { compression_encode_scratch_buffer_size(Codec::Lzfse.algorithm()) }; | ||
| Ok(Self { | ||
| path: path.to_path_buf(), | ||
| expected_len, | ||
| written: 0, | ||
| state: StreamingState::Encoding(StreamingEncoding { | ||
| file, | ||
| fork: std::io::BufWriter::with_capacity(1 << 20, fork_file), | ||
| scratch: vec![0u8; scratch_len], | ||
| partial: Vec::with_capacity(BLOCK), | ||
| offsets: vec![u32::try_from(table_len).map_err(|_| resource_fork_too_large())?], | ||
| encoded_offset: table_len, | ||
| }), | ||
| complete: false, | ||
| }) | ||
| } | ||
| fn switch_to_plain(&mut self, current: &[u8]) -> Result<(), Error> { | ||
| let StreamingState::Encoding(mut encoding) = | ||
| std::mem::replace(&mut self.state, StreamingState::Closed) | ||
| else { | ||
| return Err(Error::Io { | ||
| context: "switch streaming writer to plain", | ||
| source: std::io::Error::from(std::io::ErrorKind::InvalidInput), | ||
| }); | ||
| }; | ||
| let (fallback, mut plain) = | ||
| decode_streaming_prefix(&self.path, &mut encoding, current, self.expected_len)?; | ||
| drop(encoding); | ||
| if let Err(source) = std::fs::remove_file(&self.path) { | ||
| let _ = std::fs::remove_file(&fallback); | ||
| return Err(Error::Io { | ||
| context: "remove streaming decmpfs temp", | ||
| source, | ||
| }); | ||
| } | ||
| if let Err(source) = std::fs::rename(&fallback, &self.path) { | ||
| let _ = std::fs::remove_file(&fallback); | ||
| return Err(Error::Io { | ||
| context: "adopt streaming plain fallback", | ||
| source, | ||
| }); | ||
| } | ||
| use std::io::Seek; | ||
| plain | ||
| .seek(std::io::SeekFrom::End(0)) | ||
| .map_err(|source| Error::Io { | ||
| context: "seek streaming plain fallback", | ||
| source, | ||
| })?; | ||
| self.state = StreamingState::Plain(plain); | ||
| Ok(()) | ||
| } | ||
| pub(crate) fn write_all(&mut self, mut input: &[u8]) -> Result<(), Error> { | ||
| let next_written = self | ||
| .written | ||
| .checked_add(input.len()) | ||
| .filter(|&len| len <= self.expected_len) | ||
| .ok_or_else(|| Error::Io { | ||
| context: "stream exceeds expected length", | ||
| source: std::io::Error::from(std::io::ErrorKind::InvalidData), | ||
| })?; | ||
| while !input.is_empty() { | ||
| match &mut self.state { | ||
| StreamingState::Plain(file) => { | ||
| use std::io::Write; | ||
| file.write_all(input).map_err(|source| Error::Io { | ||
| context: "write streaming plain temp", | ||
| source, | ||
| })?; | ||
| input = &[]; | ||
| } | ||
| StreamingState::Encoding(encoding) => { | ||
| let take = (BLOCK - encoding.partial.len()).min(input.len()); | ||
| encoding.partial.extend_from_slice(&input[..take]); | ||
| input = &input[take..]; | ||
| if encoding.partial.len() == BLOCK { | ||
| let block = std::mem::replace(&mut encoding.partial, Vec::with_capacity(BLOCK)); | ||
| if !encoding.write_block(&block, self.expected_len)? { | ||
| self.switch_to_plain(&block)?; | ||
| } | ||
| } | ||
| } | ||
| StreamingState::Closed => { | ||
| return Err(Error::Io { | ||
| context: "write closed streaming writer", | ||
| source: std::io::Error::from(std::io::ErrorKind::BrokenPipe), | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| self.written = next_written; | ||
| Ok(()) | ||
| } | ||
| pub(crate) fn finish(&mut self) -> Result<bool, Error> { | ||
| if self.written != self.expected_len { | ||
| return Err(Error::Io { | ||
| context: "finish incomplete streaming writer", | ||
| source: std::io::Error::from(std::io::ErrorKind::UnexpectedEof), | ||
| }); | ||
| } | ||
| let partial = match &mut self.state { | ||
| StreamingState::Encoding(encoding) if !encoding.partial.is_empty() => Some( | ||
| std::mem::replace(&mut encoding.partial, Vec::with_capacity(BLOCK)), | ||
| ), | ||
| _ => None, | ||
| }; | ||
| if let Some(block) = partial { | ||
| let won = match &mut self.state { | ||
| StreamingState::Encoding(encoding) => encoding.write_block(&block, self.expected_len)?, | ||
| _ => false, | ||
| }; | ||
| if !won { | ||
| self.switch_to_plain(&block)?; | ||
| } | ||
| } | ||
| let compressed = match std::mem::replace(&mut self.state, StreamingState::Closed) { | ||
| StreamingState::Plain(file) => { | ||
| file.sync_all().map_err(|source| Error::Io { | ||
| context: "sync streaming plain temp", | ||
| source, | ||
| })?; | ||
| false | ||
| } | ||
| StreamingState::Encoding(mut encoding) => { | ||
| use std::io::{Seek, Write}; | ||
| let mut table = Vec::with_capacity(encoding.offsets.len() * std::mem::size_of::<u32>()); | ||
| for offset in &encoding.offsets { | ||
| table.extend_from_slice(&offset.to_le_bytes()); | ||
| } | ||
| encoding | ||
| .fork | ||
| .seek(std::io::SeekFrom::Start(0)) | ||
| .and_then(|_| encoding.fork.write_all(&table)) | ||
| .and_then(|_| encoding.fork.flush()) | ||
| .map_err(|source| Error::Io { | ||
| context: "finish streaming resource fork", | ||
| source, | ||
| })?; | ||
| encoding | ||
| .fork | ||
| .get_ref() | ||
| .sync_all() | ||
| .map_err(|source| Error::Io { | ||
| context: "sync finished streaming resource fork", | ||
| source, | ||
| })?; | ||
| let cpath = cstring(&self.path)?; | ||
| setxattr( | ||
| &cpath, | ||
| c"com.apple.decmpfs", | ||
| &decmpfs_header(Codec::Lzfse, self.expected_len), | ||
| )?; | ||
| if unsafe { libc::fchflags(encoding.file.as_raw_fd(), UF_COMPRESSED) } != 0 { | ||
| return Err(io("fchflags streaming temp")); | ||
| } | ||
| encoding.file.sync_all().map_err(|source| Error::Io { | ||
| context: "sync streaming decmpfs temp", | ||
| source, | ||
| })?; | ||
| if streaming_kernel_matches(&self.path, &encoding, self.expected_len)? { | ||
| true | ||
| } else { | ||
| let (fallback, plain) = | ||
| decode_streaming_prefix(&self.path, &mut encoding, &[], self.expected_len)?; | ||
| drop(encoding); | ||
| std::fs::remove_file(&self.path).map_err(|source| Error::Io { | ||
| context: "remove failed streaming decmpfs oracle", | ||
| source, | ||
| })?; | ||
| if let Err(source) = std::fs::rename(&fallback, &self.path) { | ||
| let _ = std::fs::remove_file(&fallback); | ||
| return Err(Error::Io { | ||
| context: "publish streaming oracle fallback", | ||
| source, | ||
| }); | ||
| } | ||
| plain.sync_all().map_err(|source| Error::Io { | ||
| context: "sync streaming oracle fallback", | ||
| source, | ||
| })?; | ||
| false | ||
| } | ||
| } | ||
| StreamingState::Closed => { | ||
| return Err(Error::Io { | ||
| context: "finish closed streaming writer", | ||
| source: std::io::Error::from(std::io::ErrorKind::BrokenPipe), | ||
| }); | ||
| } | ||
| }; | ||
| self.complete = true; | ||
| Ok(compressed) | ||
| } | ||
| } | ||
| impl Drop for StreamingWriter { | ||
| fn drop(&mut self) { | ||
| if !self.complete { | ||
| self.state = StreamingState::Closed; | ||
| let _ = std::fs::remove_file(&self.path); | ||
| } | ||
| } | ||
| } | ||
| fn decmpfs_header(codec: Codec, raw_len: usize) -> [u8; 16] { | ||
| let mut header = [0u8; 16]; | ||
| header[..4].copy_from_slice(&DECMPFS_MAGIC.to_le_bytes()); | ||
| header[4..8].copy_from_slice(&codec.compression_type().to_le_bytes()); | ||
| header[8..].copy_from_slice(&(raw_len as u64).to_le_bytes()); | ||
| header | ||
| } | ||
| fn setxattr(path: &std::ffi::CStr, name: &std::ffi::CStr, value: &[u8]) -> Result<(), Error> { | ||
@@ -199,8 +994,3 @@ let rc = unsafe { | ||
| pub(crate) fn apply_inplace(path: &Path) -> Result<(), Error> { | ||
| let raw = std::fs::read(path).map_err(|source| Error::Io { | ||
| context: "read", | ||
| source, | ||
| })?; | ||
| pub(crate) fn apply_inplace(path: &Path, snapshot: &[u8]) -> Result<(), Error> { | ||
| // Fail-soft: skip if we can't write the original (by mode or ownership) — the | ||
@@ -213,4 +1003,6 @@ // temp+rename below would otherwise replace even a file we can't open for write. | ||
| // `snapshot` is the file's bytes the caller already read for rollback — reuse | ||
| // it instead of a second full read. | ||
| let mode = std::fs::metadata(path).map(|m| m.permissions()).ok(); | ||
| apply_bytes(path, &raw, mode) | ||
| apply_bytes(path, snapshot, mode) | ||
| } | ||
@@ -220,7 +1012,8 @@ | ||
| /// write-then-read-back. The decmpfs is built directly from `content`, dropped on a | ||
| /// sibling temp (empty data fork + the two xattrs + UF_COMPRESSED), then atomically | ||
| /// renamed over `path`. A crash can only leave the original or the finished file; | ||
| /// the rename also gives a fresh inode, the copy-break from any pnpm CAS hardlink | ||
| /// siblings. This is the one-pass core both `compress_bytes` (no original) and | ||
| /// `apply_inplace` (read first) share. | ||
| /// sibling temp (empty data fork + the two xattrs + UF_COMPRESSED when compression | ||
| /// wins; ordinary data fork on no gain), then atomically renamed over `path`. A | ||
| /// crash can only leave the original or the finished file; the rename also gives a | ||
| /// fresh inode, the copy-break from any pnpm CAS hardlink siblings. This is the | ||
| /// one-pass core both `compress_bytes` (no original) and `apply_inplace` (read | ||
| /// first) share. | ||
| pub(crate) fn apply_bytes( | ||
@@ -231,12 +1024,17 @@ path: &Path, | ||
| ) -> Result<(), Error> { | ||
| within_decmpfs_limit(content.len() as u64)?; | ||
| apply_bytes_with_streaming_threshold(path, content, mode, STREAMING_THRESHOLD) | ||
| } | ||
| let mut header = Vec::with_capacity(16); | ||
| header.extend_from_slice(&DECMPFS_MAGIC.to_le_bytes()); | ||
| header.extend_from_slice(&CMP_LZVN_RESOURCE_FORK.to_le_bytes()); | ||
| header.extend_from_slice(&(content.len() as u64).to_le_bytes()); | ||
| let resource_fork = build_resource_fork(content).ok_or_else(|| Error::Io { | ||
| context: "lzvn encode", | ||
| source: std::io::Error::from(std::io::ErrorKind::InvalidData), | ||
| })?; | ||
| fn apply_bytes_with_streaming_threshold( | ||
| path: &Path, | ||
| content: &[u8], | ||
| mode: Option<std::fs::Permissions>, | ||
| streaming_threshold: usize, | ||
| ) -> Result<(), Error> { | ||
| let stream = should_stream_resource_fork(content.len(), streaming_threshold); | ||
| let in_memory_resource_fork = if stream { | ||
| None | ||
| } else { | ||
| build_in_memory_resource_fork(content)? | ||
| }; | ||
@@ -248,19 +1046,73 @@ let dir = path.parent().ok_or_else(|| io("parent"))?; | ||
| .to_string_lossy(); | ||
| let tmp = dir.join(format!(".{name}.decmpfs-{}.tmp", std::process::id())); | ||
| // Uniqueness beyond the PID: a crash can leave `.name.decmpfs-<pid>.tmp`, and a | ||
| // later run that reuses that PID (common after reboot) would fail `create_new` | ||
| // forever. PID + wall-clock nanos + a process-local counter makes a stale | ||
| // sibling collision astronomically unlikely while keeping `create_new`'s | ||
| // concurrent-writer safety. | ||
| static TMP_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); | ||
| let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); | ||
| let nanos = std::time::SystemTime::now() | ||
| .duration_since(std::time::UNIX_EPOCH) | ||
| .map(|d| d.as_nanos()) | ||
| .unwrap_or(0); | ||
| let tmp = dir.join(format!( | ||
| ".{name}.decmpfs-{}-{nanos}-{seq}.tmp", | ||
| std::process::id() | ||
| )); | ||
| let build = (|| -> Result<(), Error> { | ||
| let file = std::fs::OpenOptions::new() | ||
| .read(true) | ||
| .write(true) | ||
| .create_new(true) | ||
| .open(&tmp) | ||
| .map_err(|source| Error::Io { | ||
| context: "create temp", | ||
| let create_temp = || { | ||
| std::fs::OpenOptions::new() | ||
| .read(true) | ||
| .write(true) | ||
| .create_new(true) | ||
| .open(&tmp) | ||
| .map_err(|source| Error::Io { | ||
| context: "create temp", | ||
| source, | ||
| }) | ||
| }; | ||
| let mut file = create_temp()?; | ||
| let ctmp = cstring(&tmp)?; | ||
| let codec = if stream { | ||
| build_streaming_resource_fork(&tmp, content)? | ||
| } else if let Some(resource_fork) = &in_memory_resource_fork { | ||
| // Install the payload before publishing metadata that tells the kernel to | ||
| // decode it. The temp inode is not visible at the destination yet either | ||
| // way, but this ordering also keeps direct temp-path observers safe. | ||
| setxattr(&ctmp, c"com.apple.ResourceFork", &resource_fork.bytes)?; | ||
| Some(resource_fork.codec) | ||
| } else { | ||
| None | ||
| }; | ||
| if let Some(codec) = codec { | ||
| setxattr( | ||
| &ctmp, | ||
| c"com.apple.decmpfs", | ||
| &decmpfs_header(codec, content.len()), | ||
| )?; | ||
| if unsafe { libc::fchflags(file.as_raw_fd(), UF_COMPRESSED) } != 0 { | ||
| return Err(io("fchflags")); | ||
| } | ||
| } else { | ||
| // A losing streamed attempt left a partial resource fork on the temp. | ||
| // Recreate the inode rather than publish a plain file with stale fork data. | ||
| if stream { | ||
| drop(file); | ||
| std::fs::remove_file(&tmp).map_err(|source| Error::Io { | ||
| context: "remove losing streamed temp", | ||
| source, | ||
| })?; | ||
| file = create_temp()?; | ||
| } | ||
| use std::io::Write; | ||
| file.write_all(content).map_err(|source| Error::Io { | ||
| context: "plain temp write", | ||
| source, | ||
| })?; | ||
| let ctmp = cstring(&tmp)?; | ||
| setxattr(&ctmp, c"com.apple.decmpfs", &header)?; | ||
| setxattr(&ctmp, c"com.apple.ResourceFork", &resource_fork)?; | ||
| if unsafe { libc::fchflags(file.as_raw_fd(), UF_COMPRESSED) } != 0 { | ||
| return Err(io("fchflags")); | ||
| file.sync_all().map_err(|source| Error::Io { | ||
| context: "plain temp sync", | ||
| source, | ||
| })?; | ||
| } | ||
@@ -277,2 +1129,12 @@ Ok(()) | ||
| } | ||
| // Preserve ownership across the rewrite. Running as root (a global npm install, | ||
| // a Docker build) the temp is created owned by the current euid, so the rename | ||
| // would otherwise change the file's owner. Match the original's uid/gid. | ||
| // Best-effort: a no-op for a new path (nothing to preserve) and for a non-root | ||
| // process (chown to another owner is EPERM — but then the file was already | ||
| // ours, so nothing changes). | ||
| if let Ok(meta) = std::fs::metadata(path) { | ||
| use std::os::unix::fs::MetadataExt; | ||
| let _ = std::os::unix::fs::chown(&tmp, Some(meta.uid()), Some(meta.gid())); | ||
| } | ||
| std::fs::rename(&tmp, path).map_err(|source| { | ||
@@ -287,3 +1149,15 @@ let _ = std::fs::remove_file(&tmp); | ||
| /// Copy-on-write clone via `clonefile(2)` — shares the extents AND the decmpfs | ||
| /// state, so a compressed source stays compressed at zero cost. `Ok(false)` | ||
| /// means "cannot clone here" (cross-volume, unsupported FS, …) and the caller | ||
| /// falls back to a byte copy; a failed clonefile never leaves a partial | ||
| /// destination. | ||
| pub(crate) fn clone_file(src: &Path, dest: &Path) -> Result<bool, Error> { | ||
| let csrc = cstring(src)?; | ||
| let cdest = cstring(dest)?; | ||
| Ok(unsafe { libc::clonefile(csrc.as_ptr(), cdest.as_ptr(), 0) } == 0) | ||
| } | ||
| #[cfg(test)] | ||
| #[cfg_attr(coverage_nightly, coverage(off))] | ||
| mod tests { | ||
@@ -312,3 +1186,3 @@ use super::*; | ||
| ); | ||
| apply_inplace(&path).unwrap(); | ||
| apply_inplace(&path, &raw).unwrap(); | ||
| assert!(is_already_compressed(&path).unwrap(), "UF_COMPRESSED set"); | ||
@@ -330,2 +1204,88 @@ assert_eq!( | ||
| #[test] | ||
| fn incremental_writer_streams_lzfse_blocks_into_a_kernel_readable_file() { | ||
| let dir = | ||
| std::env::temp_dir().join(format!("decmpfs-incremental-oracle-{}", std::process::id())); | ||
| std::fs::create_dir_all(&dir).unwrap(); | ||
| let path = dir.join("model.bin"); | ||
| let raw = b"incremental lzfse resource fork ".repeat((2 << 20) / 34 + 1); | ||
| let mut writer = StreamingWriter::new(&path, raw.len()).unwrap(); | ||
| for chunk in raw.chunks(17_003) { | ||
| writer.write_all(chunk).unwrap(); | ||
| } | ||
| assert!(writer.finish().unwrap(), "compressible stream must win"); | ||
| assert!(is_already_compressed(&path).unwrap()); | ||
| assert_eq!(std::fs::read(&path).unwrap(), raw); | ||
| std::fs::remove_dir_all(&dir).ok(); | ||
| } | ||
| #[test] | ||
| fn incremental_writer_reconstructs_plain_bytes_when_compression_loses() { | ||
| let dir = std::env::temp_dir().join(format!( | ||
| "decmpfs-incremental-fallback-{}", | ||
| std::process::id() | ||
| )); | ||
| std::fs::create_dir_all(&dir).unwrap(); | ||
| let path = dir.join("random.bin"); | ||
| let mut raw = Vec::with_capacity(2 << 20); | ||
| let mut x: u64 = 0x9e37_79b9_7f4a_7c15; | ||
| while raw.len() < (2 << 20) { | ||
| x ^= x << 13; | ||
| x ^= x >> 7; | ||
| x ^= x << 17; | ||
| raw.extend_from_slice(&x.to_le_bytes()); | ||
| } | ||
| let mut writer = StreamingWriter::new(&path, raw.len()).unwrap(); | ||
| for chunk in raw.chunks(17_003) { | ||
| writer.write_all(chunk).unwrap(); | ||
| } | ||
| assert!( | ||
| !writer.finish().unwrap(), | ||
| "incompressible stream stays plain" | ||
| ); | ||
| assert!(!is_already_compressed(&path).unwrap()); | ||
| assert_eq!(std::fs::read(&path).unwrap(), raw); | ||
| std::fs::remove_dir_all(&dir).ok(); | ||
| } | ||
| // Opt-in perf probe (ignored in CI — timing is machine-specific). Reports the | ||
| // decmpfs write time for a ~40 MiB addon; run serial vs parallel with | ||
| // cargo test -p decmpfs write_time -- --ignored --nocapture | ||
| // DECMPFS_SERIAL=1 cargo test -p decmpfs write_time -- --ignored --nocapture | ||
| #[test] | ||
| #[ignore] | ||
| fn write_time_probe() { | ||
| let dir = std::env::temp_dir().join(format!("decmpfs-time-{}", std::process::id())); | ||
| std::fs::create_dir_all(&dir).unwrap(); | ||
| let path = dir.join("addon.node"); | ||
| let mut raw: Vec<u8> = Vec::with_capacity(40 << 20); | ||
| let mut x: u64 = 0x9e37_79b9_7f4a_7c15; | ||
| while raw.len() < (40 << 20) { | ||
| x ^= x << 13; | ||
| x ^= x >> 7; | ||
| x ^= x << 17; | ||
| raw.extend_from_slice(&x.to_le_bytes()); | ||
| raw.extend_from_slice(b"native addon .node text segment padding "); | ||
| } | ||
| if !matches!(detect(&dir), Ok(Support::Supported)) { | ||
| std::fs::remove_dir_all(&dir).ok(); | ||
| return; | ||
| } | ||
| let cores = std::thread::available_parallelism() | ||
| .map(|n| n.get()) | ||
| .unwrap_or(1); | ||
| let serial = std::env::var_os("DECMPFS_SERIAL").is_some(); | ||
| let start = std::time::Instant::now(); | ||
| apply_bytes(&path, &raw, None).unwrap(); | ||
| let ms = start.elapsed().as_secs_f64() * 1e3; | ||
| eprintln!( | ||
| "decmpfs write {}MiB — {} ({} cores): {:.1} ms", | ||
| raw.len() >> 20, | ||
| if serial { "serial" } else { "parallel" }, | ||
| cores, | ||
| ms, | ||
| ); | ||
| std::fs::remove_dir_all(&dir).ok(); | ||
| } | ||
| #[test] | ||
| fn detect_and_flags_error_on_a_missing_path() { | ||
@@ -351,7 +1311,17 @@ let p = std::path::Path::new("/no/such/decmpfs/path/x.bin"); | ||
| let path = dir.join("f.bin"); | ||
| std::fs::write(&path, b"\x7fELF unreadable").unwrap(); | ||
| let content = b"\x7fELF unreadable"; | ||
| std::fs::write(&path, content).unwrap(); | ||
| std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap(); | ||
| let out = apply_inplace(&path); | ||
| // apply_inplace no longer reads the file (the caller passes the snapshot it | ||
| // already holds); the fail-soft guard is now the W_OK access check, which | ||
| // rejects a file we cannot write before the temp+rename would replace it. | ||
| let out = apply_inplace(&path, content); | ||
| std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).ok(); | ||
| assert!(matches!(out, Err(Error::Io { context: "read", .. }))); | ||
| assert!(matches!( | ||
| out, | ||
| Err(Error::Io { | ||
| context: "access", | ||
| .. | ||
| }) | ||
| )); | ||
| std::fs::remove_dir_all(&dir).ok(); | ||
@@ -363,3 +1333,9 @@ } | ||
| let out = setxattr(c"/no/such/decmpfs/path", c"com.apple.decmpfs", b"x"); | ||
| assert!(matches!(out, Err(Error::Io { context: "setxattr", .. }))); | ||
| assert!(matches!( | ||
| out, | ||
| Err(Error::Io { | ||
| context: "setxattr", | ||
| .. | ||
| }) | ||
| )); | ||
| } | ||
@@ -376,2 +1352,107 @@ | ||
| #[test] | ||
| fn build_resource_fork_zero_length_is_no_gain() { | ||
| assert!( | ||
| build_resource_fork(&[]).unwrap().is_none(), | ||
| "a resource fork cannot make an empty file smaller" | ||
| ); | ||
| } | ||
| #[test] | ||
| fn streaming_threshold_keeps_vite_native_addons_on_the_fast_path() { | ||
| // The largest Darwin ARM64 addon in the 2026-07-16 Vite-family sample was | ||
| // SWC at 36.563 MiB. The complete observed set must stay comfortably below | ||
| // the in-memory cutoff, while the first byte beyond it streams. | ||
| assert!(!should_stream_resource_fork(37 << 20, STREAMING_THRESHOLD)); | ||
| assert!(!should_stream_resource_fork( | ||
| STREAMING_THRESHOLD, | ||
| STREAMING_THRESHOLD | ||
| )); | ||
| assert!(should_stream_resource_fork( | ||
| STREAMING_THRESHOLD + 1, | ||
| STREAMING_THRESHOLD | ||
| )); | ||
| } | ||
| #[test] | ||
| fn kernel_roundtrips_forced_streaming_lzfse() { | ||
| let dir = std::env::temp_dir().join(format!("decmpfs-streaming-oracle-{}", std::process::id())); | ||
| std::fs::create_dir_all(&dir).unwrap(); | ||
| let path = dir.join("f.bin"); | ||
| let raw = b"streamed lzfse decmpfs resource fork oracle ".repeat((2 << 20) / 46 + 1); | ||
| std::fs::write(&path, &raw).unwrap(); | ||
| if matches!(detect(&path).unwrap(), Support::Supported) { | ||
| apply_bytes_with_streaming_threshold(&path, &raw, None, 0).unwrap(); | ||
| assert!(is_already_compressed(&path).unwrap(), "UF_COMPRESSED set"); | ||
| assert_eq!( | ||
| std::fs::read(&path).unwrap(), | ||
| raw, | ||
| "kernel read-back must decode the streamed type-12 resource fork" | ||
| ); | ||
| let cpath = cstring(&path).unwrap(); | ||
| let mut header = [0u8; 16]; | ||
| let len = unsafe { | ||
| libc::getxattr( | ||
| cpath.as_ptr(), | ||
| c"com.apple.decmpfs".as_ptr(), | ||
| header.as_mut_ptr().cast(), | ||
| header.len(), | ||
| 0, | ||
| XATTR_NOFOLLOW | 0x0020, // XATTR_SHOWCOMPRESSION | ||
| ) | ||
| }; | ||
| assert_eq!(len, header.len() as isize); | ||
| assert_eq!(u32::from_le_bytes(header[4..8].try_into().unwrap()), 12); | ||
| } | ||
| std::fs::remove_dir_all(&dir).ok(); | ||
| } | ||
| #[test] | ||
| fn in_memory_path_falls_back_to_lzfse_when_lzvn_has_no_gain() { | ||
| // Skewed symbol frequencies give LZFSE's entropy coder something to exploit | ||
| // without manufacturing the repeated strings that LZVN specializes in. | ||
| let mut raw = Vec::with_capacity(1 << 20); | ||
| let mut x: u64 = 0x9e37_79b9_7f4a_7c15; | ||
| while raw.len() < raw.capacity() { | ||
| x ^= x << 13; | ||
| x ^= x >> 7; | ||
| x ^= x << 17; | ||
| raw.push(if x.is_multiple_of(4) { | ||
| 0 | ||
| } else { | ||
| (x >> 32) as u8 | ||
| }); | ||
| } | ||
| assert!( | ||
| build_resource_fork_with_codec(&raw, Codec::Lzvn) | ||
| .unwrap() | ||
| .is_none(), | ||
| "fixture must reach the fallback" | ||
| ); | ||
| let candidate = build_in_memory_resource_fork(&raw) | ||
| .unwrap() | ||
| .expect("LZFSE should exploit the skewed symbols"); | ||
| assert_eq!(candidate.codec, Codec::Lzfse); | ||
| assert!(candidate.bytes.len() < raw.len()); | ||
| } | ||
| #[test] | ||
| fn build_resource_fork_last_offset_equals_length() { | ||
| // Invariant across sizes that actually encode: the final table offset equals | ||
| // the total blob length. (Tiny/incompressible inputs return None — the codec | ||
| // declines — which is a separate, correct path.) | ||
| for size in [512usize, BLOCK, BLOCK + 1, BLOCK * 3 + 7] { | ||
| let raw = vec![0x41u8; size]; | ||
| let Some(rf) = build_resource_fork(&raw).unwrap() else { | ||
| continue; | ||
| }; | ||
| let num_blocks = size.div_ceil(BLOCK); | ||
| let last_idx = num_blocks * 4; // offset[num_blocks] is the last entry | ||
| let last = u32::from_le_bytes(rf[last_idx..last_idx + 4].try_into().unwrap()) as usize; | ||
| assert_eq!(last, rf.len(), "size {size}: last offset != buffer length"); | ||
| } | ||
| } | ||
| #[test] | ||
| fn cstring_rejects_an_interior_nul() { | ||
@@ -408,6 +1489,28 @@ use std::os::unix::ffi::OsStrExt; | ||
| #[test] | ||
| fn within_decmpfs_limit_rejects_oversized() { | ||
| // No 4 GiB file needed — the predicate is pure. | ||
| assert!(within_decmpfs_limit(MAX_RAW).is_ok()); | ||
| match within_decmpfs_limit(MAX_RAW + 1).unwrap_err() { | ||
| fn resource_fork_plan_accepts_raw_files_beyond_the_old_limit() { | ||
| // The raw byte count is stored as u64. Only resource-fork offsets are u32, | ||
| // so a >3.9 GB input is valid whenever its encoded fork fits in u32. | ||
| let raw_len = 4_100_000_000usize; | ||
| let num_blocks = raw_len.div_ceil(BLOCK); | ||
| assert!(matches!( | ||
| plan_resource_fork(raw_len, num_blocks, 3_000_000_000).unwrap(), | ||
| ResourceForkPlan::Compressed { .. } | ||
| )); | ||
| } | ||
| #[test] | ||
| fn resource_fork_plan_accepts_raw_files_beyond_four_gib_when_the_fork_fits() { | ||
| let raw_len = 5_000_000_000usize; | ||
| let num_blocks = raw_len.div_ceil(BLOCK); | ||
| assert!(matches!( | ||
| plan_resource_fork(raw_len, num_blocks, 3_000_000_000).unwrap(), | ||
| ResourceForkPlan::Compressed { .. } | ||
| )); | ||
| } | ||
| #[test] | ||
| fn resource_fork_plan_rejects_a_compressed_fork_past_u32() { | ||
| let raw_len = 5_000_000_000usize; | ||
| let num_blocks = raw_len.div_ceil(BLOCK); | ||
| match plan_resource_fork(raw_len, num_blocks, 4_400_000_000).unwrap_err() { | ||
| Error::Io { source, .. } => assert_eq!(source.raw_os_error(), Some(libc::EFBIG)), | ||
@@ -418,5 +1521,28 @@ other => panic!("expected EFBIG Io, got {other:?}"), | ||
| // Incompressible data → blocks are stored verbatim (compress_block returns the | ||
| // chunk). The kernel must still read them back identically. | ||
| #[test] | ||
| fn gemini_nano_lzvn_resource_fork_is_no_gain() { | ||
| // Chrome 150's v3Nano weights.bin measured with this exact 64 KiB LZVN | ||
| // encoder: the encoded blocks expand enough to cross the u32 fork ceiling. | ||
| assert_eq!( | ||
| plan_resource_fork(4_269_932_544, 65_154, 4_364_775_458).unwrap(), | ||
| ResourceForkPlan::Plain | ||
| ); | ||
| } | ||
| #[test] | ||
| fn gemini_nano_lzfse_resource_fork_fits_and_wins() { | ||
| // The streamed type-12 run encoded the same 65,154 blocks to this payload; | ||
| // with its 260,620-byte offset table the fork is safely below u32::MAX. | ||
| assert_eq!( | ||
| plan_resource_fork(4_269_932_544, 65_154, 3_598_249_560).unwrap(), | ||
| ResourceForkPlan::Compressed { | ||
| table_len: 260_620, | ||
| total_len: 3_598_510_180, | ||
| } | ||
| ); | ||
| } | ||
| // Incompressible data → LZVN would expand the resource fork, so keep an | ||
| // ordinary data fork. The bytes and compression-state signal must agree. | ||
| #[test] | ||
| fn kernel_roundtrips_incompressible_blocks() { | ||
@@ -436,11 +1562,41 @@ let dir = std::env::temp_dir().join(format!("decmpfs-raw-{}", std::process::id())); | ||
| if matches!(detect(&path).unwrap(), Support::Supported) { | ||
| apply_inplace(&path).unwrap(); | ||
| assert!(matches!( | ||
| crate::compress_file(&path).unwrap(), | ||
| crate::Outcome::NoGain { .. } | ||
| )); | ||
| assert_eq!( | ||
| std::fs::read(&path).unwrap(), | ||
| raw, | ||
| "verbatim blocks read back" | ||
| "plain fallback reads back identically" | ||
| ); | ||
| assert!( | ||
| !is_already_compressed(&path).unwrap(), | ||
| "no-gain input must not carry UF_COMPRESSED" | ||
| ); | ||
| } | ||
| std::fs::remove_dir_all(&dir).ok(); | ||
| } | ||
| #[test] | ||
| fn apply_bytes_preserves_ownership_of_an_overwritten_file() { | ||
| // Non-root exercises the chown path over an existing file — owner is our own | ||
| // uid, so preservation is a no-op we assert stays stable + non-corrupting. | ||
| // The root path (a file owned by a different uid) is verified in CI. | ||
| let dir = std::env::temp_dir().join(format!("decmpfs-own-{}", std::process::id())); | ||
| std::fs::create_dir_all(&dir).unwrap(); | ||
| let path = dir.join("f"); | ||
| std::fs::write(&path, vec![0u8; 4096]).unwrap(); | ||
| if !matches!(detect(&path), Ok(Support::Supported)) { | ||
| std::fs::remove_dir_all(&dir).ok(); | ||
| return; | ||
| } | ||
| use std::os::unix::fs::MetadataExt; | ||
| let before_uid = std::fs::metadata(&path).unwrap().uid(); | ||
| let content = vec![0xABu8; 8192]; | ||
| apply_bytes(&path, &content, None).unwrap(); | ||
| let meta = std::fs::metadata(&path).unwrap(); | ||
| assert_eq!(meta.uid(), before_uid, "owner preserved across the rewrite"); | ||
| assert_eq!(std::fs::read(&path).unwrap(), content, "content intact"); | ||
| std::fs::remove_dir_all(&dir).ok(); | ||
| } | ||
| } |
+73
-38
@@ -37,3 +37,3 @@ //! Shared orchestration that gates every backend identically. Written once here | ||
| // -> Skipped(Busy). A genuine, unclassifiable I/O error still propagates. | ||
| if let Err(err) = backend.apply_inplace(path) { | ||
| if let Err(err) = backend.apply_inplace(path, &snapshot) { | ||
| if let Error::Io { source, .. } = &err { | ||
@@ -63,3 +63,3 @@ if let Some(reason) = classify_skip(source) { | ||
| if verify::magic_prefix(path)? != magic_before { | ||
| restore(path, snapshot); | ||
| restore(path, snapshot)?; | ||
| return Ok(classify_outcome(false, before, before, None)); | ||
@@ -105,9 +105,25 @@ } | ||
| } | ||
| match err.raw_os_error() { | ||
| Some(1) | Some(13) | Some(30) => Some(SkipReason::PermissionDenied), // EPERM/EACCES/EROFS | ||
| Some(16) | Some(26) => Some(SkipReason::Busy), // EBUSY/ETXTBSY | ||
| Some(27) => Some(SkipReason::TooLarge), // EFBIG | ||
| classify_errno(err.raw_os_error()?) | ||
| } | ||
| // Per-platform errno classification. Windows `raw_os_error()` is the Win32 space, | ||
| // which does NOT coincide with POSIX (e.g. 32 = SHARING_VIOLATION on Windows but | ||
| // EPIPE on unix), so the two maps are mutually exclusive by cfg. | ||
| #[cfg(not(windows))] | ||
| fn classify_errno(code: i32) -> Option<SkipReason> { | ||
| match code { | ||
| 1 | 13 | 30 => Some(SkipReason::PermissionDenied), // EPERM/EACCES/EROFS | ||
| 16 | 26 => Some(SkipReason::Busy), // EBUSY/ETXTBSY | ||
| 27 => Some(SkipReason::TooLarge), // EFBIG | ||
| _ => None, | ||
| } | ||
| } | ||
| #[cfg(windows)] | ||
| fn classify_errno(code: i32) -> Option<SkipReason> { | ||
| match code { | ||
| 5 | 19 => Some(SkipReason::PermissionDenied), // ACCESS_DENIED / WRITE_PROTECT | ||
| 32 | 33 => Some(SkipReason::Busy), // SHARING_VIOLATION / LOCK_VIOLATION | ||
| _ => None, | ||
| } | ||
| } | ||
@@ -153,8 +169,4 @@ /// One-pass guarded write of `content` to `path` as an OS-compressed file. Reached | ||
| let after = verify::on_disk_bytes(path)?; | ||
| let read_back = std::fs::read(path).map_err(|source| Error::Io { | ||
| context: "read-back", | ||
| source, | ||
| })?; | ||
| if read_back != content { | ||
| restore(path, content); | ||
| if !verify::readback_matches(path, content)? { | ||
| restore(path, content)?; | ||
| return Ok(Outcome::Skipped { | ||
@@ -174,20 +186,30 @@ reason: SkipReason::IntegrityRevert, | ||
| /// Best-effort atomic restore of the pre-apply bytes (sibling temp + rename). | ||
| fn restore(path: &Path, bytes: &[u8]) { | ||
| /// Atomic restore of the pre-apply bytes (sibling temp + rename). Returns `Err` | ||
| /// when the rollback itself fails (e.g. `ENOSPC`, a read-only dir) — the caller | ||
| /// MUST surface that as a hard error rather than a benign `Skipped`, else a | ||
| /// corrupted file is left on disk while the outcome reads as non-fatal. | ||
| fn restore(path: &Path, bytes: &[u8]) -> Result<(), Error> { | ||
| use std::io::Write; | ||
| let Some(dir) = path.parent() else { | ||
| return; | ||
| }; | ||
| let dir = path.parent().ok_or_else(|| Error::Io { | ||
| context: "rollback restore: path has no parent", | ||
| source: std::io::Error::from(std::io::ErrorKind::InvalidInput), | ||
| })?; | ||
| let tmp = dir.join(format!(".decmpfs-restore-{}.tmp", std::process::id())); | ||
| let wrote = std::fs::File::create(&tmp).and_then(|mut file| { | ||
| file.write_all(bytes)?; | ||
| file.sync_all() | ||
| }); | ||
| if wrote.is_ok() && std::fs::rename(&tmp, path).is_ok() { | ||
| return; | ||
| } | ||
| let _ = std::fs::remove_file(&tmp); | ||
| let wrote = std::fs::File::create(&tmp) | ||
| .and_then(|mut file| { | ||
| file.write_all(bytes)?; | ||
| file.sync_all() | ||
| }) | ||
| .and_then(|()| std::fs::rename(&tmp, path)); | ||
| wrote.map_err(|source| { | ||
| let _ = std::fs::remove_file(&tmp); | ||
| Error::Io { | ||
| context: "rollback restore", | ||
| source, | ||
| } | ||
| }) | ||
| } | ||
| #[cfg(test)] | ||
| #[cfg_attr(coverage_nightly, coverage(off))] | ||
| mod tests { | ||
@@ -212,3 +234,3 @@ use super::*; | ||
| detect: Support::Supported, | ||
| apply_errno: Some(2), | ||
| apply_error: Some(std::io::ErrorKind::NotFound), | ||
| }; | ||
@@ -226,7 +248,11 @@ let out = apply_guarded(&backend, &path); | ||
| ); | ||
| for errno in [1, 13, 30] { | ||
| #[cfg(not(windows))] | ||
| let os_errors = [1, 13, 30]; // EPERM / EACCES / EROFS | ||
| #[cfg(windows)] | ||
| let os_errors = [5, 19]; // ACCESS_DENIED / WRITE_PROTECT | ||
| for code in os_errors { | ||
| assert_eq!( | ||
| classify_skip(&std::io::Error::from_raw_os_error(errno)), | ||
| classify_skip(&std::io::Error::from_raw_os_error(code)), | ||
| Some(SkipReason::PermissionDenied), | ||
| "errno {errno}" | ||
| "OS error {code}" | ||
| ); | ||
@@ -238,7 +264,11 @@ } | ||
| fn busy_errors_become_skipped() { | ||
| for errno in [16, 26] { | ||
| #[cfg(not(windows))] | ||
| let os_errors = [16, 26]; // EBUSY / ETXTBSY | ||
| #[cfg(windows)] | ||
| let os_errors = [32, 33]; // SHARING_VIOLATION / LOCK_VIOLATION | ||
| for code in os_errors { | ||
| assert_eq!( | ||
| classify_skip(&std::io::Error::from_raw_os_error(errno)), | ||
| classify_skip(&std::io::Error::from_raw_os_error(code)), | ||
| Some(SkipReason::Busy), | ||
| "errno {errno}" | ||
| "OS error {code}" | ||
| ); | ||
@@ -248,2 +278,3 @@ } | ||
| #[cfg(not(windows))] | ||
| #[test] | ||
@@ -295,3 +326,3 @@ fn efbig_becomes_too_large() { | ||
| std::fs::write(&path, b"corrupted-by-a-broken-backend").unwrap(); | ||
| restore(&path, b"the original loadable bytes"); | ||
| restore(&path, b"the original loadable bytes").unwrap(); | ||
| assert_eq!( | ||
@@ -329,3 +360,3 @@ std::fs::read(&path).unwrap(), | ||
| detect: Support::Supported, | ||
| apply_errno: None, | ||
| apply_error: None, | ||
| }; | ||
@@ -344,5 +375,6 @@ let out = compress_bytes_guarded(&backend, &path, content).unwrap(); | ||
| #[test] | ||
| fn restore_is_a_noop_when_the_path_has_no_parent() { | ||
| // "/" has no parent → restore returns early without touching anything. | ||
| restore(std::path::Path::new("/"), b"x"); | ||
| fn restore_errors_when_the_path_has_no_parent() { | ||
| // "/" has no parent → the rollback can't write a sibling temp, so it must | ||
| // surface an Err (a silent no-op would report a corrupt file as benign). | ||
| assert!(restore(std::path::Path::new("/"), b"x").is_err()); | ||
| } | ||
@@ -409,3 +441,6 @@ | ||
| std::fs::create_dir_all(&target).unwrap(); | ||
| restore(&target, b"bytes"); | ||
| assert!( | ||
| restore(&target, b"bytes").is_err(), | ||
| "rename-over-dir must Err" | ||
| ); | ||
| let tmp = dir.join(format!(".decmpfs-restore-{}.tmp", std::process::id())); | ||
@@ -412,0 +447,0 @@ assert!(!tmp.exists(), "temp left behind"); |
@@ -14,3 +14,3 @@ //! Fallback for any OS without a backend — always Unsupported{PlatformBuild}. | ||
| pub(crate) fn apply_inplace(_path: &Path) -> Result<(), Error> { | ||
| pub(crate) fn apply_inplace(_path: &Path, _snapshot: &[u8]) -> Result<(), Error> { | ||
| Ok(()) | ||
@@ -36,1 +36,7 @@ } | ||
| } | ||
| /// No clone primitive on an unsupported platform — the caller takes the plain | ||
| /// byte-copy path. | ||
| pub(crate) fn clone_file(_src: &Path, _dest: &Path) -> Result<bool, Error> { | ||
| Ok(false) | ||
| } |
+88
-0
@@ -74,7 +74,95 @@ //! Platform-agnostic measurement + loadability surface. | ||
| /// Stream-compare the file at `path` against `expected`, letting the kernel | ||
| /// decompress transparently, through a fixed reusable 64 KiB buffer — never | ||
| /// materializing a second full copy of the file (the read-back oracle runs on | ||
| /// every compressed write, so a `std::fs::read` here would heap-allocate a whole | ||
| /// extra copy of every addon). Returns `false` on the first differing byte or any | ||
| /// length divergence (a short OR long read-back), and short-circuits on mismatch. | ||
| pub(crate) fn readback_matches(path: &Path, expected: &[u8]) -> Result<bool, Error> { | ||
| let mut file = std::fs::File::open(path).map_err(|source| Error::Io { | ||
| context: "read-back", | ||
| source, | ||
| })?; | ||
| let mut buf = [0u8; 64 * 1024]; | ||
| let mut off = 0usize; | ||
| loop { | ||
| let n = file.read(&mut buf).map_err(|source| Error::Io { | ||
| context: "read-back", | ||
| source, | ||
| })?; | ||
| if n == 0 { | ||
| break; | ||
| } | ||
| // A read-back that overruns `expected`, or a chunk that differs, is a | ||
| // mismatch — bail without reading the rest. | ||
| if off + n > expected.len() || buf[..n] != expected[off..off + n] { | ||
| return Ok(false); | ||
| } | ||
| off += n; | ||
| } | ||
| // Equal only if the on-disk length matched exactly (a truncated read-back is a | ||
| // mismatch the loop can't otherwise catch). | ||
| Ok(off == expected.len()) | ||
| } | ||
| #[cfg(test)] | ||
| #[cfg_attr(coverage_nightly, coverage(off))] | ||
| mod tests { | ||
| use super::*; | ||
| fn scratch(tag: &str) -> std::path::PathBuf { | ||
| let dir = std::env::temp_dir().join(format!("decmpfs-verify-{tag}-{}", std::process::id())); | ||
| std::fs::create_dir_all(&dir).unwrap(); | ||
| dir | ||
| } | ||
| #[test] | ||
| fn readback_matches_identical_content() { | ||
| let dir = scratch("rb-eq"); | ||
| let path = dir.join("f"); | ||
| // Larger than one 64 KiB buffer to exercise the multi-chunk loop. | ||
| let content = vec![0xABu8; 200 * 1024]; | ||
| std::fs::write(&path, &content).unwrap(); | ||
| assert!(readback_matches(&path, &content).unwrap()); | ||
| std::fs::remove_dir_all(&dir).ok(); | ||
| } | ||
| #[test] | ||
| fn readback_detects_a_differing_byte() { | ||
| let dir = scratch("rb-diff"); | ||
| let path = dir.join("f"); | ||
| let content = vec![0x11u8; 100 * 1024]; | ||
| let mut on_disk = content.clone(); | ||
| on_disk[80 * 1024] = 0x22; | ||
| std::fs::write(&path, &on_disk).unwrap(); | ||
| assert!(!readback_matches(&path, &content).unwrap()); | ||
| std::fs::remove_dir_all(&dir).ok(); | ||
| } | ||
| #[test] | ||
| fn readback_detects_a_short_file() { | ||
| let dir = scratch("rb-short"); | ||
| let path = dir.join("f"); | ||
| std::fs::write(&path, vec![0x33u8; 4096]).unwrap(); | ||
| // Expected is longer than what's on disk. | ||
| assert!(!readback_matches(&path, &vec![0x33u8; 8192]).unwrap()); | ||
| std::fs::remove_dir_all(&dir).ok(); | ||
| } | ||
| #[test] | ||
| fn readback_detects_a_long_file() { | ||
| let dir = scratch("rb-long"); | ||
| let path = dir.join("f"); | ||
| std::fs::write(&path, vec![0x44u8; 8192]).unwrap(); | ||
| // Expected is shorter than what's on disk (read-back overruns). | ||
| assert!(!readback_matches(&path, &vec![0x44u8; 4096]).unwrap()); | ||
| std::fs::remove_dir_all(&dir).ok(); | ||
| } | ||
| #[test] | ||
| fn readback_errors_on_a_missing_path() { | ||
| assert!(readback_matches(std::path::Path::new("/no/such/rb/x"), b"x").is_err()); | ||
| } | ||
| #[test] | ||
| fn measures_allocation_and_reads_magic() { | ||
@@ -81,0 +169,0 @@ let dir = std::env::temp_dir().join(format!("decmpfs-verify-{}", std::process::id())); |
+195
-23
@@ -24,3 +24,3 @@ //! Windows backend — NTFS LZNT1 via `FSCTL_SET_COMPRESSION`. | ||
| use windows_sys::Win32::Storage::FileSystem::{ | ||
| CreateFileW, GetFileAttributesW, GetVolumeInformationByHandleW, | ||
| CreateFileW, GetFileAttributesW, GetVolumeInformationByHandleW, MoveFileExW, | ||
| }; | ||
@@ -35,3 +35,7 @@ use windows_sys::Win32::System::IO::DeviceIoControl; | ||
| const OPEN_EXISTING: u32 = 3; | ||
| const CREATE_ALWAYS: u32 = 2; | ||
| const CREATE_NEW: u32 = 1; | ||
| // MoveFileExW flags: replace an existing target atomically, and don't return | ||
| // until the rename is flushed to disk. | ||
| const MOVEFILE_REPLACE_EXISTING: u32 = 0x0000_0001; | ||
| const MOVEFILE_WRITE_THROUGH: u32 = 0x0000_0008; | ||
| const FSCTL_SET_COMPRESSION: u32 = 0x0009_C040; | ||
@@ -42,2 +46,6 @@ const COMPRESSION_FORMAT_DEFAULT: u16 = 1; // LZNT1 | ||
| const INVALID_FILE_ATTRIBUTES: u32 = u32::MAX; | ||
| // Required to obtain a handle to a DIRECTORY (CreateFileW fails on a dir without | ||
| // it). `detect()` probes the parent directory of a not-yet-created target on a | ||
| // fresh install, so every open must set it; harmless on a regular file. | ||
| const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000; | ||
@@ -76,3 +84,3 @@ fn wide(path: &Path) -> Vec<u16> { | ||
| disposition, | ||
| 0, | ||
| FILE_FLAG_BACKUP_SEMANTICS, | ||
| std::ptr::null_mut(), | ||
@@ -143,3 +151,6 @@ ) | ||
| pub(crate) fn apply_inplace(path: &Path) -> Result<(), Error> { | ||
| // `_snapshot` is unused on Windows: FSCTL_SET_COMPRESSION flags the EXISTING | ||
| // file in place (the kernel recompresses), so there's no temp+rewrite that would | ||
| // reuse the caller's bytes. | ||
| pub(crate) fn apply_inplace(path: &Path, _snapshot: &[u8]) -> Result<(), Error> { | ||
| let handle = open(path, GENERIC_READ | GENERIC_WRITE)?; | ||
@@ -149,6 +160,53 @@ set_compression(handle.0) | ||
| /// Write `content` to `path` as a fresh NTFS-compressed file in ONE pass: create | ||
| /// the file, FSCTL_SET_COMPRESSION on the EMPTY handle (so writes compress on the | ||
| /// way in — never a write-then-recompress), then write the bytes through the same | ||
| /// handle. `mode` is unused on Windows (no POSIX bits). Shared by `compress_bytes`. | ||
| /// A collision-resistant sibling temp path. PID + wall-clock nanos + a | ||
| /// process-local counter so two writers on the SAME destination never share a | ||
| /// temp; paired with CREATE_NEW a collision errors rather than clobbering. | ||
| /// Mirrors the macOS/Linux backends' scheme. | ||
| fn unique_tmp(dir: &Path, name: &str) -> std::path::PathBuf { | ||
| static TMP_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); | ||
| let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); | ||
| let nanos = std::time::SystemTime::now() | ||
| .duration_since(std::time::UNIX_EPOCH) | ||
| .map(|d| d.as_nanos()) | ||
| .unwrap_or(0); | ||
| dir.join(format!( | ||
| ".{name}.decmpfs-{}-{nanos}-{seq}.tmp", | ||
| std::process::id() | ||
| )) | ||
| } | ||
| /// Atomically publish `tmp` over `dest` (replace + write-through). | ||
| fn move_file_replace(tmp: &Path, dest: &Path) -> Result<(), Error> { | ||
| let wtmp = wide(tmp); | ||
| let wdest = wide(dest); | ||
| let ok = unsafe { | ||
| MoveFileExW( | ||
| wtmp.as_ptr(), | ||
| wdest.as_ptr(), | ||
| MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, | ||
| ) | ||
| }; | ||
| if ok == 0 { | ||
| return Err(io("MoveFileExW")); | ||
| } | ||
| Ok(()) | ||
| } | ||
| pub(crate) fn prepare_stream(file: &std::fs::File) -> Result<(), Error> { | ||
| use std::os::windows::io::AsRawHandle; | ||
| set_compression(file.as_raw_handle() as HANDLE) | ||
| } | ||
| pub(crate) fn publish_stream(tmp: &Path, dest: &Path) -> Result<(), Error> { | ||
| move_file_replace(tmp, dest) | ||
| } | ||
| /// Write `content` to `path` as a fresh NTFS-compressed file in ONE pass, then | ||
| /// publish it atomically: create a sibling temp with CREATE_NEW, | ||
| /// FSCTL_SET_COMPRESSION on the EMPTY handle (so writes compress on the way in — | ||
| /// never a write-then-recompress), write + flush, then MoveFileEx over `path`. | ||
| /// The temp+rename matches the macOS/Linux backends and fixes the old | ||
| /// CREATE_ALWAYS-in-place write, which truncated `path` before streaming — a | ||
| /// crash or a concurrent open saw a partial `.node`. `mode` is unused on Windows | ||
| /// (no POSIX bits). Shared by `compress_bytes`. | ||
| pub(crate) fn apply_bytes( | ||
@@ -159,17 +217,38 @@ path: &Path, | ||
| ) -> Result<(), Error> { | ||
| let handle = open_with(path, GENERIC_READ | GENERIC_WRITE, CREATE_ALWAYS)?; | ||
| set_compression(handle.0)?; | ||
| // Write through the same handle the compression flag was set on, so every block | ||
| // lands compressed. WriteFile via a std File would need a second open; instead | ||
| // borrow the raw handle into a File for the write, then forget it so Drop on the | ||
| // Handle (not the File) does the single CloseHandle. | ||
| use std::os::windows::io::FromRawHandle; | ||
| let mut file = unsafe { std::fs::File::from_raw_handle(handle.0 as _) }; | ||
| let res = file.write_all(content).and_then(|()| file.flush()); | ||
| // Don't let File's Drop close the handle — the Handle wrapper owns it. | ||
| std::mem::forget(file); | ||
| res.map_err(|source| Error::Io { | ||
| context: "write", | ||
| source, | ||
| }) | ||
| let dir = path.parent().ok_or_else(|| io("no parent dir"))?; | ||
| let name = path.file_name().map_or_else( | ||
| || std::borrow::Cow::Borrowed("addon"), | ||
| |n| n.to_string_lossy(), | ||
| ); | ||
| let tmp = unique_tmp(dir, &name); | ||
| let result = (|| { | ||
| let handle = open_with(&tmp, GENERIC_READ | GENERIC_WRITE, CREATE_NEW)?; | ||
| set_compression(handle.0)?; | ||
| // Borrow the raw handle into a File for the write, then forget it so the | ||
| // Handle wrapper (not File) does the single CloseHandle. | ||
| use std::os::windows::io::FromRawHandle; | ||
| let mut file = unsafe { std::fs::File::from_raw_handle(handle.0 as _) }; | ||
| let res = file | ||
| .write_all(content) | ||
| .and_then(|()| file.flush()) | ||
| .and_then(|()| file.sync_all()); | ||
| std::mem::forget(file); | ||
| res.map_err(|source| Error::Io { | ||
| context: "write temp", | ||
| source, | ||
| }) | ||
| })(); | ||
| match result { | ||
| Ok(()) => move_file_replace(&tmp, path).inspect_err(|_| { | ||
| // A failed publish (e.g. the dest is loaded → SHARING_VIOLATION) leaves the | ||
| // original file untouched; drop the orphan temp. | ||
| let _ = std::fs::remove_file(&tmp); | ||
| }), | ||
| Err(err) => { | ||
| let _ = std::fs::remove_file(&tmp); | ||
| Err(err) | ||
| } | ||
| } | ||
| } | ||
@@ -183,1 +262,94 @@ | ||
| } | ||
| /// NTFS has no reflink/clone primitive (ReFS block-cloning is out of scope) — | ||
| /// always report "cannot clone" so the caller takes the byte-copy path, which | ||
| /// re-applies `FSCTL_SET_COMPRESSION` at the destination. | ||
| pub(crate) fn clone_file(_src: &Path, _dest: &Path) -> Result<bool, Error> { | ||
| Ok(false) | ||
| } | ||
| #[cfg(test)] | ||
| #[cfg_attr(coverage_nightly, coverage(off))] | ||
| mod tests { | ||
| use super::*; | ||
| fn scratch(tag: &str) -> std::path::PathBuf { | ||
| let dir = std::env::temp_dir().join(format!("decmpfs-win-{tag}-{}", std::process::id())); | ||
| std::fs::create_dir_all(&dir).unwrap(); | ||
| dir | ||
| } | ||
| fn on_ntfs(dir: &Path) -> bool { | ||
| let probe = dir.join(".ntfs-probe"); | ||
| std::fs::write(&probe, b"x").unwrap(); | ||
| let yes = matches!(detect(&probe), Ok(Support::Supported)); | ||
| std::fs::remove_file(&probe).ok(); | ||
| yes | ||
| } | ||
| #[test] | ||
| fn unique_tmp_never_collides_and_is_well_formed() { | ||
| let dir = std::env::temp_dir(); | ||
| let a = unique_tmp(&dir, "addon.node"); | ||
| let b = unique_tmp(&dir, "addon.node"); | ||
| assert_ne!(a, b, "successive temps must differ (the seq counter)"); | ||
| for p in [&a, &b] { | ||
| let f = p.file_name().unwrap().to_string_lossy(); | ||
| assert!( | ||
| f.starts_with(".addon.node.decmpfs-"), | ||
| "unexpected temp name: {f}" | ||
| ); | ||
| assert!(f.ends_with(".tmp"), "unexpected temp name: {f}"); | ||
| } | ||
| } | ||
| #[test] | ||
| fn clone_file_always_declines_on_ntfs() { | ||
| let dir = scratch("clone"); | ||
| assert!(!clone_file(&dir.join("a"), &dir.join("b")).unwrap()); | ||
| std::fs::remove_dir_all(&dir).ok(); | ||
| } | ||
| #[test] | ||
| fn detect_is_ok_and_a_fresh_file_is_not_compressed() { | ||
| let dir = scratch("detect"); | ||
| let path = dir.join("f.bin"); | ||
| std::fs::write(&path, b"MZ plain").unwrap(); | ||
| assert!(detect(&path).is_ok()); | ||
| assert!(!is_already_compressed(&path).unwrap()); | ||
| std::fs::remove_dir_all(&dir).ok(); | ||
| } | ||
| #[test] | ||
| fn apply_bytes_round_trips_atomically_and_races_converge_on_ntfs() { | ||
| let dir = scratch("rt"); | ||
| if !on_ntfs(&dir) { | ||
| std::fs::remove_dir_all(&dir).ok(); | ||
| return; | ||
| } | ||
| let content = vec![0xCDu8; 128 * 1024]; | ||
| let dest = dir.join("addon.node"); | ||
| // Eight writers racing on the SAME dest must all converge to identical | ||
| // bytes — the CREATE_NEW unique-temp + MoveFileEx replace prevents the | ||
| // partial/interleaved write the old CREATE_ALWAYS-in-place path allowed. | ||
| std::thread::scope(|s| { | ||
| for _ in 0..8 { | ||
| let dest = dest.clone(); | ||
| let content = content.clone(); | ||
| s.spawn(move || { | ||
| let _ = apply_bytes(&dest, &content, None); | ||
| }); | ||
| } | ||
| }); | ||
| assert_eq!( | ||
| std::fs::read(&dest).unwrap(), | ||
| content, | ||
| "bytes survive the race" | ||
| ); | ||
| assert!( | ||
| is_already_compressed(&dest).unwrap(), | ||
| "landed NTFS-compressed" | ||
| ); | ||
| std::fs::remove_dir_all(&dir).ok(); | ||
| } | ||
| } |
| name: 📦 Publish to npm | ||
| # Dependencies: none — self-contained. | ||
| # | ||
| # The publish step is INLINED (not delegated to a reusable workflow) because npm | ||
| # OIDC trusted publishing keys on the workflow file that contains the publish step: | ||
| # `job_workflow_ref` must be a stable | ||
| # decmpfs/decmpfs/.github/workflows/provenance.yml@<ref> for the trusted-publisher | ||
| # config to match. (socket-addon delegates to socket-registry's reusable, but that | ||
| # is Socket infra; decmpfs is independent, so it follows socket-lib's inlined | ||
| # pattern.) | ||
| # | ||
| # These are addons (a `.node` dlopen'd into Node), so each platform builds on its | ||
| # own matrix host, then the publish job publishes every @decmpfs/<triple> package | ||
| # plus the main `decmpfs` package with provenance. | ||
| on: | ||
| workflow_dispatch: | ||
| inputs: | ||
| dry-run: | ||
| description: 'Dry run (default: true)' | ||
| required: false | ||
| default: true | ||
| type: boolean | ||
| dist-tag: | ||
| description: 'npm dist-tag (latest, next, beta, …)' | ||
| required: false | ||
| default: latest | ||
| type: string | ||
| permissions: | ||
| contents: read | ||
| concurrency: | ||
| group: ${{ github.workflow }}-${{ github.ref }} | ||
| cancel-in-progress: false # Don't cancel publishing. | ||
| jobs: | ||
| build: | ||
| name: Build ${{ matrix.triple }} | ||
| runs-on: ${{ matrix.runner }} | ||
| timeout-minutes: 20 | ||
| strategy: | ||
| fail-fast: false | ||
| matrix: | ||
| include: | ||
| - { triple: darwin-arm64, runner: macos-14 } | ||
| - { triple: darwin-x64, runner: macos-13 } | ||
| - { triple: linux-x64-gnu, runner: ubuntu-latest } | ||
| - { triple: linux-arm64-gnu, runner: ubuntu-24.04-arm } | ||
| - { triple: win32-x64-msvc, runner: windows-latest } | ||
| steps: | ||
| - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 (2026-06-29) | ||
| - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 (2026-06-29) | ||
| with: | ||
| node-version: 24 | ||
| - name: Build addon | ||
| working-directory: node | ||
| run: npm run build | ||
| - name: Test addon | ||
| working-directory: node | ||
| run: npm test | ||
| - name: Assemble platform package | ||
| working-directory: node | ||
| run: node scripts/make-npm-dirs.mts | ||
| - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 (2026-06-29) | ||
| with: | ||
| name: ${{ matrix.triple }} | ||
| path: node/npm/${{ matrix.triple }}/ | ||
| if-no-files-found: error | ||
| publish: | ||
| name: Publish to npm | ||
| needs: build | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 15 | ||
| # Real publishes run in the `release` environment (reviewer gate + a tighter | ||
| # OIDC trusted-publisher match); dry runs skip it for friction-free testing. | ||
| environment: ${{ inputs.dry-run == 'true' && '' || 'release' }} | ||
| permissions: | ||
| # Needed for npm provenance via OIDC trusted publishing. | ||
| id-token: write | ||
| contents: read | ||
| steps: | ||
| - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 (2026-06-29) | ||
| - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 (2026-06-29) | ||
| with: | ||
| node-version: 24 | ||
| registry-url: https://registry.npmjs.org/ | ||
| - name: Use a trusted-publishing-capable npm | ||
| run: npm install --global npm@latest | ||
| - name: Download platform packages | ||
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 (2026-06-29) | ||
| with: | ||
| path: node/npm | ||
| - name: Publish | ||
| working-directory: node | ||
| env: | ||
| DRY_RUN: ${{ inputs.dry-run }} | ||
| DIST_TAG: ${{ inputs.dist-tag }} | ||
| run: | | ||
| ARGS="--access public --tag ${DIST_TAG:-latest}" | ||
| if [ "$DRY_RUN" = "true" ]; then | ||
| ARGS="$ARGS --dry-run" | ||
| fi | ||
| # Platform packages first, then the main package that depends on them. | ||
| for dir in npm/*/; do | ||
| npm publish "$dir" $ARGS | ||
| done | ||
| npm publish . $ARGS |
| /target |
| <svg xmlns="http://www.w3.org/2000/svg" width="96" height="20" role="img" aria-label="coverage: 97%"> | ||
| <title>coverage: 97%</title> | ||
| <linearGradient id="g" x2="0" y2="100%"> | ||
| <stop offset="0" stop-color="#bbb" stop-opacity=".1"/> | ||
| <stop offset="1" stop-opacity=".1"/> | ||
| </linearGradient> | ||
| <clipPath id="r"><rect width="96" height="20" rx="3" fill="#fff"/></clipPath> | ||
| <g clip-path="url(#r)"> | ||
| <rect width="60" height="20" fill="#555"/> | ||
| <rect x="60" width="36" height="20" fill="#4c1"/> | ||
| <rect width="96" height="20" fill="url(#g)"/> | ||
| </g> | ||
| <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"> | ||
| <text x="300" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="500">coverage</text> | ||
| <text x="300" y="140" transform="scale(.1)" textLength="500">coverage</text> | ||
| <text x="780" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="240">97%</text> | ||
| <text x="780" y="140" transform="scale(.1)" textLength="240">97%</text> | ||
| </g> | ||
| </svg> |
| <svg xmlns="http://www.w3.org/2000/svg" width="100" height="20" role="img" aria-label="socket: score"> | ||
| <title>socket: score</title> | ||
| <linearGradient id="g" x2="0" y2="100%"> | ||
| <stop offset="0" stop-color="#bbb" stop-opacity=".1"/> | ||
| <stop offset="1" stop-opacity=".1"/> | ||
| </linearGradient> | ||
| <clipPath id="r"><rect width="100" height="20" rx="3" fill="#fff"/></clipPath> | ||
| <g clip-path="url(#r)"> | ||
| <rect width="52" height="20" fill="#555"/> | ||
| <rect x="52" width="48" height="20" fill="#5b51d8"/> | ||
| <rect width="100" height="20" fill="url(#g)"/> | ||
| </g> | ||
| <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"> | ||
| <text x="270" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="420">socket</text> | ||
| <text x="270" y="140" transform="scale(.1)" textLength="420">socket</text> | ||
| <text x="750" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="350">score</text> | ||
| <text x="750" y="140" transform="scale(.1)" textLength="350">score</text> | ||
| </g> | ||
| </svg> |
-21
| MIT License | ||
| Copyright (c) 2026 John-David Dalton | ||
| Permission is hereby granted, free of charge, to any person obtaining a copy | ||
| of this software and associated documentation files (the "Software"), to deal | ||
| in the Software without restriction, including without limitation the rights | ||
| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
| copies of the Software, and to permit persons to whom the Software is | ||
| furnished to do so, subject to the following conditions: | ||
| The above copyright notice and this permission notice shall be included in all | ||
| copies or substantial portions of the Software. | ||
| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||
| SOFTWARE. |
-58
| # decmpfs | ||
|  [](https://socket.dev/cargo/package/decmpfs) | ||
| Apply the operating system's **transparent per-file filesystem compression** to a | ||
| file — smaller on disk, byte-identical on read, decompressed by the kernel at | ||
| near-native speed. macOS APFS (decmpfs/LZVN), Linux btrfs (zstd→lzo→zlib), Windows | ||
| NTFS (LZNT1). | ||
| The core does the bytes-to-compressed-file write in **one pass** — it never writes a | ||
| file then reads it back to recompress. | ||
| ```rust | ||
| use decmpfs::{compress_bytes, compress_file, Gate}; | ||
| // Write `content` straight to an OS-compressed file (single pass). | ||
| let outcome = compress_bytes(path, &content, &Gate::any())?; | ||
| // Or compress a file that already exists, in place. | ||
| let outcome = compress_file(path)?; | ||
| ``` | ||
| ## Outcome, never a surprise error | ||
| Every call returns an `Outcome` — `Compressed`, `NoGain` (incompressible / | ||
| sub-cluster), `AlreadyCompressed`, `Unsupported` (the FS has no per-file | ||
| compression — ext4, xfs, ZFS, ReFS, FAT, tmpfs, network mounts), or `Skipped` | ||
| (permission, lock, gate). An unsupported filesystem or a permission issue is a | ||
| non-fatal `Outcome`, not an `Err`; `Err` is reserved for genuine I/O failures. | ||
| ## Gate | ||
| `Gate` decides which files to compress by glob and/or size: | ||
| ```rust | ||
| let gate = Gate::new(Some("**/*.node"), Some(">= 1MB"))?; | ||
| ``` | ||
| ## Codecs (speed over ratio — a file is written once, read on load) | ||
| - **macOS:** LZVN (the fastest decmpfs codec to decompress). | ||
| - **Linux btrfs:** zstd, falling back to lzo then zlib on older kernels. | ||
| - **Windows NTFS:** LZNT1 (survives a reinstall's open-for-write, unlike WOF). | ||
| ## Features | ||
| The core is dependency-light (`libc` / `windows-sys` only). The optional `addon` | ||
| feature pulls `zstd` + `sha2` to unwrap a napi `--compress` hybrid `.node` back to | ||
| the raw addon before compressing. | ||
| ## Node | ||
| An N-API binding (`writeDecmpfsFile` / `writeDecmpfsFileSync`, fs.writeFile-shaped, | ||
| atomic by default) lives in [`node/`](node/). | ||
| ## License | ||
| MIT |
Sorry, the diff of this file is not supported yet