-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcheckpoint.rs
More file actions
5027 lines (4744 loc) · 206 KB
/
Copy pathcheckpoint.rs
File metadata and controls
5027 lines (4744 loc) · 206 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
//! Crash-safe checkpoint persistence for resumable extractions.
//!
//! A [`Checkpoint`] captures everything the §10 coordinator needs to
//! pick a download + extraction back up after a `kill -9`: which source
//! was being fetched (URL + ETag/Last-Modified), how the source is
//! sliced (`total_size`, `chunk_size`), the lock-free completion bitmap
//! the workers had populated, the decoder cursor, and a sink-specific
//! [`SinkState`] blob describing how far into extraction we'd gotten.
//!
//! # File layout
//!
//! Checkpoints are not JSON. The format is a tiny custom binary frame
//! so we can verify framing, version, and integrity in three reads
//! without dragging in a serialization crate. All multi-byte integers
//! are little-endian.
//!
//! ```text
//! [ Header — 28 bytes, fixed ]
//! 8 B magic = "peelckpt"
//! 4 B format_version (u32)
//! 8 B body_length (u64)
//! 8 B body_checksum (u64) // FNV-1a-64 over the body bytes
//! [ Body — body_length bytes, length-prefixed fields per version ]
//! u32 url_len + url utf8 bytes
//! u8 etag_present + (u32 len + etag utf8 bytes)?
//! u8 last_modified_present + (u32 len + last_modified utf8 bytes)?
//! u64 total_size
//! u64 chunk_size
//! u64 decoder_position
//! u32 bitmap_len + bitmap bytes
//! i64 created_at_unix_secs
//! u32 created_at_nanos
//! u8 sink_state_tag
//! tag 0 (Raw): u64 bytes_written
//! tag 1 (Tar): u32 count, then count × (u32 len + utf8 bytes)
//! tag 2 (Zip, v2+):
//! u32 entries_completed.len, then count × u32 index
//! u8 current_entry.is_some
//! if some: u32 current_entry_index
//! u64 current_entry_offset
//! u8 hash_state_present (v3+ only)
//! if 1: SERIALIZED_LEN bytes of `hash::sha256::Sha256` state
//! u8 chunk_crc32c_present (v4+ only)
//! if 1: u32 count, then count × u32 CRC-32C values
//! ```
//!
//! # Forward compatibility
//!
//! Adding fields is allowed at the **end** of the body in a future
//! `format_version`. Older readers that encounter a higher version
//! number than they understand fail with
//! [`CheckpointError::UnsupportedVersion`] rather than silently dropping
//! the unknown trailing data. Newer readers that encounter an older
//! version dispatch on the version number and parse the v1 layout.
//!
//! # Atomic writes
//!
//! [`Checkpoint::write`] writes to `<path>.tmp`, `fsync`s the data and
//! then the parent directory, and finally `rename(2)`s `<path>.tmp` over
//! `<path>`. On every supported filesystem `rename` is atomic, so a
//! crash at any point leaves either the previous `<path>` intact or the
//! new one in place — never a torn write. A stray `<path>.tmp` from a
//! crashed previous run is overwritten by the next `write` and ignored
//! by [`Checkpoint::read`].
//!
//! # Examples
//!
//! ```no_run
//! use peel::checkpoint::{Checkpoint, PartRecord, RunMode, SinkState};
//! use peel::types::ByteOffset;
//!
//! let url: String = "https://example.com/dataset.tar.zst".into();
//! let total_size: u64 = 10 * 1024 * 1024;
//! let etag = Some("\"abc123\"".to_string());
//! let ckpt = Checkpoint {
//! url: url.clone(),
//! etag: etag.clone(),
//! last_modified: None,
//! parts: vec![PartRecord {
//! url,
//! size: total_size,
//! etag,
//! last_modified: None,
//! expected_sha256: None,
//! volume_role: None,
//! }],
//! total_size,
//! chunk_size: 4 * 1024 * 1024,
//! decoder_position: ByteOffset::new(2 * 1024 * 1024),
//! bitmap_completed: vec![0xFF, 0x0F],
//! created_at: std::time::SystemTime::now(),
//! sink_state: SinkState::Tar {
//! members_completed: vec!["root/a.txt".into()],
//! in_flight: None,
//! },
//! hash_state: None,
//! chunk_crc32c: None,
//! decoder_state: None,
//! mode: RunMode::Extract,
//! source_mtime: None,
//! cumulative_elapsed: std::time::Duration::ZERO,
//! };
//! ckpt.write(std::path::Path::new("/tmp/peel-demo.ckpt"))?;
//! # Ok::<(), peel::checkpoint::CheckpointError>(())
//! ```
use std::fs::{File, OpenOptions};
use std::io::{ErrorKind, Read, Write};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use thiserror::Error;
use crate::types::ByteOffset;
/// Atomically publish `tmp` over `dst`, replacing any existing entry
/// at `dst`. The data already written to `tmp` is the visible
/// `dst` afterward; concurrent observers see either the old or the
/// new content, never a partial mix.
///
/// On Unix this is `rename(2)`, which atomically replaces the target
/// directory entry on the same filesystem.
///
/// On Windows `std::fs::rename` fails by default when the destination
/// exists; the equivalent atomic-replace primitive is
/// `MoveFileExW(REPLACE_EXISTING | WRITE_THROUGH)`. `WRITE_THROUGH`
/// commits the rename to disk before returning, which on NTFS gives
/// the same durability guarantee the Unix path gets from the
/// caller's parent-directory `fsync` (the Unix path that runs after
/// the rename on first publish). (`PLAN_v3_windows.md` §9.)
///
/// # Errors
///
/// Returns the underlying [`std::io::Error`] for any OS-level
/// failure.
fn atomic_publish(tmp: &Path, dst: &Path) -> std::io::Result<()> {
#[cfg(unix)]
{
std::fs::rename(tmp, dst)
}
#[cfg(windows)]
{
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Storage::FileSystem::{
MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH,
};
// Convert both paths to NUL-terminated UTF-16 buffers per
// the Win32 wide-string contract. `encode_wide()` does the
// OsStr → UTF-16 encoding; we append the trailing NUL.
let to_wide = |p: &Path| -> Vec<u16> {
let mut v: Vec<u16> = p.as_os_str().encode_wide().collect();
v.push(0);
v
};
let src_w = to_wide(tmp);
let dst_w = to_wide(dst);
// SAFETY: `src_w` and `dst_w` are NUL-terminated UTF-16
// buffers owned by the caller for the duration of this
// call; `MoveFileExW` reads them by pointer and never
// retains them past return. The flags are valid bits per
// <fileapi.h>. The function returns non-zero on success.
let rc = unsafe {
MoveFileExW(
src_w.as_ptr(),
dst_w.as_ptr(),
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
)
};
if rc == 0 {
return Err(std::io::Error::last_os_error());
}
Ok(())
}
#[cfg(not(any(unix, windows)))]
{
// Other platforms (e.g. WASI) fall back to plain `rename`
// and accept whatever atomicity guarantees the host's libc
// provides.
std::fs::rename(tmp, dst)
}
}
/// Magic bytes at the start of every checkpoint file.
///
/// Eight bytes of ASCII so a `head -c 8 file.ckpt` is human-readable
/// without a hex dump.
pub const MAGIC: [u8; 8] = *b"peelckpt";
/// The format version this build writes and is the highest version it
/// can read. Future builds bump this when the body layout changes.
///
/// History:
///
/// - **v1** — `Raw` and `Tar` sink states.
/// - **v2** — adds the `Zip` sink state for per-entry ZIP extraction
/// (`internal/PLAN_v2.md` §5). The body layout is otherwise unchanged;
/// v2 readers parse v1 files transparently.
/// - **v3** — appends an optional [`Checkpoint::hash_state`] field
/// carrying the serialized SHA-256 state used by `--sha256`
/// integrity verification (`internal/PLAN_v2.md` §10). v3 readers
/// parse v1 / v2 files transparently with `hash_state = None`.
/// - **v4** — appends an optional [`Checkpoint::chunk_crc32c`]
/// per-chunk fingerprint vector for `PLAN_v2.md` §11's mid-flight
/// source-change detector. v4 readers parse v1 / v2 / v3 files
/// transparently with `chunk_crc32c = None`.
/// - **v5** — appends an optional opaque [`Checkpoint::decoder_state`]
/// blob for `OPTIMIZATIONS.md` O.7b's mid-frame lz4 resume. The
/// blob is decoder-private — checkpoint code only carries it
/// verbatim. Length is capped at [`MAX_DECODER_STATE_LEN`] on
/// decode to bound allocation. v5 readers parse v1 / v2 / v3 / v4
/// files transparently with `decoder_state = None`. Older binaries
/// refuse v5 files with [`CheckpointError::UnsupportedVersion`].
/// - **v6** — extends [`SinkState::Tar`] with optional `in_flight`
/// parser state so a kill at any block boundary (not just between
/// tar members) is resumable. Supports the
/// `OPTIMIZATIONS.md`-tracked Polkachu shape where alignment
/// between LZ4 block boundaries and tar member boundaries is
/// essentially never satisfied. v6 readers parse v1 / v2 / v3 /
/// v4 / v5 files transparently with `in_flight = None`. Older
/// binaries refuse v6 with [`CheckpointError::UnsupportedVersion`].
/// - **v7** — extends [`SinkState::Zip`] with an optional opaque
/// `current_entry_decoder_state` blob carrying the in-flight zip
/// entry's codec state (`internal/PLAN_deflate_block_decoder.md`
/// Phase 9b). v7 readers parse v1..=v6 files transparently
/// with `current_entry_decoder_state = None` (which fall through
/// to the existing per-entry "restart from byte 0" path for
/// DEFLATE / zstd entries). Older binaries refuse v7 with
/// [`CheckpointError::UnsupportedVersion`].
/// - **v8** — replaces the legacy `(url, etag, last_modified)` trio
/// with a [`Checkpoint::parts`] vec of [`PartRecord`]s, one per
/// logical part (`internal/PLAN_multi_url_source.md` §5). Single-URL
/// runs write a 1-element vec; multi-URL runs write the full
/// list. The optional [`Checkpoint::hash_state`] also carries a
/// `u32` `active_part_idx` so resume can rebuild the
/// [`crate::hash::IntegrityHasher`] state machine at the right
/// part. v8 readers parse v1..=v7 files transparently by
/// synthesizing `parts[0]` from the legacy fields and treating
/// the saved `hash_state` (when present) as `active_part_idx =
/// 0`. Older binaries refuse v8 with
/// [`CheckpointError::UnsupportedVersion`].
/// - **v9** — adds [`SinkState::Sevenz`] with `folders_completed` +
/// optional `current_folder` for the 7z second-pipeline driver
/// (`internal/PLAN_7z_support.md` §9). Older binaries refuse v9 files
/// with [`CheckpointError::UnsupportedVersion`].
/// - **v10** — adds [`SinkState::Rar`] with `entries_completed`,
/// optional `current_entry`, and `current_entry_offset` for the
/// RAR5 STORED-method pipeline (`internal/PLAN_rar.md` §3). Round-one
/// resume restarts the in-flight entry from
/// `current_entry_offset` (the on-disk file is truncated to that
/// length and the running BLAKE2sp / CRC-32 are seeded by
/// re-reading the prefix). Newer formats will add a serialized
/// BLAKE2sp / RAR5-decoder snapshot on top of this same variant
/// when §4 lands the hand-rolled compressed-method decoder.
/// Older binaries refuse v10 files with
/// [`CheckpointError::UnsupportedVersion`].
/// - **v11** — extends [`SinkState::Rar`] with an optional opaque
/// `current_entry_decoder_state` blob carrying the in-flight
/// compressed entry's [`crate::decode::rar_native::RarStreamDecoder`]
/// snapshot (`internal/PLAN_rar5_decoder.md` §F1). v11 readers parse
/// v1..=v10 files transparently with the blob set to `None`
/// (compressed entries fall through to the existing
/// "restart entry from byte 0" path). The format version on
/// disk only bumps to 11 when a SinkState::Rar checkpoint
/// actually ships a non-`None` blob — STORED-only checkpoints
/// keep writing the v10 layout so existing tight crash-resume
/// tests don't see a byte-count drift in their checkpoint
/// sidecar. Older binaries refuse v11 files with
/// [`CheckpointError::UnsupportedVersion`].
///
/// **v12** — adds the [`Checkpoint::mode`] field
/// (`internal/PLAN_download_modes.md` §5) so resume can detect drift
/// between runs that pass different mode flags (e.g. a prior
/// `--no-extract` checkpoint paired with a later run that omits
/// the flag). Forward-compat: v11 and earlier read as
/// [`RunMode::Extract`]; v12 writes only fire when `mode !=
/// Extract` (the byte-identical lower bound).
///
/// **v13** — adds the [`Checkpoint::source_mtime`] trailer for
/// [`RunMode::LocalDestructive`] runs
/// (`internal/old/PLAN_local_file_extract.md` §5). The trailer is written
/// only for `LocalDestructive`; every other mode falls through to
/// the pre-§5 byte-identical layout.
///
/// **v14** — adds a per-[`PartRecord`] `volume_role` tag
/// (`internal/PLAN_multivolume_archives.md` §5) so a multi-volume
/// resume can verify the volume set's shape matches what the
/// checkpoint was written under. The tag is per-part because each
/// part of a multi-volume archive is its own self-contained volume
/// with its own format-specific metadata; the role identifies
/// which format dialect the part belongs to.
/// [`PartRecord::volume_role`] is [`Option<VolumeRole>`]: `None`
/// is the legacy linear byte-concat shape (today's single-URL and
/// multi-URL behaviour). The writer only bumps the on-disk header
/// to v14 when at least one part has `Some(role)`; runs whose
/// parts are all `None` continue writing v8..=v13 layouts
/// byte-identically. v14 readers parse v1..=v13 transparently
/// with every part's `volume_role` defaulting to `None`. Older
/// binaries refuse v14 files with
/// [`CheckpointError::UnsupportedVersion`].
///
/// **v15** — appends an optional [`Checkpoint::cumulative_elapsed`]
/// trailer carrying the total wall-clock time spent on the
/// operation across every run that has contributed to this
/// checkpoint. The coordinator seeds the value from the prior
/// checkpoint on resume and snapshots `prior + (now - run_start)`
/// at every write so a crash-loop accumulates monotonically. v15
/// readers parse v1..=v14 files transparently with the value
/// defaulting to [`Duration::ZERO`]. The writer only bumps the
/// on-disk version to 15 when the value is non-zero, so existing
/// byte-identical regression tests (sample checkpoints constructed
/// with `Duration::ZERO`) keep their v8..=v14 layouts. Older
/// binaries refuse v15 files with
/// [`CheckpointError::UnsupportedVersion`].
///
/// **v16** — extends [`TarSinkState`] with an optional
/// `pending_linkpath` field carrying a buffered symlink / hard-link
/// target override (PAX `linkpath=` or a GNU `K` long-link) that was
/// captured between the extension header and the link entry it
/// applies to. v16 readers parse v1..=v15 files transparently with
/// the field defaulting to `None`. The writer only bumps the on-disk
/// version to 16 when an in-flight tar state actually carries a
/// `pending_linkpath`, so the overwhelmingly common quiescent /
/// mid-file checkpoints keep their v6..=v15 layouts byte-identically.
/// Older binaries refuse v16 files with
/// [`CheckpointError::UnsupportedVersion`].
pub const FORMAT_VERSION: u32 = 16;
/// Pre-§F1 max format version, written when no SinkState payload
/// uses v11-only fields. Keeping the on-disk version dynamic
/// means STORED-only RAR checkpoints round-trip byte-identically
/// to the v10 layout, which keeps tight crash-resume tests
/// (whose checkpoint sidecar size is observed indirectly via
/// kill-switch timing) stable across the §F1 transition.
const FORMAT_VERSION_RAR_STORED_ONLY: u32 = 10;
/// `internal/PLAN_rar5_decoder.md` §F1: format version the writer
/// upgrades to when [`SinkState::Rar::current_entry_decoder_state`]
/// is populated. The reader dispatches on `format_version >= 11`
/// to know whether the per-entry decoder-state presence byte is
/// present in the body.
const FORMAT_VERSION_RAR_F1: u32 = 11;
/// `internal/PLAN_download_modes.md` §5: format version the writer
/// upgrades to when [`Checkpoint::mode`] is anything other than
/// [`RunMode::Extract`]. Default-mode runs keep writing the
/// pre-§5 layout (v10 / v11 depending on the SinkState payload)
/// so byte-identical regression checks pass.
const FORMAT_VERSION_RUN_MODE: u32 = 12;
/// `internal/old/PLAN_local_file_extract.md` §5: format version the
/// writer upgrades to when [`Checkpoint::mode`] is
/// [`RunMode::LocalDestructive`]. Carries the source-file mtime
/// trailer so the resume path can defend against a same-size
/// swap of the underlying archive between runs.
const FORMAT_VERSION_LOCAL_DESTRUCTIVE: u32 = 13;
/// `internal/PLAN_multivolume_archives.md` §5: format version the
/// writer upgrades to when at least one [`PartRecord`] carries a
/// non-`None` [`VolumeRole`]. The per-part role byte is appended
/// for every part in the body (uniformly, so the read loop has a
/// fixed shape) — runs where every part is linear byte-concat
/// keep writing v8..=v13 layouts byte-identically so the existing
/// resume tests do not see a sidecar-size drift on the bump.
const FORMAT_VERSION_MULTIVOLUME: u32 = 14;
/// Format version the writer upgrades to when
/// [`Checkpoint::cumulative_elapsed`] is non-zero. The 12-byte
/// trailer (i64 secs + u32 nanos) is appended unconditionally at
/// v15, so the read path is "read trailer iff `format_version >=
/// 15 && cursor.remaining() > 0`". Zero-valued checkpoints keep
/// writing the v8..=v14 layout byte-identically.
const FORMAT_VERSION_CUMULATIVE_ELAPSED: u32 = 15;
/// Format version the writer upgrades to when a
/// [`SinkState::Tar`]'s in-flight state carries a
/// [`TarSinkState::pending_linkpath`]. The optional `linkpath`
/// override string is written inside the tar-sink-state body
/// (right after `pending_size`) and read back under a
/// `format_version >= 16` guard; checkpoints without a buffered
/// link target keep writing the v6..=v15 tar layout byte-identically.
const FORMAT_VERSION_TAR_LINKPATH: u32 = 16;
/// Fixed-size header length, in bytes.
const HEADER_LEN: usize = 8 + 4 + 8 + 8;
/// Tag for [`SinkState::Raw`] in the on-disk format.
const SINK_TAG_RAW: u8 = 0;
/// Tag for [`SinkState::Tar`] in the on-disk format.
const SINK_TAG_TAR: u8 = 1;
/// Tag for [`SinkState::Sevenz`] in the on-disk format. Added in
/// v9 of the format (`internal/PLAN_7z_support.md` §9).
const SINK_TAG_SEVENZ: u8 = 3;
/// Tag for [`SinkState::Zip`] in the on-disk format. Added in v2 of
/// the checkpoint layout.
const SINK_TAG_ZIP: u8 = 2;
/// Tag for [`SinkState::Rar`] in the on-disk format. Added in v10
/// of the format (`internal/PLAN_rar.md` §3).
const SINK_TAG_RAR: u8 = 4;
/// `internal/PLAN_download_modes.md` §5: which top-level mode a
/// checkpoint was written by, persisted in v12 so resume can
/// detect a user changing flags between runs and refuse to silently
/// "forget" extractor or download-only progress.
#[derive(Debug, Default, Clone, Copy, Eq, PartialEq)]
pub enum RunMode {
/// Default extract pipeline: download → decode → extract →
/// hole-punch → delete source on success. Writers default to
/// this value, and readers of pre-v12 checkpoints synthesize
/// it.
#[default]
Extract,
/// Download-only mode: scheduler fills `.peel.part`, no
/// decoder runs, the part-file is renamed to the final output
/// path on completion (`internal/PLAN_download_modes.md` §2).
NoExtract,
/// Extract-and-preserve mode: decoder runs and the puncher is
/// forced to no-op so the source archive stays at its full
/// `Content-Length`. On completion the `.peel.part` is renamed
/// to a user-supplied keep-archive path
/// (`internal/PLAN_download_modes.md` §3).
KeepArchive,
/// Local-file destructive mode: the user-supplied archive on
/// disk is progressively hole-punched as the decoder advances
/// and deleted on clean completion
/// (`internal/old/PLAN_local_file_extract.md` §5). Carries a
/// `source_mtime` trailer in the on-disk format (v13+) so a
/// same-size swap between runs is detected on resume.
LocalDestructive,
}
/// Run-mode tag for [`RunMode::Extract`] in the on-disk format
/// (`internal/PLAN_download_modes.md` §5). Implicit for pre-v12
/// checkpoints — the deserializer synthesizes the value.
const RUN_MODE_TAG_EXTRACT: u8 = 0;
/// Run-mode tag for [`RunMode::NoExtract`].
const RUN_MODE_TAG_NO_EXTRACT: u8 = 1;
/// Run-mode tag for [`RunMode::KeepArchive`].
const RUN_MODE_TAG_KEEP_ARCHIVE: u8 = 2;
/// Run-mode tag for [`RunMode::LocalDestructive`]. Added in v13.
const RUN_MODE_TAG_LOCAL_DESTRUCTIVE: u8 = 3;
/// `internal/PLAN_multivolume_archives.md` §5: which multi-volume
/// archive format a [`PartRecord`] represents one volume of.
///
/// Carried as `Option<VolumeRole>` on [`PartRecord`]; `None` means
/// "linear byte-concat" — every pre-v14 [`PartRecord`] decodes as
/// `None`, single-URL runs serialize `None`, and the
/// multi-URL-source path (`internal/PLAN_multi_url_source.md`) also
/// serializes `None` because its parts byte-concatenate into one
/// logical stream rather than each carrying their own format
/// metadata.
///
/// The three concrete variants describe the three multi-volume
/// archive formats `peel` discovers in
/// [`crate::multivolume`]. They are persisted so a resume can
/// confirm the volume set discovered on the second run still has
/// the same shape — a heterogeneous mix (e.g. a checkpoint that
/// recorded `Rar5Volume` parts resuming against a `.7z.NNN` set)
/// is a hard error rather than a silently-wrong decode.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum VolumeRole {
/// One volume of a RAR5 multi-volume archive
/// (`<base>.part<NN>.rar`). Volumes are not a byte-concat —
/// each volume carries its own RAR main+end headers, and
/// files marked `FHD_SPLIT_BEFORE` / `FHD_SPLIT_AFTER` span
/// volume boundaries with the format parser stitching the
/// logical stream.
Rar5Volume,
/// One volume of a 7z multi-volume archive
/// (`<base>.7z.<NNN>`). Volumes *are* a byte-concat — the
/// logical `.7z` is `cat name.7z.001 … name.7z.NNN`. The role
/// tag is still recorded so the resume path can verify the
/// discovered set is a 7z volume set (and not, say, a same-
/// shaped numbered URL list whose parts happen to byte-
/// concatenate into a tar stream).
SevenZVolume,
/// One volume of a spanned ZIP archive (`<base>.z<NN>`
/// siblings + trailing `<base>.zip`). The final `.zip`
/// carries the EOCD + central directory; earlier volumes
/// hold the per-entry local-file-header payload, which can
/// itself span volume boundaries.
ZipSpannedVolume,
}
/// Volume-role tag for [`VolumeRole::Rar5Volume`]. Added in v14.
const VOLUME_ROLE_TAG_RAR5: u8 = 0;
/// Volume-role tag for [`VolumeRole::SevenZVolume`]. Added in v14.
const VOLUME_ROLE_TAG_SEVENZ: u8 = 1;
/// Volume-role tag for [`VolumeRole::ZipSpannedVolume`]. Added in v14.
const VOLUME_ROLE_TAG_ZIP_SPANNED: u8 = 2;
impl VolumeRole {
fn to_tag(self) -> u8 {
match self {
VolumeRole::Rar5Volume => VOLUME_ROLE_TAG_RAR5,
VolumeRole::SevenZVolume => VOLUME_ROLE_TAG_SEVENZ,
VolumeRole::ZipSpannedVolume => VOLUME_ROLE_TAG_ZIP_SPANNED,
}
}
fn from_tag(tag: u8) -> Option<Self> {
match tag {
VOLUME_ROLE_TAG_RAR5 => Some(VolumeRole::Rar5Volume),
VOLUME_ROLE_TAG_SEVENZ => Some(VolumeRole::SevenZVolume),
VOLUME_ROLE_TAG_ZIP_SPANNED => Some(VolumeRole::ZipSpannedVolume),
_ => None,
}
}
}
impl std::fmt::Display for RunMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.label())
}
}
impl RunMode {
/// Stable human-readable label used in [`CheckpointError::ModeMismatch`]
/// messages and in `tracing` output. Mirrors the flag the user
/// would type on the CLI for each mode.
#[must_use]
pub fn label(&self) -> &'static str {
match self {
RunMode::Extract => "extract",
RunMode::NoExtract => "--no-extract",
RunMode::KeepArchive => "--keep-archive",
RunMode::LocalDestructive => "local-destructive",
}
}
fn to_tag(self) -> u8 {
match self {
RunMode::Extract => RUN_MODE_TAG_EXTRACT,
RunMode::NoExtract => RUN_MODE_TAG_NO_EXTRACT,
RunMode::KeepArchive => RUN_MODE_TAG_KEEP_ARCHIVE,
RunMode::LocalDestructive => RUN_MODE_TAG_LOCAL_DESTRUCTIVE,
}
}
fn from_tag(tag: u8) -> Option<Self> {
match tag {
RUN_MODE_TAG_EXTRACT => Some(RunMode::Extract),
RUN_MODE_TAG_NO_EXTRACT => Some(RunMode::NoExtract),
RUN_MODE_TAG_KEEP_ARCHIVE => Some(RunMode::KeepArchive),
RUN_MODE_TAG_LOCAL_DESTRUCTIVE => Some(RunMode::LocalDestructive),
_ => None,
}
}
}
/// Maximum length of the v5 [`Checkpoint::decoder_state`] blob.
///
/// Sized to accommodate the hand-rolled zstd decoder's resume blob
/// (`internal/PLAN_zstd_block_decoder.md` Phase 7), which carries a
/// sliding-window snapshot of up to `MAX_WINDOW_SIZE` (128 MiB at
/// `windowLog = 27`) plus ~10 KiB of metadata. The lz4 blob is on
/// the order of 50 bytes; the upper bound is a zstd-only concern.
/// [`MAX_BODY_LEN`] (1 GiB) still bounds the worst-case allocation
/// a hostile checkpoint can trigger before
/// [`Checkpoint::deserialize`] checks the body checksum.
pub const MAX_DECODER_STATE_LEN: u32 = (1 << 27) + 32 * 1024;
/// Per-step wall-clock timings for one [`Checkpoint::write_timed`]
/// call.
///
/// Each field measures one stage of the atomic-write sequence so
/// `PLAN_checkpoint_cadence_throughput.md` Phase 0 can attribute the
/// per-checkpoint stall to a specific syscall. The fields are
/// non-overlapping; their sum is the wall-clock spent inside
/// `write_timed`.
#[derive(Debug, Default, Clone, Copy)]
pub struct CheckpointWriteTimings {
/// Time to serialize the [`Checkpoint`] body into a `Vec<u8>`.
pub serialize_time: Duration,
/// Time from `OpenOptions::open(.tmp)` through the final
/// `write_all` (no `fsync`).
pub tmp_write_time: Duration,
/// Time spent inside `File::sync_all` on the `.tmp` file.
pub tmp_fsync_time: Duration,
/// Time spent inside `fs::rename(.tmp, path)`.
pub rename_time: Duration,
/// Time spent inside `File::sync_all` on the parent directory.
/// Zero when [`Self::dir_fsync_called`] is `false` (parent open
/// failed, parent path was empty, or we're on a platform that
/// can't fsync directories).
pub dir_fsync_time: Duration,
/// Whether the parent-directory `sync_all` was attempted.
pub dir_fsync_called: bool,
}
/// Errors produced by [`Checkpoint::read`] / [`Checkpoint::write`] and
/// the in-memory [`Checkpoint::deserialize`] / [`Checkpoint::serialize`]
/// helpers.
///
/// Variants are specific so callers can decide what to log vs. retry
/// vs. surface as a hard failure. Per `internal/ENGINEERING_BEST_PRACTICES`
/// §3.1 every variant carries enough structured context that the
/// `Display` message alone is debuggable.
#[derive(Debug, Error)]
pub enum CheckpointError {
/// A filesystem syscall failed.
#[error("io error operating on {path}")]
Io {
/// Path involved in the failing call.
path: PathBuf,
/// The underlying OS error.
#[source]
source: std::io::Error,
},
/// The first eight bytes of the file did not match [`MAGIC`].
#[error("not a peel checkpoint (magic was {found:02X?})")]
BadMagic {
/// The eight bytes the reader actually saw.
found: [u8; 8],
},
/// The file declares a `format_version` outside the supported
/// range `1..=FORMAT_VERSION`. Version 0 has never existed and
/// versions above [`FORMAT_VERSION`] are produced by newer
/// binaries; per §9.4 the older binary refuses to guess at either
/// shape and surfaces this so the caller can produce a clean
/// "checkpoint not readable / upgrade required" message.
#[error("checkpoint format version {found} is not in supported range 1..={supported_max}")]
UnsupportedVersion {
/// The version recorded in the file header.
found: u32,
/// The highest version this build understands.
supported_max: u32,
},
/// The file's framing is internally consistent (magic, version) but
/// the body length is shorter than the version's minimum, or
/// truncated mid-field.
#[error("checkpoint is truncated or malformed: {reason}")]
Truncated {
/// Human-readable detail naming which field overran.
reason: String,
},
/// The body checksum recorded in the header does not match the
/// computed checksum of the body bytes — the file has been
/// corrupted on disk or in transit.
#[error(
"checkpoint body checksum mismatch (header {expected:#018x}, computed {computed:#018x})"
)]
BodyChecksumMismatch {
/// Checksum the header recorded.
expected: u64,
/// Checksum we computed over the body bytes we read.
computed: u64,
},
/// A `Vec<u8>`-prefixed UTF-8 field failed to decode.
#[error("checkpoint field {field} is not valid utf-8")]
InvalidUtf8 {
/// Name of the field that failed to decode.
field: &'static str,
/// Underlying conversion error.
#[source]
source: std::string::FromUtf8Error,
},
/// A discriminant byte (`SinkState` tag) had an unknown value.
#[error("checkpoint field {field} has unknown enum tag {tag}")]
InvalidEnumTag {
/// Name of the field whose tag was unknown.
field: &'static str,
/// The unknown tag value.
tag: u8,
},
/// A boolean-coded presence byte (etag / last-modified) was neither
/// `0` nor `1`.
#[error("checkpoint field {field} presence byte {value} is not a valid boolean")]
InvalidPresence {
/// Name of the field whose presence byte was invalid.
field: &'static str,
/// The byte we observed.
value: u8,
},
/// `internal/PLAN_download_modes.md` §5: the on-disk checkpoint
/// declares a [`RunMode`] that disagrees with the current run's
/// mode. Resume would silently lose progress (or attempt the
/// wrong cleanup), so the resume path surfaces this as a hard
/// error.
#[error(
"checkpoint was written by mode `{old}` but current run uses mode `{new}`; \
re-run with the matching flag or delete the .peel.ckpt file to start over"
)]
ModeMismatch {
/// The mode recorded in the on-disk checkpoint.
old: RunMode,
/// The mode the current invocation is running under.
new: RunMode,
},
/// `internal/old/PLAN_local_file_extract.md` §5: the local
/// destructive-mode resume path detected drift between the
/// checkpoint's view of the source archive and the file
/// currently on disk — either the source path moved, its
/// length changed, or its `mtime` shifted. Silently
/// "resuming" against the wrong bytes would either feed the
/// decoder garbage or produce a byte-different output tree;
/// surface a hard error and let the user decide.
#[error(
"local-destructive resume rejected: source archive has changed since the \
checkpoint was written ({reason}); either restore the original archive at \
{expected_path} or delete the .peel.ckpt to start over (which will leave \
the partially-punched source unrecoverable)"
)]
SourceMismatch {
/// The source path the checkpoint expects.
expected_path: PathBuf,
/// Human-readable summary of the drift.
reason: String,
},
/// The file declares a `body_length` that exceeds the configured
/// safety cap. Defensive against pathological inputs.
#[error("checkpoint body length {found} exceeds safety cap {cap}")]
BodyTooLarge {
/// The declared body length.
found: u64,
/// The configured cap.
cap: u64,
},
}
/// In-flight [`crate::sink::TarSink`] parser state captured at a
/// checkpoint, mirroring the live `TarSink::State` enum in a form that
/// can be (de)serialized.
///
/// Stored inside [`SinkState::Tar`]'s `in_flight` field so a kill
/// mid-member is resumable without requiring decoder block boundaries
/// to coincide with tar member boundaries (a rare alignment for
/// real-world `tar.lz4` archives like Polkachu's chain snapshots).
///
/// Deserialization rejects out-of-range fields (e.g. `header_filled >
/// 512`, `padding > 511`) so a corrupt checkpoint can't drive the
/// resumed sink into UB-adjacent states.
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct TarSinkState {
/// Cumulative archive bytes the sink has consumed at the
/// checkpoint moment. Diagnostic only; the resumed sink rebuilds
/// its own counter from `state` and the resumed decoder's output.
pub archive_offset: u64,
/// Number of *consecutive* trailing zero blocks the sink has
/// observed leading up to the checkpoint. Tar uses two zero
/// blocks as the end-of-archive marker.
pub zero_blocks_seen: u8,
/// PAX `path=` override applying to the next non-PAX entry, if
/// any was buffered when the checkpoint fired.
pub pending_path: Option<String>,
/// PAX `size=` override applying to the next non-PAX entry, if
/// any.
pub pending_size: Option<u64>,
/// Link-target override (PAX `linkpath=` or a GNU `K` long-link)
/// applying to the next symlink / hard-link entry, if any was
/// buffered when the checkpoint fired. Serialized only from
/// [`FORMAT_VERSION_TAR_LINKPATH`] (v16) onward; older readers
/// reconstruct it as `None`.
pub pending_linkpath: Option<String>,
/// The parser's driving state.
pub state: TarMemberState,
}
/// Serializable companion of `crate::sink::tar::State`.
///
/// `Header` carries the partial 512-byte buffer accumulated so far so
/// resume picks up reading the rest of it. `File` carries the path,
/// remaining payload bytes, and remaining padding bytes — the resumed
/// sink reopens the file at offset `total_size - remaining` and
/// continues. `PaxData` and `LongName` carry their accumulator
/// buffers. `Finished` is the terminal state.
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum TarMemberState {
/// Buffering bytes toward the next 512-byte tar header. `filled`
/// is in `0..=512`; `buf` carries exactly the bytes received so
/// far (length == `filled`; the rest of the 512-byte block is
/// not stored).
Header {
/// Bytes already received toward the header.
filled: u32,
/// The bytes received so far (length == `filled`).
buf: Vec<u8>,
},
/// Mid-payload write to a regular file. The resumed sink reopens
/// `path` (relative to the extraction root), seeks to the
/// already-written offset, and continues.
File {
/// Bytes of file payload still to receive.
remaining: u64,
/// Bytes of trailing zero padding still to consume.
padding: u32,
/// Output path the sink is writing into, relative to the
/// extraction root.
path: String,
/// Original payload size declared by the tar header, used to
/// derive the resumed file's seek offset
/// (`total_size - remaining`).
total_size: u64,
},
/// Mid-PAX-extended-header read.
PaxData {
/// Bytes of PAX body still to receive.
remaining: u64,
/// Bytes of trailing zero padding still to consume.
padding: u32,
/// Accumulator for the PAX entry data so far.
buf: Vec<u8>,
},
/// Mid-GNU-long-name read.
LongName {
/// Bytes of body still to receive.
remaining: u64,
/// Bytes of trailing zero padding still to consume.
padding: u32,
/// Accumulator for the long path so far.
buf: Vec<u8>,
/// `true` for 'K' (long link target, discarded), `false` for
/// 'L' (long path, applied to the next entry).
is_link: bool,
},
/// End-of-archive marker observed; further bytes are an error.
Finished,
}
/// Maximum buffered data the v6 [`TarSinkState`] decoder will trust
/// before [`Checkpoint::deserialize`] rejects with
/// [`CheckpointError::Truncated`]. Bounds the allocation a hostile
/// blob can trigger before the body checksum kicks in.
const MAX_TAR_BUFFER_LEN: u32 = 16 * 1024;
/// Tag for [`TarMemberState::Header`].
const TAR_TAG_HEADER: u8 = 0;
/// Tag for [`TarMemberState::File`].
const TAR_TAG_FILE: u8 = 1;
/// Tag for [`TarMemberState::PaxData`].
const TAR_TAG_PAX: u8 = 2;
/// Tag for [`TarMemberState::LongName`].
const TAR_TAG_LONGNAME: u8 = 3;
/// Tag for [`TarMemberState::Finished`].
const TAR_TAG_FINISHED: u8 = 4;
/// Sink-specific extraction state opaque to everything but the sink.
///
/// `Raw` and `Tar` are the two MVP sinks (see [`crate::sink`]); each
/// carries the minimum state required to skip already-extracted output
/// on resume:
///
/// - [`SinkState::Raw`] records bytes already written to the single
/// output file, so resume seeks past them rather than redoing them.
/// - [`SinkState::Tar`] records the in-flight parser state so resume
/// picks up exactly where the killed run left off — including in
/// the middle of a multi-MB tar member, which is the common case
/// for archives whose decoder block boundaries don't align with
/// tar-member boundaries.
///
/// The §10 coordinator captures the appropriate variant whenever the
/// extractor reports a checkpoint.
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum SinkState {
/// State for [`crate::sink::RawSink`].
Raw {
/// Total bytes successfully written to the output file at the
/// checkpoint moment. Resume seeks past `bytes_written` and
/// continues writing from there.
bytes_written: u64,
},
/// State for [`crate::sink::TarSink`].
Tar {
/// Tar member names already extracted at the checkpoint moment.
/// Vestigial: the streaming pipeline does not re-present prior
/// members on resume (the resumed decoder produces only the
/// archive suffix), so this list is empty in v6 producers and
/// is preserved only for v5-and-earlier on-disk compatibility.
members_completed: Vec<String>,
/// In-flight tar parser state, captured at the checkpoint
/// moment. `Some(...)` whenever the sink was mid-member (or
/// mid-header / mid-PAX / mid-LongName); `None` only when the
/// sink had just finished a member and was quiescent or fully
/// finished. The resumed sink restores this state directly
/// instead of starting from scratch — necessary for archive
/// shapes (e.g. Polkachu's single-frame `tar.lz4`) where
/// alignment between decoder block boundaries and tar member
/// boundaries is rare. Added in checkpoint format v6; older
/// readers see `None`.
in_flight: Option<TarSinkState>,
},
/// State for [`crate::sink::ZipSink`].
///
/// ZIP archives are extracted per-entry in central-directory
/// order. The checkpoint records which entries are durable on
/// disk via `entries_completed`, and (when a crash interrupts an
/// entry) the in-flight entry index plus the byte offset within
/// it. STORED entries resume from `current_entry_offset`;
/// DEFLATE / zstd entries resume from `current_entry_offset` *and*
/// the codec's `current_entry_decoder_state` blob (Phase 9b of
/// `internal/PLAN_deflate_block_decoder.md`). When the blob is
/// `None` for a DEFLATE / zstd entry — either because the
/// checkpoint was captured at byte 0 or the v6 reader couldn't
/// see it — the pipeline falls back to "restart entry from byte
/// 0", matching the pre-Phase-9b behaviour.
Zip {
/// Indices (within the central directory) of entries that
/// finished extracting before this checkpoint was written.
/// Ordered, deduplicated in the producer; the on-disk form
/// trusts the producer rather than re-checking on read.
entries_completed: Vec<u32>,
/// Index of the entry that was in flight when the checkpoint
/// was written, if any. `None` means the sink was quiescent.
current_entry: Option<u32>,
/// Bytes already written into the in-flight entry. `0` when
/// `current_entry` is `None`.
current_entry_offset: u64,
/// Opaque decoder-state blob captured at the most recent
/// in-entry checkpoint. `None` when the in-flight entry is
/// at byte 0, when the entry uses STORED (no codec state),
/// or when the checkpoint was written by a pre-v7 binary.
/// Added in checkpoint format v7; v6 readers see `None` and
/// fall through to the per-entry "restart from byte 0" path.
current_entry_decoder_state: Option<Vec<u8>>,
},
/// State for [`crate::sink::RarSink`] (added in v10 of the
/// format, `internal/PLAN_rar.md` §3).
///
/// RAR5 archives are extracted entry-by-entry in archive
/// order. The checkpoint records which entries are durable on
/// disk via `entries_completed`, and (when a crash interrupts
/// an entry) the in-flight entry index plus the byte offset
/// within it. STORED entries (round-one §3) resume from
/// `current_entry_offset`; compressed entries (round-one §4 /
/// `PLAN_rar5_decoder.md` §F1) resume from
/// `current_entry_offset` *plus* the
/// `current_entry_decoder_state` blob — together those two
/// fields let the next run pick up the in-flight entry
/// without restarting from byte 0.
Rar {
/// Indices (in archive order) of entries that finished
/// extracting before this checkpoint was written. Ordered
/// and deduplicated by the producer.
entries_completed: Vec<u32>,
/// Index of the entry that was in flight when the
/// checkpoint was written, if any. `None` means the sink
/// was quiescent.
current_entry: Option<u32>,
/// Bytes already written into the in-flight entry. `0`
/// when `current_entry` is `None`.
current_entry_offset: u64,
/// Opaque decoder-state blob captured at the most recent
/// in-entry checkpoint. For RAR5 archives this is a
/// serialized
/// [`crate::decode::rar_native::RarStreamDecoder`] snapshot
/// (`PLAN_rar5_decoder.md` §F1); for legacy (RAR3/RAR4)
/// archives it is a serialized
/// [`crate::decode::rar_legacy::RarLegacyStreamDecoder`]
/// snapshot (`PLAN_rar3.md` §F1). The two formats are
/// distinguished by their leading 4-byte magic (`RR5S`
/// vs. `RR3S`), so a checkpoint's blob is always routable
/// back to the decoder that produced it. `None` when:
///
/// - the in-flight entry uses STORED (no decoder state to
/// capture; resume re-reads the on-disk prefix);
/// - the in-flight entry is at byte 0 (no work to resume);
/// - the checkpoint was written by a v1..=v10 binary
/// (the field didn't exist yet).
///
/// When `None` for a compressed entry the resume path
/// truncates the on-disk file to byte 0 and re-extracts —
/// matching the §E1 fallback behaviour.
current_entry_decoder_state: Option<Vec<u8>>,
},
/// State for [`crate::sink::SevenzSink`] (added in v9 of the
/// format, `internal/PLAN_7z_support.md` §9).
///
/// 7z archives are extracted folder-by-folder in archive
/// order. The checkpoint records which folders are durable on
/// disk in `folders_completed`; when a crash interrupts a
/// folder, `current_folder` names it. Round-one resume
/// restarts the in-flight folder from byte 0 (mid-folder
/// resume is `O.32c` in `OPTIMIZATIONS.md` and depends on
/// `xz_liblzma::resume`'s sliding-window snapshot machinery).
Sevenz {
/// Folder indices (in archive order) that finished
/// extracting before this checkpoint was written.
folders_completed: Vec<u32>,
/// Index of the folder that was in flight when the
/// checkpoint was written, if any. `None` means the sink
/// was quiescent (between folders).
current_folder: Option<u32>,
},
}