Skip to main content

powdb_sync/
segment.rs

1use std::fs::{self, File, OpenOptions};
2use std::io::{self, Read, Write};
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::time::{SystemTime, UNIX_EPOCH};
6
7use powdb_storage::catalog::CATALOG_VERSION;
8use powdb_storage::create_data_dir_secure;
9use powdb_storage::wal::{WalRecord, WAL_FORMAT_VERSION};
10
11const SEGMENT_MAGIC: &[u8; 4] = b"PRUL";
12const FOOTER_MAGIC: &[u8; 4] = b"RULF";
13pub const RETAINED_SEGMENT_FORMAT_VERSION: u16 = 1;
14
15const RESERVED: u16 = 0;
16const HEADER_LEN: usize = 4 + 2 + 2 + 2 + 2 + 4 + 8 + 8 + 8 + 16;
17const UNIT_HEADER_LEN: usize = 4 + 4 + 8 + 1 + 8;
18const FOOTER_LEN: usize = 4 + 4;
19const MAX_RETAINED_UNIT_DATA_SIZE: usize = 256 * 1024 * 1024;
20const MAX_RETAINED_SEGMENT_FILE_SIZE: u64 = 1024 * 1024 * 1024;
21static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
22
23/// Segment identity metadata used to reject forked or incompatible histories.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub struct SegmentIdentity {
26    pub database_id: [u8; 16],
27    pub primary_generation: u64,
28    pub wal_format_version: u16,
29    pub catalog_version: u16,
30}
31
32impl SegmentIdentity {
33    /// Stamp an identity with this binary's maximum supported catalog version.
34    ///
35    /// Use this at *consumer* sites that want "the newest catalog format this
36    /// binary can read" as the expected identity. Producers that publish
37    /// segments must instead stamp the database's *active* catalog version with
38    /// [`Self::with_catalog_version`], so a database that has not yet activated
39    /// v6 keeps stamping v5 (old-replica compatibility).
40    pub fn current(database_id: [u8; 16], primary_generation: u64) -> Self {
41        Self::with_catalog_version(database_id, primary_generation, CATALOG_VERSION)
42    }
43
44    /// Stamp an identity with an explicit (active) catalog version.
45    ///
46    /// The catalog version is a *compatibility annotation*, not lineage: it
47    /// records the on-disk catalog format the producing database was running
48    /// when the segment was published. It is deliberately excluded from
49    /// [`Self::lineage_matches`]; consumers accept an older annotation and
50    /// reject a newer one via [`validate_identity`].
51    pub fn with_catalog_version(
52        database_id: [u8; 16],
53        primary_generation: u64,
54        catalog_version: u16,
55    ) -> Self {
56        Self {
57            database_id,
58            primary_generation,
59            wal_format_version: WAL_FORMAT_VERSION,
60            catalog_version,
61        }
62    }
63
64    /// True when the strict lineage fields (database id, primary generation,
65    /// WAL format) match. `catalog_version` is intentionally ignored: it is a
66    /// compatibility annotation that legitimately increases across a segment
67    /// chain when a database activates a newer catalog format mid-stream.
68    pub fn lineage_matches(self, other: SegmentIdentity) -> bool {
69        self.database_id == other.database_id
70            && self.primary_generation == other.primary_generation
71            && self.wal_format_version == other.wal_format_version
72    }
73
74    pub fn validate(self) -> io::Result<()> {
75        validate_identity(self)
76    }
77}
78
79/// One retained replication unit.
80///
81/// V1 stores current WAL records as units. The segment envelope owns durability,
82/// identity, range, and corruption checks; the future applier can convert the
83/// record type back into storage-layer replay actions.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct RetainedUnit {
86    pub tx_id: u64,
87    pub record_type: u8,
88    pub lsn: u64,
89    pub data: Vec<u8>,
90}
91
92impl From<&WalRecord> for RetainedUnit {
93    fn from(record: &WalRecord) -> Self {
94        Self {
95            tx_id: record.tx_id,
96            record_type: record.record_type as u8,
97            lsn: record.lsn,
98            data: record.data.clone(),
99        }
100    }
101}
102
103/// Immutable retained-unit segment.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct RetainedSegment {
106    pub identity: SegmentIdentity,
107    pub start_lsn: u64,
108    pub end_lsn: u64,
109    pub units: Vec<RetainedUnit>,
110}
111
112impl RetainedSegment {
113    pub fn new(identity: SegmentIdentity, units: Vec<RetainedUnit>) -> io::Result<Self> {
114        validate_identity(identity)?;
115        validate_units(&units)?;
116        let start_lsn = units[0].lsn;
117        let end_lsn = units[units.len() - 1].lsn;
118        Ok(Self {
119            identity,
120            start_lsn,
121            end_lsn,
122            units,
123        })
124    }
125
126    pub fn to_bytes(&self) -> io::Result<Vec<u8>> {
127        self.validate()?;
128
129        let mut out = Vec::with_capacity(HEADER_LEN + FOOTER_LEN);
130        out.extend_from_slice(SEGMENT_MAGIC);
131        out.extend_from_slice(&RETAINED_SEGMENT_FORMAT_VERSION.to_le_bytes());
132        out.extend_from_slice(&RESERVED.to_le_bytes());
133        out.extend_from_slice(&self.identity.wal_format_version.to_le_bytes());
134        out.extend_from_slice(&self.identity.catalog_version.to_le_bytes());
135        out.extend_from_slice(&checked_unit_count(self.units.len())?.to_le_bytes());
136        out.extend_from_slice(&self.start_lsn.to_le_bytes());
137        out.extend_from_slice(&self.end_lsn.to_le_bytes());
138        out.extend_from_slice(&self.identity.primary_generation.to_le_bytes());
139        out.extend_from_slice(&self.identity.database_id);
140
141        for unit in &self.units {
142            let data_len_u32 = checked_data_len(unit.data.len())?;
143            let total_len = (UNIT_HEADER_LEN as u32)
144                .checked_add(data_len_u32)
145                .ok_or_else(|| invalid_input("retained unit is too large"))?;
146            let crc = unit_crc(unit);
147            out.extend_from_slice(&total_len.to_le_bytes());
148            out.extend_from_slice(&crc.to_le_bytes());
149            out.extend_from_slice(&unit.tx_id.to_le_bytes());
150            out.push(unit.record_type);
151            out.extend_from_slice(&unit.lsn.to_le_bytes());
152            out.extend_from_slice(&unit.data);
153        }
154
155        let file_crc = crc32fast::hash(&out);
156        out.extend_from_slice(FOOTER_MAGIC);
157        out.extend_from_slice(&file_crc.to_le_bytes());
158        Ok(out)
159    }
160
161    pub fn validate(&self) -> io::Result<()> {
162        validate_identity(self.identity)?;
163        validate_units(&self.units)?;
164        if self.start_lsn != self.units[0].lsn {
165            return Err(invalid_input(
166                "retained segment start LSN does not match first unit",
167            ));
168        }
169        if self.end_lsn != self.units[self.units.len() - 1].lsn {
170            return Err(invalid_input(
171                "retained segment end LSN does not match last unit",
172            ));
173        }
174        Ok(())
175    }
176
177    pub fn from_bytes(bytes: &[u8]) -> io::Result<Self> {
178        if bytes.len() < HEADER_LEN + FOOTER_LEN {
179            return Err(invalid_data("retained segment is truncated"));
180        }
181
182        let footer_start = bytes.len() - FOOTER_LEN;
183        if &bytes[footer_start..footer_start + 4] != FOOTER_MAGIC {
184            return Err(invalid_data("retained segment footer magic mismatch"));
185        }
186        let expected_file_crc = read_u32(bytes, footer_start + 4, "segment footer crc")?;
187        let actual_file_crc = crc32fast::hash(&bytes[..footer_start]);
188        if expected_file_crc != actual_file_crc {
189            return Err(invalid_data("retained segment footer CRC mismatch"));
190        }
191
192        let mut pos = 0usize;
193        if read_exact(bytes, &mut pos, 4, "segment magic")? != SEGMENT_MAGIC {
194            return Err(invalid_data("retained segment magic mismatch"));
195        }
196        let segment_version = read_u16_at_cursor(bytes, &mut pos, "segment version")?;
197        if segment_version != RETAINED_SEGMENT_FORMAT_VERSION {
198            return Err(invalid_data("unsupported retained segment format version"));
199        }
200        let reserved = read_u16_at_cursor(bytes, &mut pos, "reserved")?;
201        if reserved != RESERVED {
202            return Err(invalid_data("retained segment reserved field must be zero"));
203        }
204        let wal_format_version = read_u16_at_cursor(bytes, &mut pos, "WAL format version")?;
205        let catalog_version = read_u16_at_cursor(bytes, &mut pos, "catalog version")?;
206        let record_count = read_u32_at_cursor(bytes, &mut pos, "record count")? as usize;
207        let start_lsn = read_u64_at_cursor(bytes, &mut pos, "start LSN")?;
208        let end_lsn = read_u64_at_cursor(bytes, &mut pos, "end LSN")?;
209        let primary_generation = read_u64_at_cursor(bytes, &mut pos, "primary generation")?;
210        let database_id_slice = read_exact(bytes, &mut pos, 16, "database id")?;
211        let mut database_id = [0u8; 16];
212        database_id.copy_from_slice(database_id_slice);
213
214        let identity = SegmentIdentity {
215            database_id,
216            primary_generation,
217            wal_format_version,
218            catalog_version,
219        };
220        validate_identity(identity)?;
221
222        if record_count == 0 {
223            return Err(invalid_data(
224                "retained segment must contain at least one unit",
225            ));
226        }
227        let max_possible_units = footer_start.saturating_sub(pos) / UNIT_HEADER_LEN;
228        if record_count > max_possible_units {
229            return Err(invalid_data(
230                "retained segment record count exceeds file capacity",
231            ));
232        }
233
234        let mut units = Vec::new();
235        units
236            .try_reserve(record_count)
237            .map_err(|_| invalid_data("retained segment record count is too large"))?;
238        for _ in 0..record_count {
239            if pos >= footer_start {
240                return Err(invalid_data("retained segment record count exceeds file"));
241            }
242            let total_len = read_u32(bytes, pos, "unit total length")? as usize;
243            if total_len < UNIT_HEADER_LEN {
244                return Err(invalid_data("retained unit length is smaller than header"));
245            }
246            if pos
247                .checked_add(total_len)
248                .is_none_or(|end| end > footer_start)
249            {
250                return Err(invalid_data("retained unit extends beyond segment"));
251            }
252            let unit_start = pos;
253            pos += 4;
254            let stored_crc = read_u32_at_cursor(bytes, &mut pos, "unit crc")?;
255            let tx_id = read_u64_at_cursor(bytes, &mut pos, "unit tx id")?;
256            let record_type = read_u8_at_cursor(bytes, &mut pos, "unit record type")?;
257            let lsn = read_u64_at_cursor(bytes, &mut pos, "unit LSN")?;
258            let data_len = total_len - UNIT_HEADER_LEN;
259            if data_len > MAX_RETAINED_UNIT_DATA_SIZE {
260                return Err(invalid_data("retained unit payload exceeds maximum size"));
261            }
262            let data = read_exact(bytes, &mut pos, data_len, "unit payload")?.to_vec();
263            debug_assert_eq!(pos, unit_start + total_len);
264
265            let unit = RetainedUnit {
266                tx_id,
267                record_type,
268                lsn,
269                data,
270            };
271            if !is_known_record_type(unit.record_type) {
272                return Err(invalid_data("unknown retained unit record type"));
273            }
274            if stored_crc != unit_crc(&unit) {
275                return Err(invalid_data("retained unit CRC mismatch"));
276            }
277            units.push(unit);
278        }
279
280        if pos != footer_start {
281            return Err(invalid_data("retained segment has trailing bytes"));
282        }
283
284        let segment = Self::new(identity, units)?;
285        if segment.start_lsn != start_lsn || segment.end_lsn != end_lsn {
286            return Err(invalid_data(
287                "retained segment LSN range does not match records",
288            ));
289        }
290        Ok(segment)
291    }
292}
293
294#[derive(Debug, Clone, PartialEq, Eq)]
295pub struct SegmentFile {
296    pub path: PathBuf,
297    pub start_lsn: u64,
298    pub end_lsn: u64,
299}
300
301#[derive(Debug, Clone, PartialEq, Eq)]
302pub struct RetainedTailAvailability {
303    pub since_lsn: u64,
304    pub through_lsn: u64,
305    pub units_available: usize,
306    pub first_lsn: Option<u64>,
307    pub last_lsn: Option<u64>,
308}
309
310#[derive(Debug, Clone, PartialEq, Eq)]
311pub struct RetainedTailProgress {
312    pub since_lsn: u64,
313    pub requested_through_lsn: u64,
314    pub contiguous_through_lsn: u64,
315    pub units_available: usize,
316    pub first_lsn: Option<u64>,
317    pub last_lsn: Option<u64>,
318}
319
320pub fn segment_file_name(start_lsn: u64, end_lsn: u64) -> String {
321    format!("retained-{start_lsn:020}-{end_lsn:020}.prul")
322}
323
324pub fn write_segment_atomic(dir: &Path, segment: &RetainedSegment) -> io::Result<PathBuf> {
325    create_data_dir_secure(dir)?;
326    let final_path = dir.join(segment_file_name(segment.start_lsn, segment.end_lsn));
327
328    let temp_path = temp_segment_path(dir, segment);
329    let bytes = segment.to_bytes()?;
330    let write_result: io::Result<()> = (|| {
331        let mut file = OpenOptions::new()
332            .write(true)
333            .create_new(true)
334            .open(&temp_path)?;
335        file.write_all(&bytes)?;
336        file.sync_all()?;
337        drop(file);
338
339        // `rename` is atomic but not no-clobber on Unix. A hard link in the
340        // same directory creates the final name only if it does not exist.
341        fs::hard_link(&temp_path, &final_path)?;
342        fsync_dir(dir)?;
343        let _ = fs::remove_file(&temp_path);
344        let _ = fsync_dir(dir);
345        Ok(())
346    })();
347    if write_result.is_err() {
348        let _ = fs::remove_file(&temp_path);
349    }
350    write_result?;
351    Ok(final_path)
352}
353
354pub fn read_segment_file(path: &Path) -> io::Result<RetainedSegment> {
355    let file = File::open(path)?;
356    let len = file.metadata()?.len();
357    if len > MAX_RETAINED_SEGMENT_FILE_SIZE {
358        return Err(invalid_data("retained segment file exceeds maximum size"));
359    }
360    let capacity = usize::try_from(len)
361        .map_err(|_| invalid_data("retained segment file size exceeds platform capacity"))?;
362    let mut bytes = Vec::new();
363    bytes
364        .try_reserve_exact(capacity)
365        .map_err(|_| invalid_data("retained segment file is too large to allocate"))?;
366    let mut limited = file.take(MAX_RETAINED_SEGMENT_FILE_SIZE + 1);
367    limited.read_to_end(&mut bytes)?;
368    if bytes.len() as u64 > MAX_RETAINED_SEGMENT_FILE_SIZE {
369        return Err(invalid_data("retained segment file exceeds maximum size"));
370    }
371    RetainedSegment::from_bytes(&bytes)
372}
373
374pub fn list_segment_files(dir: &Path) -> io::Result<Vec<SegmentFile>> {
375    let mut files = Vec::new();
376    let entries = match fs::read_dir(dir) {
377        Ok(entries) => entries,
378        Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(files),
379        Err(err) => return Err(err),
380    };
381    for entry in entries {
382        let entry = entry?;
383        let path = entry.path();
384        let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
385            continue;
386        };
387        if let Some((start_lsn, end_lsn)) = parse_segment_file_name(name) {
388            files.push(SegmentFile {
389                path,
390                start_lsn,
391                end_lsn,
392            });
393        }
394    }
395    files.sort_by_key(|file| (file.start_lsn, file.end_lsn));
396    Ok(files)
397}
398
399pub fn read_units_since(
400    dir: &Path,
401    expected_identity: SegmentIdentity,
402    since_lsn: u64,
403    max_units: usize,
404) -> io::Result<Vec<RetainedUnit>> {
405    read_units_through(dir, expected_identity, since_lsn, u64::MAX, max_units)
406}
407
408pub fn read_units_through(
409    dir: &Path,
410    expected_identity: SegmentIdentity,
411    since_lsn: u64,
412    through_lsn: u64,
413    max_units: usize,
414) -> io::Result<Vec<RetainedUnit>> {
415    validate_identity(expected_identity)?;
416    if max_units == 0 || through_lsn <= since_lsn {
417        return Ok(Vec::new());
418    }
419
420    let mut out = Vec::new();
421    let Some(mut expected_next_lsn) = since_lsn.checked_add(1) else {
422        return Ok(out);
423    };
424    let mut have_started = false;
425    let mut chain_catalog_version = 0u16;
426
427    for file in list_segment_files(dir)? {
428        if file.start_lsn > through_lsn {
429            if expected_next_lsn <= through_lsn {
430                return Err(invalid_data(format!(
431                    "retained segment gap: expected LSN {}, found {}",
432                    expected_next_lsn, file.start_lsn
433                )));
434            }
435            break;
436        }
437        let segment = read_segment_file(&file.path)?;
438        if segment.start_lsn != file.start_lsn || segment.end_lsn != file.end_lsn {
439            return Err(invalid_data(format!(
440                "retained segment filename range {}-{} does not match header range {}-{}",
441                file.start_lsn, file.end_lsn, segment.start_lsn, segment.end_lsn
442            )));
443        }
444        if !segment.identity.lineage_matches(expected_identity) {
445            return Err(invalid_data(
446                "retained segment identity does not match expected database history",
447            ));
448        }
449        if segment.identity.catalog_version < chain_catalog_version {
450            return Err(invalid_data(format!(
451                "retained segment catalog format decreased across chain (v{} then v{}); rebootstrap required",
452                chain_catalog_version, segment.identity.catalog_version
453            )));
454        }
455        chain_catalog_version = segment.identity.catalog_version;
456
457        if segment.end_lsn <= since_lsn {
458            continue;
459        }
460
461        if !have_started {
462            if segment.start_lsn > expected_next_lsn {
463                return Err(invalid_data(format!(
464                    "retained segment gap: expected LSN {}, found {}",
465                    expected_next_lsn, segment.start_lsn
466                )));
467            }
468        } else if segment.start_lsn < expected_next_lsn {
469            return Err(invalid_data(format!(
470                "retained segment overlap: expected LSN {}, found {}",
471                expected_next_lsn, segment.start_lsn
472            )));
473        } else if segment.start_lsn > expected_next_lsn {
474            return Err(invalid_data(format!(
475                "retained segment gap: expected LSN {}, found {}",
476                expected_next_lsn, segment.start_lsn
477            )));
478        }
479
480        for unit in segment.units {
481            if unit.lsn < expected_next_lsn {
482                if have_started {
483                    return Err(invalid_data(format!(
484                        "retained unit overlap: expected LSN {}, found {}",
485                        expected_next_lsn, unit.lsn
486                    )));
487                }
488                continue;
489            }
490            if unit.lsn > expected_next_lsn {
491                return Err(invalid_data(format!(
492                    "retained unit gap: expected LSN {}, found {}",
493                    expected_next_lsn, unit.lsn
494                )));
495            }
496            if unit.lsn > through_lsn {
497                return Ok(out);
498            }
499            out.push(unit);
500            have_started = true;
501            expected_next_lsn = expected_next_lsn
502                .checked_add(1)
503                .ok_or_else(|| invalid_data("retained unit LSN overflow"))?;
504            if out.len() == max_units {
505                return Ok(out);
506            }
507        }
508    }
509    Ok(out)
510}
511
512pub fn validate_retained_tail_available(
513    dir: &Path,
514    expected_identity: SegmentIdentity,
515    since_lsn: u64,
516    through_lsn: u64,
517) -> io::Result<RetainedTailAvailability> {
518    validate_identity(expected_identity)?;
519    if through_lsn <= since_lsn {
520        return Ok(RetainedTailAvailability {
521            since_lsn,
522            through_lsn,
523            units_available: 0,
524            first_lsn: None,
525            last_lsn: None,
526        });
527    }
528
529    let mut expected_next_lsn = since_lsn
530        .checked_add(1)
531        .ok_or_else(|| invalid_data("retained tail LSN overflow"))?;
532    let mut units_available = 0usize;
533    let mut first_lsn = None;
534    let mut have_started = false;
535    let mut chain_catalog_version = 0u16;
536
537    for file in list_segment_files(dir)? {
538        let segment = read_segment_file(&file.path)?;
539        if segment.start_lsn != file.start_lsn || segment.end_lsn != file.end_lsn {
540            return Err(invalid_data(format!(
541                "retained segment filename range {}-{} does not match header range {}-{}",
542                file.start_lsn, file.end_lsn, segment.start_lsn, segment.end_lsn
543            )));
544        }
545        if !segment.identity.lineage_matches(expected_identity) {
546            return Err(invalid_data(
547                "retained segment identity does not match expected database history",
548            ));
549        }
550        if segment.identity.catalog_version < chain_catalog_version {
551            return Err(invalid_data(format!(
552                "retained segment catalog format decreased across chain (v{} then v{}); rebootstrap required",
553                chain_catalog_version, segment.identity.catalog_version
554            )));
555        }
556        chain_catalog_version = segment.identity.catalog_version;
557
558        if segment.end_lsn < expected_next_lsn {
559            continue;
560        }
561        if !have_started {
562            if segment.start_lsn > expected_next_lsn {
563                return Err(invalid_data(format!(
564                    "retained segment gap: expected LSN {}, found {}",
565                    expected_next_lsn, segment.start_lsn
566                )));
567            }
568        } else if segment.start_lsn < expected_next_lsn {
569            return Err(invalid_data(format!(
570                "retained segment overlap: expected LSN {}, found {}",
571                expected_next_lsn, segment.start_lsn
572            )));
573        } else if segment.start_lsn > expected_next_lsn {
574            return Err(invalid_data(format!(
575                "retained segment gap: expected LSN {}, found {}",
576                expected_next_lsn, segment.start_lsn
577            )));
578        }
579
580        for unit in segment.units {
581            if unit.lsn < expected_next_lsn {
582                continue;
583            }
584            if unit.lsn > expected_next_lsn {
585                return Err(invalid_data(format!(
586                    "retained unit gap: expected LSN {}, found {}",
587                    expected_next_lsn, unit.lsn
588                )));
589            }
590            first_lsn = Some(first_lsn.unwrap_or(unit.lsn));
591            have_started = true;
592            units_available = units_available
593                .checked_add(1)
594                .ok_or_else(|| invalid_data("retained tail unit count overflow"))?;
595            if unit.lsn >= through_lsn {
596                return Ok(RetainedTailAvailability {
597                    since_lsn,
598                    through_lsn,
599                    units_available,
600                    first_lsn,
601                    last_lsn: Some(unit.lsn),
602                });
603            }
604            expected_next_lsn = expected_next_lsn
605                .checked_add(1)
606                .ok_or_else(|| invalid_data("retained tail LSN overflow"))?;
607        }
608    }
609
610    Err(invalid_data(format!(
611        "retained tail missing required LSN {} through {}",
612        expected_next_lsn, through_lsn
613    )))
614}
615
616pub fn retained_tail_progress(
617    dir: &Path,
618    expected_identity: SegmentIdentity,
619    since_lsn: u64,
620    requested_through_lsn: u64,
621) -> io::Result<RetainedTailProgress> {
622    validate_identity(expected_identity)?;
623    if requested_through_lsn <= since_lsn {
624        return Ok(RetainedTailProgress {
625            since_lsn,
626            requested_through_lsn,
627            contiguous_through_lsn: since_lsn,
628            units_available: 0,
629            first_lsn: None,
630            last_lsn: None,
631        });
632    }
633
634    let mut expected_next_lsn = since_lsn
635        .checked_add(1)
636        .ok_or_else(|| invalid_data("retained tail LSN overflow"))?;
637    let mut units_available = 0usize;
638    let mut first_lsn = None;
639    let mut last_lsn = None;
640    let mut have_started = false;
641    let mut chain_catalog_version = 0u16;
642
643    for file in list_segment_files(dir)? {
644        if file.end_lsn <= since_lsn {
645            continue;
646        }
647        if file.start_lsn > requested_through_lsn {
648            break;
649        }
650
651        let segment = read_segment_file(&file.path)?;
652        if segment.start_lsn != file.start_lsn || segment.end_lsn != file.end_lsn {
653            return Err(invalid_data(format!(
654                "retained segment filename range {}-{} does not match header range {}-{}",
655                file.start_lsn, file.end_lsn, segment.start_lsn, segment.end_lsn
656            )));
657        }
658        if !segment.identity.lineage_matches(expected_identity) {
659            return Err(invalid_data(
660                "retained segment identity does not match expected database history",
661            ));
662        }
663        if segment.identity.catalog_version < chain_catalog_version {
664            return Err(invalid_data(format!(
665                "retained segment catalog format decreased across chain (v{} then v{}); rebootstrap required",
666                chain_catalog_version, segment.identity.catalog_version
667            )));
668        }
669        chain_catalog_version = segment.identity.catalog_version;
670        if segment.end_lsn < expected_next_lsn {
671            continue;
672        }
673
674        if !have_started {
675            if segment.start_lsn > expected_next_lsn {
676                return Err(invalid_data(format!(
677                    "retained segment gap: expected LSN {}, found {}",
678                    expected_next_lsn, segment.start_lsn
679                )));
680            }
681        } else if segment.start_lsn < expected_next_lsn {
682            return Err(invalid_data(format!(
683                "retained segment overlap: expected LSN {}, found {}",
684                expected_next_lsn, segment.start_lsn
685            )));
686        } else if segment.start_lsn > expected_next_lsn {
687            return Err(invalid_data(format!(
688                "retained segment gap: expected LSN {}, found {}",
689                expected_next_lsn, segment.start_lsn
690            )));
691        }
692
693        for unit in segment.units {
694            if unit.lsn < expected_next_lsn {
695                continue;
696            }
697            if unit.lsn > expected_next_lsn {
698                return Err(invalid_data(format!(
699                    "retained unit gap: expected LSN {}, found {}",
700                    expected_next_lsn, unit.lsn
701                )));
702            }
703            if unit.lsn > requested_through_lsn {
704                return Ok(RetainedTailProgress {
705                    since_lsn,
706                    requested_through_lsn,
707                    contiguous_through_lsn: last_lsn.unwrap_or(since_lsn),
708                    units_available,
709                    first_lsn,
710                    last_lsn,
711                });
712            }
713
714            first_lsn = Some(first_lsn.unwrap_or(unit.lsn));
715            last_lsn = Some(unit.lsn);
716            have_started = true;
717            units_available = units_available
718                .checked_add(1)
719                .ok_or_else(|| invalid_data("retained tail unit count overflow"))?;
720            expected_next_lsn = expected_next_lsn
721                .checked_add(1)
722                .ok_or_else(|| invalid_data("retained tail LSN overflow"))?;
723            if unit.lsn >= requested_through_lsn {
724                return Ok(RetainedTailProgress {
725                    since_lsn,
726                    requested_through_lsn,
727                    contiguous_through_lsn: unit.lsn,
728                    units_available,
729                    first_lsn,
730                    last_lsn,
731                });
732            }
733        }
734    }
735
736    Ok(RetainedTailProgress {
737        since_lsn,
738        requested_through_lsn,
739        contiguous_through_lsn: last_lsn.unwrap_or(since_lsn),
740        units_available,
741        first_lsn,
742        last_lsn,
743    })
744}
745
746fn parse_segment_file_name(name: &str) -> Option<(u64, u64)> {
747    let rest = name.strip_prefix("retained-")?;
748    let rest = rest.strip_suffix(".prul")?;
749    let (start, end) = rest.split_once('-')?;
750    let start_lsn = start.parse().ok()?;
751    let end_lsn = end.parse().ok()?;
752    Some((start_lsn, end_lsn))
753}
754
755fn temp_segment_path(dir: &Path, segment: &RetainedSegment) -> PathBuf {
756    let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
757    let nanos = SystemTime::now()
758        .duration_since(UNIX_EPOCH)
759        .map(|duration| duration.as_nanos())
760        .unwrap_or(0);
761    dir.join(format!(
762        ".{}.tmp.{}.{}.{}",
763        segment_file_name(segment.start_lsn, segment.end_lsn),
764        std::process::id(),
765        nanos,
766        counter
767    ))
768}
769
770fn validate_identity(identity: SegmentIdentity) -> io::Result<()> {
771    if identity.primary_generation == 0 {
772        return Err(invalid_data(
773            "retained segment primary generation must be non-zero",
774        ));
775    }
776    if identity.database_id == [0; 16] {
777        return Err(invalid_data(
778            "retained segment database id must be non-zero",
779        ));
780    }
781    if identity.wal_format_version != WAL_FORMAT_VERSION {
782        return Err(invalid_data("retained segment WAL format is unsupported"));
783    }
784    // Catalog version is a compatibility annotation, not a strict-equality
785    // identity field. A producer stamps its *active* catalog version, which may
786    // legitimately be older than this binary's maximum (a database that never
787    // activated v6 still stamps v5). Accept any version this binary can read
788    // (1..=CATALOG_VERSION); reject only a version newer than we understand.
789    if identity.catalog_version == 0 {
790        return Err(invalid_data(
791            "retained segment catalog format v0 is invalid",
792        ));
793    }
794    if identity.catalog_version > CATALOG_VERSION {
795        return Err(invalid_data(format!(
796            "retained segment catalog format v{} is newer than this binary supports (max v{})",
797            identity.catalog_version, CATALOG_VERSION
798        )));
799    }
800    Ok(())
801}
802
803fn validate_units(units: &[RetainedUnit]) -> io::Result<()> {
804    if units.is_empty() {
805        return Err(invalid_input(
806            "retained segment must contain at least one unit",
807        ));
808    }
809    if units.len() > u32::MAX as usize {
810        return Err(invalid_input("retained segment has too many units"));
811    }
812
813    let mut expected_lsn = units[0].lsn;
814    if expected_lsn == 0 {
815        return Err(invalid_input("retained unit LSN must be non-zero"));
816    }
817    for unit in units {
818        if unit.lsn != expected_lsn {
819            return Err(invalid_input("retained unit LSNs must be contiguous"));
820        }
821        if !is_known_record_type(unit.record_type) {
822            return Err(invalid_input("unknown retained unit record type"));
823        }
824        checked_data_len(unit.data.len())?;
825        expected_lsn = expected_lsn
826            .checked_add(1)
827            .ok_or_else(|| invalid_input("retained unit LSN overflow"))?;
828    }
829    Ok(())
830}
831
832fn is_known_record_type(record_type: u8) -> bool {
833    matches!(record_type, 1..=10)
834}
835
836fn unit_crc(unit: &RetainedUnit) -> u32 {
837    let mut crc_input = Vec::with_capacity(17 + unit.data.len());
838    crc_input.extend_from_slice(&unit.tx_id.to_le_bytes());
839    crc_input.push(unit.record_type);
840    crc_input.extend_from_slice(&unit.lsn.to_le_bytes());
841    crc_input.extend_from_slice(&unit.data);
842    crc32fast::hash(&crc_input)
843}
844
845fn checked_unit_count(count: usize) -> io::Result<u32> {
846    u32::try_from(count).map_err(|_| invalid_input("retained segment has too many units"))
847}
848
849fn checked_data_len(len: usize) -> io::Result<u32> {
850    if len > MAX_RETAINED_UNIT_DATA_SIZE {
851        return Err(invalid_input("retained unit payload exceeds maximum size"));
852    }
853    u32::try_from(len).map_err(|_| invalid_input("retained unit payload is too large"))
854}
855
856fn read_exact<'a>(
857    bytes: &'a [u8],
858    pos: &mut usize,
859    len: usize,
860    field: &str,
861) -> io::Result<&'a [u8]> {
862    let end = pos
863        .checked_add(len)
864        .ok_or_else(|| invalid_data(format!("{field} offset overflow")))?;
865    if end > bytes.len() {
866        return Err(invalid_data(format!("truncated {field}")));
867    }
868    let slice = &bytes[*pos..end];
869    *pos = end;
870    Ok(slice)
871}
872
873fn read_u8_at_cursor(bytes: &[u8], pos: &mut usize, field: &str) -> io::Result<u8> {
874    let slice = read_exact(bytes, pos, 1, field)?;
875    Ok(slice[0])
876}
877
878fn read_u16_at_cursor(bytes: &[u8], pos: &mut usize, field: &str) -> io::Result<u16> {
879    let slice = read_exact(bytes, pos, 2, field)?;
880    let arr: [u8; 2] = slice
881        .try_into()
882        .map_err(|_| invalid_data(format!("invalid {field}")))?;
883    Ok(u16::from_le_bytes(arr))
884}
885
886fn read_u32_at_cursor(bytes: &[u8], pos: &mut usize, field: &str) -> io::Result<u32> {
887    let slice = read_exact(bytes, pos, 4, field)?;
888    let arr: [u8; 4] = slice
889        .try_into()
890        .map_err(|_| invalid_data(format!("invalid {field}")))?;
891    Ok(u32::from_le_bytes(arr))
892}
893
894fn read_u64_at_cursor(bytes: &[u8], pos: &mut usize, field: &str) -> io::Result<u64> {
895    let slice = read_exact(bytes, pos, 8, field)?;
896    let arr: [u8; 8] = slice
897        .try_into()
898        .map_err(|_| invalid_data(format!("invalid {field}")))?;
899    Ok(u64::from_le_bytes(arr))
900}
901
902fn read_u32(bytes: &[u8], pos: usize, field: &str) -> io::Result<u32> {
903    let end = pos
904        .checked_add(4)
905        .ok_or_else(|| invalid_data(format!("{field} offset overflow")))?;
906    if end > bytes.len() {
907        return Err(invalid_data(format!("truncated {field}")));
908    }
909    let arr: [u8; 4] = bytes[pos..end]
910        .try_into()
911        .map_err(|_| invalid_data(format!("invalid {field}")))?;
912    Ok(u32::from_le_bytes(arr))
913}
914
915#[cfg(unix)]
916fn fsync_dir(dir: &Path) -> io::Result<()> {
917    File::open(dir)?.sync_all()
918}
919
920#[cfg(not(unix))]
921fn fsync_dir(_dir: &Path) -> io::Result<()> {
922    Ok(())
923}
924
925fn invalid_input(message: impl Into<String>) -> io::Error {
926    io::Error::new(io::ErrorKind::InvalidInput, message.into())
927}
928
929fn invalid_data(message: impl Into<String>) -> io::Error {
930    io::Error::new(io::ErrorKind::InvalidData, message.into())
931}
932
933#[cfg(test)]
934mod tests {
935    use super::*;
936    use powdb_storage::catalog::LEGACY_CATALOG_VERSION;
937    use std::sync::{Arc, Barrier};
938
939    fn identity() -> SegmentIdentity {
940        SegmentIdentity::current(*b"0123456789abcdef", 7)
941    }
942
943    fn unit(lsn: u64) -> RetainedUnit {
944        RetainedUnit {
945            tx_id: 42,
946            record_type: 1,
947            lsn,
948            data: format!("payload-{lsn}").into_bytes(),
949        }
950    }
951
952    #[test]
953    fn segment_round_trips() {
954        let segment = RetainedSegment::new(identity(), vec![unit(10), unit(11)]).unwrap();
955        let bytes = segment.to_bytes().unwrap();
956        let decoded = RetainedSegment::from_bytes(&bytes).unwrap();
957        assert_eq!(decoded, segment);
958    }
959
960    #[test]
961    fn rejects_empty_and_non_contiguous_segments() {
962        assert!(RetainedSegment::new(identity(), Vec::new()).is_err());
963        let err = RetainedSegment::new(identity(), vec![unit(10), unit(12)]).unwrap_err();
964        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
965    }
966
967    #[test]
968    fn public_segment_literal_is_validated_before_serialization() {
969        let invalid = RetainedSegment {
970            identity: identity(),
971            start_lsn: 99,
972            end_lsn: 100,
973            units: vec![unit(1), unit(2)],
974        };
975
976        let err = invalid.to_bytes().unwrap_err();
977        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
978
979        let dir = tempfile::tempdir().unwrap();
980        let err = write_segment_atomic(dir.path(), &invalid).unwrap_err();
981        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
982        assert!(
983            list_segment_files(dir.path()).unwrap().is_empty(),
984            "invalid public segment literals must not publish unreadable retained logs"
985        );
986    }
987
988    #[test]
989    fn rejects_corrupt_footer_checksum() {
990        let segment = RetainedSegment::new(identity(), vec![unit(1), unit(2)]).unwrap();
991        let mut bytes = segment.to_bytes().unwrap();
992        bytes[HEADER_LEN + 3] ^= 0x55;
993        let err = RetainedSegment::from_bytes(&bytes).unwrap_err();
994        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
995    }
996
997    #[test]
998    fn rejects_truncated_segment() {
999        let segment = RetainedSegment::new(identity(), vec![unit(1), unit(2)]).unwrap();
1000        let bytes = segment.to_bytes().unwrap();
1001        let truncated = &bytes[..bytes.len() - 3];
1002        let err = RetainedSegment::from_bytes(truncated).unwrap_err();
1003        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
1004    }
1005
1006    #[test]
1007    fn atomic_publish_and_range_read_work() {
1008        let dir = tempfile::tempdir().unwrap();
1009        let first = RetainedSegment::new(identity(), vec![unit(1), unit(2)]).unwrap();
1010        let second = RetainedSegment::new(identity(), vec![unit(3), unit(4)]).unwrap();
1011
1012        let first_path = write_segment_atomic(dir.path(), &first).unwrap();
1013        let second_path = write_segment_atomic(dir.path(), &second).unwrap();
1014        assert!(first_path.exists());
1015        assert!(second_path.exists());
1016
1017        let files = list_segment_files(dir.path()).unwrap();
1018        assert_eq!(files.len(), 2);
1019        assert_eq!(files[0].start_lsn, 1);
1020        assert_eq!(files[1].start_lsn, 3);
1021
1022        let units = read_units_since(dir.path(), identity(), 2, 10).unwrap();
1023        let lsns: Vec<u64> = units.into_iter().map(|unit| unit.lsn).collect();
1024        assert_eq!(lsns, vec![3, 4]);
1025    }
1026
1027    #[cfg(unix)]
1028    #[test]
1029    fn atomic_publish_creates_owner_only_segment_dir() {
1030        use std::os::unix::fs::PermissionsExt;
1031
1032        let dir = tempfile::tempdir().unwrap();
1033        let segment_dir = dir.path().join("retained");
1034        let segment = RetainedSegment::new(identity(), vec![unit(1)]).unwrap();
1035
1036        write_segment_atomic(&segment_dir, &segment).unwrap();
1037
1038        let mode = std::fs::metadata(&segment_dir)
1039            .unwrap()
1040            .permissions()
1041            .mode()
1042            & 0o777;
1043        assert_eq!(mode, 0o700);
1044    }
1045
1046    #[test]
1047    fn read_segment_file_rejects_oversized_file_before_reading() {
1048        let dir = tempfile::tempdir().unwrap();
1049        let path = dir.path().join(segment_file_name(1, 1));
1050        let file = File::create(&path).unwrap();
1051        file.set_len(MAX_RETAINED_SEGMENT_FILE_SIZE + 1).unwrap();
1052        drop(file);
1053
1054        let err = read_segment_file(&path).unwrap_err();
1055        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
1056        assert!(err.to_string().contains("maximum size"));
1057    }
1058
1059    #[test]
1060    fn range_read_honors_limit() {
1061        let dir = tempfile::tempdir().unwrap();
1062        let segment = RetainedSegment::new(identity(), vec![unit(1), unit(2), unit(3)]).unwrap();
1063        write_segment_atomic(dir.path(), &segment).unwrap();
1064
1065        let units = read_units_since(dir.path(), identity(), 0, 2).unwrap();
1066        let lsns: Vec<u64> = units.into_iter().map(|unit| unit.lsn).collect();
1067        assert_eq!(lsns, vec![1, 2]);
1068    }
1069
1070    #[test]
1071    fn range_read_stops_at_through_lsn() {
1072        let dir = tempfile::tempdir().unwrap();
1073        let segment = RetainedSegment::new(
1074            identity(),
1075            vec![unit(1), unit(2), unit(3), unit(4), unit(5)],
1076        )
1077        .unwrap();
1078        write_segment_atomic(dir.path(), &segment).unwrap();
1079
1080        let units = read_units_through(dir.path(), identity(), 1, 3, 10).unwrap();
1081        let lsns: Vec<u64> = units.into_iter().map(|unit| unit.lsn).collect();
1082        assert_eq!(lsns, vec![2, 3]);
1083    }
1084
1085    #[test]
1086    fn retained_tail_progress_reports_contiguous_prefix_without_requiring_target() {
1087        let dir = tempfile::tempdir().unwrap();
1088        write_segment_atomic(
1089            dir.path(),
1090            &RetainedSegment::new(identity(), vec![unit(1), unit(2), unit(3)]).unwrap(),
1091        )
1092        .unwrap();
1093
1094        let progress = retained_tail_progress(dir.path(), identity(), 0, 5).unwrap();
1095        assert_eq!(progress.since_lsn, 0);
1096        assert_eq!(progress.requested_through_lsn, 5);
1097        assert_eq!(progress.contiguous_through_lsn, 3);
1098        assert_eq!(progress.units_available, 3);
1099        assert_eq!(progress.first_lsn, Some(1));
1100        assert_eq!(progress.last_lsn, Some(3));
1101    }
1102
1103    #[test]
1104    fn retained_tail_progress_rejects_gapped_retained_history() {
1105        let dir = tempfile::tempdir().unwrap();
1106        write_segment_atomic(
1107            dir.path(),
1108            &RetainedSegment::new(identity(), vec![unit(1), unit(2)]).unwrap(),
1109        )
1110        .unwrap();
1111        write_segment_atomic(
1112            dir.path(),
1113            &RetainedSegment::new(identity(), vec![unit(4), unit(5)]).unwrap(),
1114        )
1115        .unwrap();
1116
1117        let err = retained_tail_progress(dir.path(), identity(), 0, 5).unwrap_err();
1118        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
1119        assert!(err.to_string().contains("gap"));
1120    }
1121
1122    #[test]
1123    fn concurrent_publish_same_range_is_no_clobber() {
1124        let dir = tempfile::tempdir().unwrap();
1125        let segment = Arc::new(RetainedSegment::new(identity(), vec![unit(1), unit(2)]).unwrap());
1126        let barrier = Arc::new(Barrier::new(8));
1127
1128        let mut handles = Vec::new();
1129        for _ in 0..8 {
1130            let dir = dir.path().to_path_buf();
1131            let segment = Arc::clone(&segment);
1132            let barrier = Arc::clone(&barrier);
1133            handles.push(std::thread::spawn(move || {
1134                barrier.wait();
1135                write_segment_atomic(&dir, &segment)
1136            }));
1137        }
1138
1139        let mut successes = 0usize;
1140        let mut already_exists = 0usize;
1141        for handle in handles {
1142            match handle.join().unwrap() {
1143                Ok(_) => successes += 1,
1144                Err(err) if err.kind() == io::ErrorKind::AlreadyExists => already_exists += 1,
1145                Err(err) => panic!("unexpected publish error: {err}"),
1146            }
1147        }
1148        assert_eq!(successes, 1);
1149        assert_eq!(already_exists, 7);
1150
1151        let files = list_segment_files(dir.path()).unwrap();
1152        assert_eq!(files.len(), 1);
1153        let temp_files = std::fs::read_dir(dir.path())
1154            .unwrap()
1155            .filter_map(Result::ok)
1156            .filter(|entry| entry.file_name().to_string_lossy().contains(".tmp."))
1157            .count();
1158        assert_eq!(temp_files, 0);
1159    }
1160
1161    #[test]
1162    fn read_units_since_rejects_missing_segment_gap() {
1163        let dir = tempfile::tempdir().unwrap();
1164        write_segment_atomic(
1165            dir.path(),
1166            &RetainedSegment::new(identity(), vec![unit(1), unit(2)]).unwrap(),
1167        )
1168        .unwrap();
1169        write_segment_atomic(
1170            dir.path(),
1171            &RetainedSegment::new(identity(), vec![unit(4), unit(5)]).unwrap(),
1172        )
1173        .unwrap();
1174
1175        let err = read_units_since(dir.path(), identity(), 0, 10).unwrap_err();
1176        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
1177        assert!(err.to_string().contains("gap"));
1178    }
1179
1180    #[test]
1181    fn validate_retained_tail_available_accepts_contiguous_tail() {
1182        let dir = tempfile::tempdir().unwrap();
1183        write_segment_atomic(
1184            dir.path(),
1185            &RetainedSegment::new(identity(), vec![unit(1), unit(2)]).unwrap(),
1186        )
1187        .unwrap();
1188        write_segment_atomic(
1189            dir.path(),
1190            &RetainedSegment::new(identity(), vec![unit(3), unit(4), unit(5)]).unwrap(),
1191        )
1192        .unwrap();
1193
1194        let availability = validate_retained_tail_available(dir.path(), identity(), 2, 5).unwrap();
1195        assert_eq!(availability.since_lsn, 2);
1196        assert_eq!(availability.through_lsn, 5);
1197        assert_eq!(availability.units_available, 3);
1198        assert_eq!(availability.first_lsn, Some(3));
1199        assert_eq!(availability.last_lsn, Some(5));
1200    }
1201
1202    #[test]
1203    fn validate_retained_tail_available_allows_empty_target_range() {
1204        let dir = tempfile::tempdir().unwrap();
1205        let availability = validate_retained_tail_available(dir.path(), identity(), 7, 7).unwrap();
1206        assert_eq!(availability.units_available, 0);
1207        assert_eq!(availability.first_lsn, None);
1208        assert_eq!(availability.last_lsn, None);
1209    }
1210
1211    #[test]
1212    fn validate_retained_tail_available_rejects_missing_tail() {
1213        let dir = tempfile::tempdir().unwrap();
1214        write_segment_atomic(
1215            dir.path(),
1216            &RetainedSegment::new(identity(), vec![unit(1), unit(2)]).unwrap(),
1217        )
1218        .unwrap();
1219        write_segment_atomic(
1220            dir.path(),
1221            &RetainedSegment::new(identity(), vec![unit(4), unit(5)]).unwrap(),
1222        )
1223        .unwrap();
1224
1225        let err = validate_retained_tail_available(dir.path(), identity(), 2, 5).unwrap_err();
1226        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
1227        assert!(err.to_string().contains("gap"));
1228    }
1229
1230    #[test]
1231    fn validate_retained_tail_available_rejects_overlapping_segments() {
1232        let dir = tempfile::tempdir().unwrap();
1233        write_segment_atomic(
1234            dir.path(),
1235            &RetainedSegment::new(identity(), vec![unit(1), unit(2), unit(3)]).unwrap(),
1236        )
1237        .unwrap();
1238        write_segment_atomic(
1239            dir.path(),
1240            &RetainedSegment::new(identity(), vec![unit(3), unit(4)]).unwrap(),
1241        )
1242        .unwrap();
1243
1244        let err = validate_retained_tail_available(dir.path(), identity(), 0, 4).unwrap_err();
1245        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
1246        assert!(err.to_string().contains("overlap"));
1247    }
1248
1249    #[test]
1250    fn read_units_since_rejects_overlapping_segments() {
1251        let dir = tempfile::tempdir().unwrap();
1252        write_segment_atomic(
1253            dir.path(),
1254            &RetainedSegment::new(identity(), vec![unit(1), unit(2)]).unwrap(),
1255        )
1256        .unwrap();
1257        write_segment_atomic(
1258            dir.path(),
1259            &RetainedSegment::new(identity(), vec![unit(2), unit(3)]).unwrap(),
1260        )
1261        .unwrap();
1262
1263        let err = read_units_since(dir.path(), identity(), 0, 10).unwrap_err();
1264        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
1265        assert!(err.to_string().contains("overlap"));
1266    }
1267
1268    #[test]
1269    fn read_units_since_rejects_filename_header_range_mismatch() {
1270        let dir = tempfile::tempdir().unwrap();
1271        let segment = RetainedSegment::new(identity(), vec![unit(1), unit(2)]).unwrap();
1272        std::fs::write(
1273            dir.path().join(segment_file_name(10, 11)),
1274            segment.to_bytes().unwrap(),
1275        )
1276        .unwrap();
1277
1278        let err = read_units_since(dir.path(), identity(), 0, 10).unwrap_err();
1279        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
1280        assert!(err.to_string().contains("filename range"));
1281    }
1282
1283    #[test]
1284    fn read_units_since_rejects_mixed_database_identity() {
1285        let dir = tempfile::tempdir().unwrap();
1286        let wrong_identity = SegmentIdentity::current(*b"fedcba9876543210", 7);
1287        write_segment_atomic(
1288            dir.path(),
1289            &RetainedSegment::new(wrong_identity, vec![unit(1), unit(2)]).unwrap(),
1290        )
1291        .unwrap();
1292
1293        let err = read_units_since(dir.path(), identity(), 0, 10).unwrap_err();
1294        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
1295        assert!(err.to_string().contains("identity"));
1296    }
1297
1298    #[test]
1299    fn read_units_since_rejects_mixed_primary_generation() {
1300        let dir = tempfile::tempdir().unwrap();
1301        let wrong_identity = SegmentIdentity::current(*b"0123456789abcdef", 8);
1302        write_segment_atomic(
1303            dir.path(),
1304            &RetainedSegment::new(wrong_identity, vec![unit(1), unit(2)]).unwrap(),
1305        )
1306        .unwrap();
1307
1308        let err = read_units_since(dir.path(), identity(), 0, 10).unwrap_err();
1309        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
1310        assert!(err.to_string().contains("identity"));
1311    }
1312
1313    #[test]
1314    fn rejects_zero_database_id_and_primary_generation() {
1315        assert!(RetainedSegment::new(SegmentIdentity::current([0; 16], 1), vec![unit(1)]).is_err());
1316        assert!(RetainedSegment::new(
1317            SegmentIdentity::current(*b"0123456789abcdef", 0),
1318            vec![unit(1)]
1319        )
1320        .is_err());
1321    }
1322
1323    #[test]
1324    fn rejects_impossible_record_count_before_allocating() {
1325        let segment = RetainedSegment::new(identity(), vec![unit(1)]).unwrap();
1326        let mut bytes = segment.to_bytes().unwrap();
1327        bytes[12..16].copy_from_slice(&u32::MAX.to_le_bytes());
1328        rewrite_footer_crc(&mut bytes);
1329
1330        let err = RetainedSegment::from_bytes(&bytes).unwrap_err();
1331        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
1332        assert!(err.to_string().contains("record count"));
1333    }
1334
1335    fn identity_v(catalog_version: u16) -> SegmentIdentity {
1336        SegmentIdentity::with_catalog_version(*b"0123456789abcdef", 7, catalog_version)
1337    }
1338
1339    #[test]
1340    fn validate_identity_accepts_older_and_rejects_newer_catalog_version() {
1341        // Legacy (v5) and current (v6) formats both validate on this binary.
1342        assert!(identity_v(LEGACY_CATALOG_VERSION).validate().is_ok());
1343        assert!(identity_v(CATALOG_VERSION).validate().is_ok());
1344        // v0 is invalid.
1345        assert!(identity_v(0).validate().is_err());
1346        // A version newer than this binary is rejected, naming both versions.
1347        let err = identity_v(CATALOG_VERSION + 1).validate().unwrap_err();
1348        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
1349        let msg = err.to_string();
1350        assert!(msg.contains(&format!("v{}", CATALOG_VERSION + 1)), "{msg}");
1351        assert!(msg.contains(&format!("max v{CATALOG_VERSION}")), "{msg}");
1352    }
1353
1354    #[test]
1355    fn v5_stamped_segment_reads_on_v6_binary() {
1356        let dir = tempfile::tempdir().unwrap();
1357        write_segment_atomic(
1358            dir.path(),
1359            &RetainedSegment::new(identity_v(LEGACY_CATALOG_VERSION), vec![unit(1), unit(2)])
1360                .unwrap(),
1361        )
1362        .unwrap();
1363        // Expected identity stamps this binary's max (v6); lineage matches and
1364        // the older annotation is accepted.
1365        let units = read_units_since(dir.path(), identity(), 0, 10).unwrap();
1366        let lsns: Vec<u64> = units.into_iter().map(|unit| unit.lsn).collect();
1367        assert_eq!(lsns, vec![1, 2]);
1368    }
1369
1370    #[test]
1371    fn catalog_version_increase_across_chain_is_accepted() {
1372        let dir = tempfile::tempdir().unwrap();
1373        write_segment_atomic(
1374            dir.path(),
1375            &RetainedSegment::new(identity_v(LEGACY_CATALOG_VERSION), vec![unit(1), unit(2)])
1376                .unwrap(),
1377        )
1378        .unwrap();
1379        write_segment_atomic(
1380            dir.path(),
1381            &RetainedSegment::new(identity_v(CATALOG_VERSION), vec![unit(3), unit(4)]).unwrap(),
1382        )
1383        .unwrap();
1384        // Activation mid-stream (v5 then v6) is a valid, monotonic chain.
1385        let units = read_units_since(dir.path(), identity(), 0, 10).unwrap();
1386        let lsns: Vec<u64> = units.into_iter().map(|unit| unit.lsn).collect();
1387        assert_eq!(lsns, vec![1, 2, 3, 4]);
1388    }
1389
1390    #[test]
1391    fn catalog_version_decrease_across_chain_is_rejected() {
1392        let dir = tempfile::tempdir().unwrap();
1393        write_segment_atomic(
1394            dir.path(),
1395            &RetainedSegment::new(identity_v(CATALOG_VERSION), vec![unit(1), unit(2)]).unwrap(),
1396        )
1397        .unwrap();
1398        write_segment_atomic(
1399            dir.path(),
1400            &RetainedSegment::new(identity_v(LEGACY_CATALOG_VERSION), vec![unit(3), unit(4)])
1401                .unwrap(),
1402        )
1403        .unwrap();
1404        let err = read_units_since(dir.path(), identity(), 0, 10).unwrap_err();
1405        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
1406        assert!(err.to_string().contains("decreased across chain"), "{err}");
1407    }
1408
1409    fn rewrite_footer_crc(bytes: &mut [u8]) {
1410        let footer_start = bytes.len() - FOOTER_LEN;
1411        let crc = crc32fast::hash(&bytes[..footer_start]);
1412        bytes[footer_start + 4..footer_start + 8].copy_from_slice(&crc.to_le_bytes());
1413    }
1414}