Skip to main content

ewf_image/writer/
acquisition.rs

1//! Append-only EWF1 acquisition with immutable sealed-segment checkpoints.
2
3use std::fs::{self, File};
4use std::io::{Read, Write};
5use std::path::{Path, PathBuf};
6
7use sha2::{Digest, Sha256};
8use tempfile::NamedTempFile;
9
10use super::{
11    ChunkDescriptor, ChunkSpool, EWF1_TABLE_GROUP_MAX_ENTRIES, Ewf1SegmentSections,
12    Ewf1SegmentWriteContext, TerminalSection, WriteCompression, WriteHashState, WriteOptions,
13    WriteResult, effective_write_hashes, encode_chunk, header_payload, header2_payload,
14    publication_segment_paths, segment_path, validate_options, write_ewf1_segment,
15    writer_chunk_geometry, xheader_payload,
16};
17use crate::publication::{OutputLock, acquisition_path, sync_dir};
18use crate::{AcquisitionError, EwfError, EwfMetadata, Result};
19
20mod checkpoint;
21pub use checkpoint::{
22    AcquisitionCheckpoint, AcquisitionOperationPhase, AcquisitionOperationProgress,
23};
24use checkpoint::{load_checkpoint, operation_progress};
25use std::ops::ControlFlow;
26mod source;
27pub use source::{
28    AcquisitionOutcome, AcquisitionProgress, AcquisitionReadOptions, AcquisitionStatus,
29    UnreadableSectorPolicy,
30};
31
32#[cfg(test)]
33mod tests;
34
35/// Configuration for a new, single-destination physical E01 acquisition.
36/// The source size must be known, nonzero, and a multiple of the sector size.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct AcquisitionOptions {
39    /// Exact source size. Acquisition never pads or silently truncates input.
40    pub source_size: u64,
41    /// Bytes per sector; defaults to 512.
42    pub bytes_per_sector: u32,
43    /// Sectors per chunk; defaults to 64. Chunks may not exceed 16 MiB.
44    pub sectors_per_chunk: u32,
45    /// Maximum chunks per sealed segment, from 1 through 16,375.
46    /// Defaults to 16,375 (about 512 MiB of raw data at default geometry).
47    pub chunks_per_segment: u32,
48    /// Raw or zlib compression. `BZip2` is not supported by this EWF1 API.
49    pub compression: WriteCompression,
50    /// Acquisition metadata, fixed at creation and checked on resume.
51    pub metadata: EwfMetadata,
52}
53
54impl AcquisitionOptions {
55    /// Uses default E01 geometry and zlib compression for the given source size.
56    pub fn new(source_size: u64) -> Self {
57        Self {
58            source_size,
59            bytes_per_sector: 512,
60            sectors_per_chunk: 64,
61            chunks_per_segment: EWF1_TABLE_GROUP_MAX_ENTRIES as u32,
62            compression: WriteCompression::Zlib,
63            metadata: EwfMetadata::default(),
64        }
65    }
66
67    fn writer_options(&self) -> Result<WriteOptions> {
68        let (chunk_size, _) = writer_chunk_geometry(self.sectors_per_chunk, self.bytes_per_sector)?;
69        if self.source_size == 0
70            || !self
71                .source_size
72                .is_multiple_of(u64::from(self.bytes_per_sector))
73            || chunk_size > 16 * 1024 * 1024
74            || self.chunks_per_segment == 0
75            || self.chunks_per_segment as usize > EWF1_TABLE_GROUP_MAX_ENTRIES
76            || self.source_size.div_ceil(chunk_size) > u64::from(u32::MAX)
77        {
78            return Err(EwfError::Unsupported(
79                "unsupported acquisition geometry".into(),
80            ));
81        }
82        let options = WriteOptions {
83            sectors_per_chunk: self.sectors_per_chunk,
84            bytes_per_sector: self.bytes_per_sector,
85            compression: self.compression,
86            media_size: Some(self.source_size),
87            metadata: self.metadata.clone(),
88            ..WriteOptions::default()
89        };
90        validate_options(&options)?;
91        Ok(options)
92    }
93}
94
95/// Append-only physical E01 writer with resumable sealed segments.
96///
97/// RAM holds a fixed number of chunk buffers and at most `chunks_per_segment` descriptors. Scratch disk
98/// holds at most one encoded segment, plus its copy while sealing. Completed
99/// native segments accumulate in an adjacent `.ewf-acquisition` directory.
100/// [`Self::finish`] publishes them without copying payloads, using hard links;
101/// the destination filesystem must support hard links and file locking.
102///
103/// Drop preserves sealed checkpoints. Resume discards unsealed input, verifies
104/// every sealed segment, and rehashes its decoded media to restore digest state.
105/// Supply input again starting at [`Self::checkpoint_offset`]. No sealed segment
106/// is rewritten. The source identity is a caller-supplied assertion (for example
107/// a SHA256 of stable device identifiers); this API cannot detect source changes
108/// if the caller reuses an identity. Use a stable source snapshot when needed.
109///
110/// Flush seals complete chunks. A partial chunk remains uncheckpointed until
111/// filled or until the declared source size is reached. Any I/O failure poisons
112/// the writer: drop it and resume to discover the last successful checkpoint.
113/// Files are synchronized before checkpoint acknowledgement; directory entries
114/// are synchronized on Unix. Windows process-crash recovery is supported, but
115/// power-loss durability is not certified.
116pub struct AcquisitionWriter {
117    first: PathBuf,
118    state: PathBuf,
119    options: WriteOptions,
120    source_size: u64,
121    chunk_size: usize,
122    chunks_per_segment: usize,
123    offset: u64,
124    pending: Vec<u8>,
125    chunks: Vec<ChunkDescriptor>,
126    spool: Option<ChunkSpool>,
127    hashes: WriteHashState,
128    sealed: Vec<Seal>,
129    errors: Vec<AcquisitionError>,
130    substituted_sectors: u64,
131    failed: bool,
132    #[cfg(test)]
133    fail_seal: bool,
134    _lock: OutputLock,
135}
136
137#[derive(Clone)]
138struct Seal {
139    end: u64,
140    size: u64,
141    digest: [u8; 32],
142}
143
144impl AcquisitionWriter {
145    /// Creates a new acquisition. Existing E01-family output is never replaced.
146    /// The identity and all options must be identical when resuming.
147    pub fn create(
148        first: impl AsRef<Path>,
149        options: &AcquisitionOptions,
150        source_identity: [u8; 32],
151    ) -> Result<Self> {
152        let write_options = options.writer_options()?;
153        let first = normalize_first(first.as_ref())?;
154        validate_segment_budget(&first, options)?;
155        let lock = OutputLock::acquire(&first)?;
156        crate::publication::ensure_no_pending(&first)?;
157        ensure_output_absent(&first)?;
158        let state = acquisition_path(&first)?;
159        let init = tempfile::Builder::new()
160            .prefix(".ewf-acquisition-init-")
161            .tempdir_in(crate::segment::segment_dir(&first))?;
162        fs::create_dir(init.path().join("scratch"))?;
163        atomic_file(
164            init.path(),
165            &init.path().join("config"),
166            &fingerprint(&first, options, &write_options, source_identity)?,
167        )?;
168        // Fail before accepting source data on filesystems that cannot perform
169        // the eventual copy-free, exclusive publication.
170        let probe = init.path().join("hard-link-probe");
171        fs::hard_link(init.path().join("config"), &probe)?;
172        fs::remove_file(probe)?;
173        sync_dir(init.path())?;
174        fs::rename(init.path(), &state)?;
175        sync_dir(crate::segment::segment_dir(&first))?;
176        Self::new(first, state, write_options, options, lock)
177    }
178
179    /// Reopens an interrupted acquisition and validates its sealed prefix.
180    /// Returns an error for a different identity/configuration, corrupt or missing
181    /// sealed data, or a live writer. Work is proportional to checkpointed bytes.
182    /// A fully acquired but unpublished set can be resumed and finished directly.
183    pub fn resume(
184        first: impl AsRef<Path>,
185        options: &AcquisitionOptions,
186        source_identity: [u8; 32],
187    ) -> Result<Self> {
188        Self::resume_with_progress(first, options, source_identity, |_| {
189            ControlFlow::Continue(())
190        })
191    }
192
193    /// Resumes with cancellable segment validation and media rehash progress.
194    /// Cancellation returns `EwfError::Aborted` and leaves checkpoint files intact.
195    pub fn resume_with_progress(
196        first: impl AsRef<Path>,
197        options: &AcquisitionOptions,
198        source_identity: [u8; 32],
199        mut on_progress: impl FnMut(AcquisitionOperationProgress) -> ControlFlow<()>,
200    ) -> Result<Self> {
201        let write_options = options.writer_options()?;
202        let first = normalize_first(first.as_ref())?;
203        validate_segment_budget(&first, options)?;
204        let lock = OutputLock::acquire(&first)?;
205        let checkpoint = load_checkpoint(
206            &first,
207            options,
208            &write_options,
209            source_identity,
210            true,
211            &mut on_progress,
212        )?;
213        let state = checkpoint.state;
214        let sealed = checkpoint.sealed;
215        let previous = checkpoint.report.checkpoint_bytes;
216        let count = sealed.len();
217        let errors = checkpoint.report.acquisition_errors;
218        let mut hashes = WriteHashState::new();
219        if let Some(image) = checkpoint.image {
220            let mut progress =
221                operation_progress(AcquisitionOperationPhase::RehashingMedia, previous, count);
222            checkpoint::notify(progress, &mut on_progress)?;
223            let mut cursor = image.cursor();
224            let mut buffer = vec![0; image.chunk_size() as usize];
225            loop {
226                let size = cursor.read(&mut buffer)?;
227                if size == 0 {
228                    break;
229                }
230                hashes.update(&buffer[..size]);
231                progress.bytes_processed += size as u64;
232                progress.segments_processed =
233                    sealed.partition_point(|seal| seal.end <= progress.bytes_processed);
234                checkpoint::notify(progress, &mut on_progress)?;
235            }
236        }
237        // Validate first; only uncommitted scratch and the possible next segment
238        // may be discarded. Sealed segments and their records stay immutable.
239        if previous < options.source_size {
240            let unsealed = staged_path(&first, &state, count + 1)?;
241            match fs::symlink_metadata(&unsealed) {
242                Ok(metadata) => {
243                    require_file_type(&metadata)?;
244                    fs::remove_file(unsealed)?;
245                }
246                Err(error) if error.kind() == std::io::ErrorKind::NotFound => (),
247                Err(error) => return Err(error.into()),
248            }
249        }
250        for entry in fs::read_dir(state.join("scratch"))? {
251            let entry = entry?;
252            require_file_type(&fs::symlink_metadata(entry.path())?)?;
253            fs::remove_file(entry.path())?;
254        }
255        let mut writer = Self::new(first, state, write_options, options, lock)?;
256        writer.offset = previous;
257        writer.hashes = hashes;
258        writer.sealed = sealed;
259        writer.substituted_sectors = errors.iter().map(|range| range.sector_count).sum();
260        writer.errors = errors;
261        Ok(writer)
262    }
263
264    fn new(
265        first: PathBuf,
266        state: PathBuf,
267        options: WriteOptions,
268        acquisition: &AcquisitionOptions,
269        lock: OutputLock,
270    ) -> Result<Self> {
271        let chunk_size =
272            (u64::from(options.bytes_per_sector) * u64::from(options.sectors_per_chunk)) as usize;
273        let spool = ChunkSpool {
274            file: NamedTempFile::new_in(state.join("scratch"))?,
275            len: 0,
276        };
277        Ok(Self {
278            first,
279            state,
280            options,
281            source_size: acquisition.source_size,
282            chunk_size,
283            chunks_per_segment: acquisition.chunks_per_segment as usize,
284            offset: 0,
285            pending: Vec::with_capacity(chunk_size),
286            chunks: Vec::with_capacity(acquisition.chunks_per_segment as usize),
287            spool: Some(spool),
288            hashes: WriteHashState::new(),
289            sealed: Vec::new(),
290            errors: Vec::new(),
291            substituted_sectors: 0,
292            failed: false,
293            #[cfg(test)]
294            fail_seal: false,
295            _lock: lock,
296        })
297    }
298
299    /// Number of bytes accepted in this acquisition, including unsealed bytes.
300    pub fn position(&self) -> u64 {
301        self.offset
302    }
303
304    /// Source offset durable at the last acknowledged sealed-segment checkpoint.
305    pub fn checkpoint_offset(&self) -> u64 {
306        self.sealed.last().map_or(0, |seal| seal.end)
307    }
308
309    /// Number of immutable native EWF segments checkpointed so far.
310    pub fn sealed_segments(&self) -> usize {
311        self.sealed.len()
312    }
313
314    /// Unreadable source sectors replaced with zeroes, including unsealed input.
315    /// Checkpointed ranges are recovered from native EWF error sections on resume.
316    pub fn acquisition_errors(&self) -> &[AcquisitionError] {
317        &self.errors
318    }
319
320    /// Seals complete buffered chunks and returns the resumable source offset.
321    /// Any partial chunk remains in memory and is not included in this offset.
322    pub fn checkpoint(&mut self) -> Result<u64> {
323        self.ensure_healthy()?;
324        if !self.chunks.is_empty() {
325            let result = self.seal(false);
326            self.failed = result.is_err();
327            result?;
328        }
329        Ok(self.checkpoint_offset())
330    }
331
332    /// Completes and publishes the exact declared source, including MD5, SHA1,
333    /// and SHA256 references. Too little input is an error, never an implicit pad.
334    /// Before journal retirement, a publication error preserves checkpoints for
335    /// `resume(...).finish()`. A cleanup failure after retirement can leave an
336    /// already completed image; open the image to check that outcome.
337    pub fn finish(self) -> Result<WriteResult> {
338        self.finish_with_progress(|_| ControlFlow::Continue(()))
339    }
340
341    /// Validates and publishes with cooperative cancellation. Returning `Break`
342    /// leaves the acquisition resumable, including after some output links have
343    /// been installed. Cancellation is checked before journal retirement; cleanup
344    /// after that commit point is not cancellable.
345    pub fn finish_with_progress(
346        mut self,
347        mut on_progress: impl FnMut(AcquisitionOperationProgress) -> ControlFlow<()>,
348    ) -> Result<WriteResult> {
349        self.ensure_healthy()?;
350        if self.checkpoint_offset() != self.source_size {
351            return Err(EwfError::Malformed(
352                "acquisition source is incomplete".into(),
353            ));
354        }
355        let paths = (1..=self.sealed.len())
356            .map(|index| segment_path(&self.first, index))
357            .collect::<Result<Vec<_>>>()?;
358        let stored_bytes = self
359            .sealed
360            .iter()
361            .try_fold(0_u64, |total, seal| total.checked_add(seal.size))
362            .ok_or_else(|| EwfError::Malformed("acquisition stored-size overflow".into()))?;
363        let mut progress = operation_progress(
364            AcquisitionOperationPhase::ValidatingSegments,
365            stored_bytes,
366            self.sealed.len(),
367        );
368        checkpoint::notify(progress, &mut on_progress)?;
369        for (index, seal) in self.sealed.iter().enumerate() {
370            seal.validate_with_progress(
371                &staged_path(&self.first, &self.state, index + 1)?,
372                &mut progress,
373                &mut on_progress,
374            )?;
375        }
376        let mut progress = operation_progress(
377            AcquisitionOperationPhase::Publishing,
378            stored_bytes,
379            self.sealed.len(),
380        );
381        checkpoint::notify(progress, &mut on_progress)?;
382        let publishing = self.state.join("publishing");
383        if !publishing.try_exists()? {
384            ensure_output_absent(&self.first)?;
385            atomic_file(&self.state.join("scratch"), &publishing, &[])?;
386            sync_dir(&self.state)?;
387        }
388        #[cfg(test)]
389        tests::crash_at("publishing", 0);
390        if publication_segment_paths(&paths, false)?.len() != paths.len() {
391            return Err(EwfError::Malformed(
392                "unexpected acquisition output segments".into(),
393            ));
394        }
395        for (index, (path, seal)) in paths.iter().zip(&self.sealed).enumerate() {
396            let staged = staged_path(&self.first, &self.state, index + 1)?;
397            match fs::hard_link(staged, path) {
398                Ok(()) => (),
399                Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
400                    // Existing links can result from interrupted publication.
401                    // Revalidate their contents without counting the bytes twice.
402                    let mut validation = operation_progress(
403                        AcquisitionOperationPhase::ValidatingSegments,
404                        seal.size,
405                        1,
406                    );
407                    seal.validate_with_progress(path, &mut validation, &mut on_progress)?;
408                }
409                Err(error) => return Err(error.into()),
410            }
411            progress.bytes_processed += seal.size;
412            progress.segments_processed += 1;
413            checkpoint::notify(progress, &mut on_progress)?;
414            #[cfg(test)]
415            tests::crash_at("linked", index + 1);
416        }
417        sync_dir(crate::segment::segment_dir(&self.first))?;
418        #[cfg(test)]
419        tests::crash_at("published", 0);
420        // Retire the entire journal atomically. Interruption during deletion
421        // leaves only inert cleanup files, never a half-deleted live checkpoint.
422        drop(self.spool.take());
423        let cleanup = tempfile::Builder::new()
424            .prefix(".ewf-acquisition-cleanup-")
425            .tempdir_in(crate::segment::segment_dir(&self.first))?;
426        fs::rename(&self.state, cleanup.path().join("retired"))?;
427        sync_dir(crate::segment::segment_dir(&self.first))?;
428        #[cfg(test)]
429        tests::crash_at("retired", 0);
430        cleanup.close()?;
431        let (_, _, computed_sha256) = self.hashes.finalize();
432        Ok(WriteResult {
433            segment_paths: paths,
434            secondary_segment_paths: Vec::new(),
435            logical_size: self.source_size,
436            chunk_size: self.chunk_size as u64,
437            chunk_count: self.source_size.div_ceil(self.chunk_size as u64),
438            computed_sha256,
439        })
440    }
441
442    fn ensure_healthy(&self) -> Result<()> {
443        if self.failed {
444            return Err(EwfError::Unsupported(
445                "acquisition failed; drop and resume it".into(),
446            ));
447        }
448        Ok(())
449    }
450
451    fn append(&mut self, bytes: &[u8]) -> Result<usize> {
452        self.ensure_healthy()?;
453        if bytes.is_empty() {
454            return Ok(0);
455        }
456        if self.offset == self.source_size {
457            return Err(EwfError::Malformed(
458                "input exceeds declared acquisition size".into(),
459            ));
460        }
461        let take = bytes
462            .len()
463            .min(self.chunk_size - self.pending.len())
464            .min((self.source_size - self.offset).min(usize::MAX as u64) as usize);
465        self.pending.extend_from_slice(&bytes[..take]);
466        self.offset += take as u64;
467        if self.pending.len() == self.chunk_size || self.offset == self.source_size {
468            self.hashes.update(&self.pending);
469            let data = std::mem::replace(&mut self.pending, Vec::with_capacity(self.chunk_size));
470            let encoded = encode_chunk(
471                data,
472                self.options.compression,
473                self.options.compression_values,
474                self.chunk_size as u64,
475                false,
476                false,
477            )?;
478            self.chunks
479                .push(self.spool.as_mut().expect("active spool").append(encoded)?);
480            if self.chunks.len() == self.chunks_per_segment || self.offset == self.source_size {
481                self.seal(self.offset == self.source_size)?;
482            }
483        }
484        Ok(take)
485    }
486
487    fn seal(&mut self, final_segment: bool) -> Result<()> {
488        let index = self.sealed.len() + 1;
489        let target = staged_path(&self.first, &self.state, index)?;
490        let mut options = self.options.clone();
491        let sector_size = u64::from(options.bytes_per_sector);
492        let end_sector = (self.offset - self.pending.len() as u64) / sector_size;
493        options.acquisition_errors = self
494            .errors
495            .iter()
496            .filter_map(|range| {
497                // libewf replaces its error table when a later segment carries
498                // error2. Keep each sealed prefix independently resumable and
499                // make the last table describe the entire acquired prefix.
500                let start = range.first_sector;
501                let end = (range.first_sector + range.sector_count).min(end_sector);
502                (start < end).then(|| AcquisitionError {
503                    first_sector: start,
504                    sector_count: end - start,
505                })
506            })
507            .collect();
508        let mut sections = Ewf1SegmentSections::for_segment(index == 1, final_segment);
509        sections.bits |= Ewf1SegmentSections::ERRORS;
510        if final_segment {
511            let (md5, sha1, sha256) = self.hashes.clone().finalize();
512            options.hashes = effective_write_hashes(&options.hashes, md5, sha1, sha256)?;
513        }
514        let mut segment = NamedTempFile::new_in(self.state.join("scratch"))?;
515        let spool = self.spool.as_mut().expect("active spool");
516        write_ewf1_segment(
517            segment.as_file_mut(),
518            spool,
519            &self.chunks,
520            &options,
521            Ewf1SegmentWriteContext {
522                segment_number: u16::try_from(index)
523                    .map_err(|_| EwfError::Unsupported("too many acquisition segments".into()))?,
524                chunk_count: self.source_size.div_ceil(self.chunk_size as u64) as u32,
525                sector_count: self.source_size / u64::from(options.bytes_per_sector),
526                sections,
527                terminal_section: if final_segment {
528                    TerminalSection::Done
529                } else {
530                    TerminalSection::Next
531                },
532            },
533        )?;
534        segment.as_file().sync_all()?;
535        #[cfg(test)]
536        {
537            tests::crash_at("segment-synced", index);
538            if self.fail_seal {
539                return Err(std::io::Error::new(
540                    std::io::ErrorKind::StorageFull,
541                    "injected acquisition storage failure",
542                )
543                .into());
544            }
545        }
546        let size = segment.as_file().metadata()?.len();
547        let digest = file_digest(segment.path())?;
548        segment
549            .persist_noclobber(&target)
550            .map_err(|error| error.error)?;
551        sync_dir(&self.state)?;
552        #[cfg(test)]
553        tests::crash_at("segment-installed", index);
554        let seal = Seal {
555            end: self.offset - self.pending.len() as u64,
556            size,
557            digest,
558        };
559        atomic_file(
560            &self.state.join("scratch"),
561            &checkpoint_path(&self.state, index),
562            &seal.bytes(),
563        )?;
564        #[cfg(test)]
565        tests::crash_at("record-installed", index);
566        sync_dir(&self.state)?;
567        #[cfg(test)]
568        tests::crash_at("checkpoint-synced", index);
569        self.sealed.push(seal);
570        self.chunks.clear();
571        spool.file.as_file_mut().set_len(0)?;
572        spool.len = 0;
573        Ok(())
574    }
575}
576
577fn normalize_errors(
578    errors: &[AcquisitionError],
579    bytes: u64,
580    sector_size: u32,
581) -> Result<Vec<AcquisitionError>> {
582    let mut errors = errors.to_vec();
583    errors.sort_unstable_by_key(|range| range.first_sector);
584    let mut normalized: Vec<AcquisitionError> = Vec::new();
585    for range in errors {
586        let end = range
587            .first_sector
588            .checked_add(range.sector_count)
589            .filter(|end| *end <= bytes / u64::from(sector_size))
590            .ok_or_else(|| {
591                EwfError::Malformed("acquisition error range exceeds checkpoint".into())
592            })?;
593        if range.sector_count == 0 {
594            return Err(EwfError::Malformed("empty acquisition error range".into()));
595        }
596        if let Some(previous) = normalized.last_mut()
597            && range.first_sector <= previous.first_sector + previous.sector_count
598        {
599            previous.sector_count =
600                end.max(previous.first_sector + previous.sector_count) - previous.first_sector;
601        } else {
602            normalized.push(range);
603        }
604    }
605    Ok(normalized)
606}
607
608impl Write for AcquisitionWriter {
609    fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
610        let result = self.append(bytes);
611        self.failed |= result.is_err();
612        result.map_err(std::io::Error::other)
613    }
614
615    fn flush(&mut self) -> std::io::Result<()> {
616        self.checkpoint().map(|_| ()).map_err(std::io::Error::other)
617    }
618}
619
620impl Seal {
621    fn bytes(&self) -> Vec<u8> {
622        let mut bytes = Vec::with_capacity(80);
623        bytes.extend_from_slice(&self.end.to_le_bytes());
624        bytes.extend_from_slice(&self.size.to_le_bytes());
625        bytes.extend_from_slice(&self.digest);
626        bytes.extend_from_slice(&Sha256::digest(&bytes));
627        bytes
628    }
629
630    fn read(path: &Path) -> Result<Self> {
631        let bytes = read_fixed::<80>(path)?;
632        if Sha256::digest(&bytes[..48])[..] != bytes[48..] {
633            return Err(EwfError::Malformed("damaged acquisition checkpoint".into()));
634        }
635        Ok(Self {
636            end: u64::from_le_bytes(bytes[..8].try_into().expect("fixed length")),
637            size: u64::from_le_bytes(bytes[8..16].try_into().expect("fixed length")),
638            digest: bytes[16..48].try_into().expect("fixed length"),
639        })
640    }
641
642    fn validate_with_progress(
643        &self,
644        path: &Path,
645        progress: &mut AcquisitionOperationProgress,
646        callback: &mut impl FnMut(AcquisitionOperationProgress) -> ControlFlow<()>,
647    ) -> Result<()> {
648        let metadata = fs::symlink_metadata(path)?;
649        require_file_type(&metadata)?;
650        if metadata.len() != self.size {
651            return Err(EwfError::Malformed(
652                "sealed acquisition segment changed".into(),
653            ));
654        }
655        let mut file = File::open(path)?;
656        let mut hash = Sha256::new();
657        let mut buffer = [0; 16 * 1024];
658        let mut remaining = self.size;
659        while remaining > 0 {
660            let take = remaining.min(buffer.len() as u64) as usize;
661            file.read_exact(&mut buffer[..take])?;
662            hash.update(&buffer[..take]);
663            remaining -= take as u64;
664            progress.bytes_processed += take as u64;
665            checkpoint::notify(*progress, callback)?;
666        }
667        if <[u8; 32]>::from(hash.finalize()) != self.digest {
668            return Err(EwfError::Malformed(
669                "sealed acquisition segment changed".into(),
670            ));
671        }
672        progress.segments_processed += 1;
673        checkpoint::notify(*progress, callback)
674    }
675}
676
677fn normalize_first(first: &Path) -> Result<PathBuf> {
678    if first.extension().and_then(|name| name.to_str()) != Some("E01") {
679        return Err(EwfError::Unsupported(
680            "acquisition requires an .E01 first segment".into(),
681        ));
682    }
683    Ok(crate::segment::segment_dir(first)
684        .canonicalize()?
685        .join(first.file_name().expect("E01 filename")))
686}
687
688fn validate_segment_budget(first: &Path, options: &AcquisitionOptions) -> Result<()> {
689    let chunk_size = u64::from(options.bytes_per_sector) * u64::from(options.sectors_per_chunk);
690    let segments = options
691        .source_size
692        .div_ceil(chunk_size)
693        .div_ceil(u64::from(options.chunks_per_segment));
694    segment_path(
695        first,
696        usize::try_from(segments)
697            .map_err(|_| EwfError::Unsupported("too many acquisition segments".into()))?,
698    )?;
699    Ok(())
700}
701
702fn fingerprint(
703    first: &Path,
704    acquisition: &AcquisitionOptions,
705    options: &WriteOptions,
706    source: [u8; 32],
707) -> Result<[u8; 32]> {
708    let mut hash = Sha256::new();
709    hash.update(b"ewf-image-acquisition-v1");
710    hash.update(source);
711    hash.update(acquisition.source_size.to_le_bytes());
712    hash.update(acquisition.bytes_per_sector.to_le_bytes());
713    hash.update(acquisition.sectors_per_chunk.to_le_bytes());
714    hash.update(acquisition.chunks_per_segment.to_le_bytes());
715    hash.update([u8::from(acquisition.compression == WriteCompression::Zlib)]);
716    let path = first.as_os_str().as_encoded_bytes();
717    hash.update((path.len() as u64).to_le_bytes());
718    hash.update(path);
719    for payload in [
720        header_payload(
721            &options.metadata,
722            options.header_codepage,
723            options.compression_values.level,
724        )?,
725        header2_payload(&options.metadata, options.compression_values.level)?,
726        xheader_payload(&options.metadata, options.compression_values.level)?,
727    ] {
728        let payload = payload.unwrap_or_default();
729        hash.update((payload.len() as u64).to_le_bytes());
730        hash.update(payload);
731    }
732    Ok(hash.finalize().into())
733}
734
735fn staged_path(first: &Path, state: &Path, index: usize) -> Result<PathBuf> {
736    Ok(state.join(
737        segment_path(first, index)?
738            .file_name()
739            .expect("segment filename"),
740    ))
741}
742
743fn checkpoint_path(state: &Path, index: usize) -> PathBuf {
744    state.join(format!("checkpoint-{index:05}"))
745}
746
747fn checkpoint_count(state: &Path) -> Result<usize> {
748    let mut indices = Vec::new();
749    for entry in fs::read_dir(state)? {
750        let entry = entry?;
751        let name = entry.file_name();
752        if let Some(index) = name
753            .to_str()
754            .and_then(|name| name.strip_prefix("checkpoint-"))
755        {
756            let index = index
757                .parse::<u16>()
758                .map_err(|_| EwfError::Malformed("invalid acquisition checkpoint name".into()))?;
759            if entry.path() != checkpoint_path(state, usize::from(index)) {
760                return Err(EwfError::Malformed(
761                    "invalid acquisition checkpoint name".into(),
762                ));
763            }
764            indices.push(index);
765        }
766    }
767    indices.sort_unstable();
768    if indices
769        .iter()
770        .enumerate()
771        .any(|(index, value)| index + 1 != usize::from(*value))
772    {
773        return Err(EwfError::Malformed("missing acquisition checkpoint".into()));
774    }
775    Ok(indices.len())
776}
777
778fn atomic_file(scratch: &Path, target: &Path, bytes: &[u8]) -> Result<()> {
779    let mut file = NamedTempFile::new_in(scratch)?;
780    file.write_all(bytes)?;
781    file.as_file().sync_all()?;
782    file.persist_noclobber(target)
783        .map_err(|error| error.error)?;
784    Ok(())
785}
786
787fn read_fixed<const N: usize>(path: &Path) -> Result<[u8; N]> {
788    let metadata = fs::symlink_metadata(path)?;
789    require_file_type(&metadata)?;
790    if metadata.len() != N as u64 {
791        return Err(EwfError::Malformed(
792            "invalid acquisition record length".into(),
793        ));
794    }
795    let mut bytes = [0; N];
796    File::open(path)?.read_exact(&mut bytes)?;
797    Ok(bytes)
798}
799
800fn require_file_type(metadata: &fs::Metadata) -> Result<()> {
801    if !metadata.file_type().is_file() {
802        return Err(EwfError::Malformed(
803            "acquisition path is not a regular file".into(),
804        ));
805    }
806    Ok(())
807}
808
809fn require_directory(path: &Path) -> Result<()> {
810    if !fs::symlink_metadata(path)?.file_type().is_dir() {
811        return Err(EwfError::Malformed(
812            "acquisition path is not a directory".into(),
813        ));
814    }
815    Ok(())
816}
817
818fn file_digest(path: &Path) -> Result<[u8; 32]> {
819    let mut file = File::open(path)?;
820    let mut hash = Sha256::new();
821    let mut buffer = [0; 16 * 1024];
822    loop {
823        let size = file.read(&mut buffer)?;
824        if size == 0 {
825            break;
826        }
827        hash.update(&buffer[..size]);
828    }
829    Ok(hash.finalize().into())
830}
831
832fn ensure_output_absent(first: &Path) -> Result<()> {
833    for path in publication_segment_paths(&[first.to_path_buf()], false)? {
834        match fs::symlink_metadata(path) {
835            Err(error) if error.kind() == std::io::ErrorKind::NotFound => (),
836            Err(error) => return Err(error.into()),
837            Ok(_) => {
838                return Err(std::io::Error::new(
839                    std::io::ErrorKind::AlreadyExists,
840                    "acquisition output already exists",
841                )
842                .into());
843            }
844        }
845    }
846    Ok(())
847}