Skip to main content

rhiza_sql/
qwal.rs

1use std::collections::HashSet;
2use std::fs::File;
3use std::io::{Read, Seek, SeekFrom, Write};
4use std::path::Path;
5
6use rhiza_core::{LogHash, LogIndex};
7use serde::{Deserialize, Serialize};
8#[cfg(test)]
9use sha2::{Digest, Sha256};
10
11use crate::page_state::StateIdentityV3;
12#[cfg(test)]
13use crate::page_state::{PageStateCacheV3, PageStatePatchV3};
14use crate::{Error, Result};
15
16pub const QWAL_V3_MAGIC: &[u8; 6] = b"QWAL\0\x04";
17pub const MAX_QWAL_V3_BYTES: usize = 512 * 1024;
18pub const MAX_QWAL_V3_RECEIPTS: usize = 1024;
19
20const SQLITE_HEADER_BYTES: usize = 100;
21const SQLITE_MAGIC: &[u8; 16] = b"SQLite format 3\0";
22const MIN_SQLITE_PAGE_SIZE: u32 = 512;
23const MAX_SQLITE_PAGE_SIZE: u32 = 65_536;
24const MAX_ID_BYTES: usize = 256;
25const MAX_FINGERPRINT_BYTES: usize = 4 * 1024;
26
27/// A final page image in a closed SQLite database.
28///
29/// Page numbers are one-based, as they are in SQLite's file format.
30#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
31#[serde(deny_unknown_fields)]
32pub struct QwalPageV3 {
33    pub page_no: u32,
34    pub after_image: Vec<u8>,
35}
36
37/// One successful member of a QWAL v3 batch.
38///
39/// The decided entry anchor is intentionally absent: every receipt in the
40/// envelope is installed at the single anchor of the log entry carrying the
41/// batch.
42#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
43#[serde(deny_unknown_fields)]
44pub struct QwalReceiptV3 {
45    pub request_id: String,
46    pub request_digest: LogHash,
47    pub result_blob: Vec<u8>,
48}
49
50/// Canonical QWAL v3 page effect decided by the replicated log.
51///
52/// The envelope deliberately contains no local path or WAL-index state. The
53/// caller owns the control-sidecar intent and atomic installation protocol.
54#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
55#[serde(deny_unknown_fields)]
56pub struct QwalEnvelopeV3 {
57    pub cluster_id: String,
58    pub epoch: u64,
59    pub configuration_id: u64,
60    pub recovery_generation: u64,
61    pub base_index: LogIndex,
62    pub base_hash: LogHash,
63    pub base_state: StateIdentityV3,
64    pub target_state: StateIdentityV3,
65    pub materializer_fingerprint: String,
66    pub receipts: Vec<QwalReceiptV3>,
67    pub pages: Vec<QwalPageV3>,
68}
69
70impl QwalEnvelopeV3 {
71    /// Validates every structural property that does not require the base file.
72    pub fn validate(&self) -> Result<()> {
73        validate_nonempty_bounded("cluster_id", &self.cluster_id, MAX_ID_BYTES)?;
74        validate_nonempty_bounded(
75            "materializer_fingerprint",
76            &self.materializer_fingerprint,
77            MAX_FINGERPRINT_BYTES,
78        )?;
79        let base_file_bytes = state_file_bytes(&self.base_state, "base")?;
80        let target_file_bytes = state_file_bytes(&self.target_state, "target")?;
81        if self.base_state.page_size != self.target_state.page_size {
82            return invalid("QWAL base and target page sizes differ");
83        }
84
85        if self.receipts.is_empty() || self.receipts.len() > MAX_QWAL_V3_RECEIPTS {
86            return invalid(format!(
87                "QWAL receipts must contain 1..={MAX_QWAL_V3_RECEIPTS} members"
88            ));
89        }
90        let mut result_bytes = 0usize;
91        let mut request_ids = HashSet::with_capacity(self.receipts.len());
92        for receipt in &self.receipts {
93            validate_nonempty_bounded("request_id", &receipt.request_id, MAX_ID_BYTES)?;
94            if !request_ids.insert(receipt.request_id.as_str()) {
95                return invalid("QWAL receipt request ids must be unique");
96            }
97            crate::validate_sql_result_blob_bounds(&receipt.result_blob)
98                .map_err(|_| Error::InvalidEntry("QWAL receipt result is not canonical".into()))?;
99            result_bytes = result_bytes
100                .checked_add(receipt.result_blob.len())
101                .ok_or_else(|| Error::ResourceExhausted("QWAL result bytes overflow".into()))?;
102            if result_bytes > MAX_QWAL_V3_BYTES {
103                return Err(Error::ResourceExhausted(format!(
104                    "QWAL results exceed {MAX_QWAL_V3_BYTES} bytes"
105                )));
106            }
107        }
108
109        let page_size = u64::from(self.target_state.page_size);
110
111        let mut previous = 0;
112        let mut page_bytes = 0usize;
113        for page in &self.pages {
114            if page.page_no == 0 {
115                return invalid("QWAL page number must be one-based");
116            }
117            if page.page_no <= previous {
118                return invalid("QWAL pages must be strictly ordered without duplicates");
119            }
120            if page.after_image.len() != self.target_state.page_size as usize {
121                return invalid("QWAL after-image length does not match page size");
122            }
123            let page_end = u64::from(page.page_no)
124                .checked_mul(page_size)
125                .ok_or_else(|| Error::InvalidEntry("QWAL page offset overflows".into()))?;
126            if page_end > target_file_bytes {
127                return invalid("QWAL page lies outside the target file");
128            }
129            if page.page_no == 1 {
130                validate_sqlite_header(&page.after_image, self.target_state.page_size)?;
131                validate_header_page_count(&page.after_image, target_file_bytes, page_size)?;
132            }
133            page_bytes = page_bytes
134                .checked_add(page.after_image.len())
135                .ok_or_else(|| Error::ResourceExhausted("QWAL page bytes overflow".into()))?;
136            if page_bytes > MAX_QWAL_V3_BYTES {
137                return Err(Error::ResourceExhausted(format!(
138                    "QWAL page images exceed {MAX_QWAL_V3_BYTES} bytes"
139                )));
140            }
141            previous = page.page_no;
142        }
143
144        // Growing via set_len alone would create an attacker-sized sparse
145        // file. Every page beyond the base EOF must therefore be carried by
146        // the bounded envelope. Since pages are already strictly ordered and
147        // range checked, cardinality proves that the suffix is gap-free.
148        let base_pages = self.base_state.page_count;
149        let target_pages = self.target_state.page_count;
150        debug_assert_eq!(u64::from(base_pages) * page_size, base_file_bytes);
151        debug_assert_eq!(u64::from(target_pages) * page_size, target_file_bytes);
152        if target_pages > base_pages {
153            let first_new = self
154                .pages
155                .partition_point(|page| page.page_no <= base_pages);
156            let supplied_new_pages = u32::try_from(self.pages.len() - first_new)
157                .map_err(|_| Error::ResourceExhausted("QWAL page count overflows".into()))?;
158            let required_new_pages = target_pages - base_pages;
159            if supplied_new_pages != required_new_pages {
160                return invalid("QWAL growth must include every newly allocated page");
161            }
162        }
163
164        Ok(())
165    }
166
167    pub fn encode(&self) -> Result<Vec<u8>> {
168        encode_qwal_v3(self)
169    }
170
171    pub fn decode(bytes: &[u8]) -> Result<Self> {
172        decode_qwal_v3(bytes)
173    }
174}
175
176pub fn encode_qwal_v3(effect: &QwalEnvelopeV3) -> Result<Vec<u8>> {
177    effect.validate()?;
178    let body = postcard::to_allocvec(effect)
179        .map_err(|error| Error::InvalidEntry(format!("QWAL encode failed: {error}")))?;
180    let encoded_len = QWAL_V3_MAGIC
181        .len()
182        .checked_add(body.len())
183        .ok_or_else(|| Error::ResourceExhausted("QWAL encoded length overflows".into()))?;
184    if encoded_len > MAX_QWAL_V3_BYTES {
185        return Err(Error::ResourceExhausted(format!(
186            "QWAL envelope exceeds {MAX_QWAL_V3_BYTES} bytes"
187        )));
188    }
189    let mut encoded = Vec::with_capacity(encoded_len);
190    encoded.extend_from_slice(QWAL_V3_MAGIC);
191    encoded.extend_from_slice(&body);
192    Ok(encoded)
193}
194
195pub fn decode_qwal_v3(bytes: &[u8]) -> Result<QwalEnvelopeV3> {
196    if bytes.len() > MAX_QWAL_V3_BYTES {
197        return Err(Error::ResourceExhausted(format!(
198            "QWAL envelope exceeds {MAX_QWAL_V3_BYTES} bytes"
199        )));
200    }
201    let Some(body) = bytes.strip_prefix(QWAL_V3_MAGIC) else {
202        return invalid("invalid QWAL v3 magic");
203    };
204    if body.is_empty() {
205        return invalid("empty QWAL v3 body");
206    }
207    let effect: QwalEnvelopeV3 = postcard::from_bytes(body)
208        .map_err(|error| Error::InvalidEntry(format!("QWAL decode failed: {error}")))?;
209    effect.validate()?;
210
211    // Reject alternate integer encodings and any trailing bytes. This also
212    // makes the qlog payload a unique byte representation of the envelope.
213    let canonical = postcard::to_allocvec(&effect)
214        .map_err(|error| Error::InvalidEntry(format!("QWAL re-encode failed: {error}")))?;
215    if canonical.as_slice() != body {
216        return invalid("QWAL body is not canonically encoded");
217    }
218    Ok(effect)
219}
220
221/// Test oracle for byte-for-byte database equality.
222#[cfg(test)]
223fn file_digest(path: impl AsRef<Path>) -> Result<LogHash> {
224    let mut file = File::open(path.as_ref()).map_err(io_error)?;
225    let mut hasher = Sha256::new();
226    let mut buffer = [0u8; 64 * 1024];
227    loop {
228        let read = file.read(&mut buffer).map_err(io_error)?;
229        if read == 0 {
230            break;
231        }
232        hasher.update(&buffer[..read]);
233    }
234    Ok(LogHash::from_bytes(hasher.finalize().into()))
235}
236
237/// Reads and validates the page size in a closed SQLite database header.
238pub fn sqlite_page_size(path: impl AsRef<Path>) -> Result<u32> {
239    let mut file = File::open(path.as_ref()).map_err(io_error)?;
240    let mut header = [0u8; SQLITE_HEADER_BYTES];
241    file.read_exact(&mut header).map_err(io_error)?;
242    let file_bytes = file.metadata().map_err(io_error)?.len();
243    sqlite_page_size_from_header(&header, file_bytes)
244}
245
246/// Test-only byte diff oracle for closed SQLite databases.
247#[cfg(test)]
248fn diff_closed_databases(
249    base_path: impl AsRef<Path>,
250    target_path: impl AsRef<Path>,
251) -> Result<Vec<QwalPageV3>> {
252    let base_path = base_path.as_ref();
253    let target_path = target_path.as_ref();
254    let base_page_size = sqlite_page_size(base_path)?;
255    let target_page_size = sqlite_page_size(target_path)?;
256    if base_page_size != target_page_size {
257        return invalid("SQLite base and target page sizes differ");
258    }
259
260    let page_size = base_page_size as usize;
261    let mut base = File::open(base_path).map_err(io_error)?;
262    let mut target = File::open(target_path).map_err(io_error)?;
263    let target_bytes = target.metadata().map_err(io_error)?.len();
264    let target_pages = target_bytes / u64::from(base_page_size);
265    let mut base_page = vec![0; page_size];
266    let mut target_page = vec![0; page_size];
267    let mut pages = Vec::new();
268    let mut changed_bytes = 0usize;
269
270    for page_index in 0..target_pages {
271        target.read_exact(&mut target_page).map_err(io_error)?;
272        let base_has_page = read_page_or_eof(&mut base, &mut base_page)?;
273        if !base_has_page || base_page != target_page {
274            changed_bytes = changed_bytes
275                .checked_add(page_size)
276                .ok_or_else(|| Error::ResourceExhausted("QWAL diff size overflows".into()))?;
277            if changed_bytes > MAX_QWAL_V3_BYTES {
278                return Err(Error::ResourceExhausted(format!(
279                    "QWAL changed pages exceed {MAX_QWAL_V3_BYTES} bytes"
280                )));
281            }
282            pages.push(QwalPageV3 {
283                page_no: u32::try_from(page_index + 1)
284                    .map_err(|_| Error::ResourceExhausted("QWAL page count exceeds u32".into()))?,
285                after_image: target_page.clone(),
286            });
287        }
288    }
289    Ok(pages)
290}
291
292/// Applies a structurally valid, preverified transition to the held canonical
293/// database inode. The caller must keep SQLite connections and lifecycle gates
294/// closed until this returns and the page-state binding is refreshed.
295pub(crate) fn apply_preverified_qwal_in_place(
296    file: &mut File,
297    effect: &QwalEnvelopeV3,
298    mut after_page_write: impl FnMut(u32) -> Result<()>,
299) -> Result<()> {
300    effect.validate()?;
301    let base_bytes = file.metadata().map_err(io_error)?.len();
302    if base_bytes != state_file_bytes(&effect.base_state, "base")? {
303        return invalid("QWAL base file size mismatch");
304    }
305    if sqlite_page_size_from_file(file)? != effect.base_state.page_size {
306        return invalid("QWAL base page size mismatch");
307    }
308
309    file.set_len(state_file_bytes(&effect.target_state, "target")?)
310        .map_err(io_error)?;
311    let page_size = u64::from(effect.target_state.page_size);
312    for page in &effect.pages {
313        let offset = u64::from(page.page_no - 1)
314            .checked_mul(page_size)
315            .ok_or_else(|| Error::InvalidEntry("QWAL page offset overflows".into()))?;
316        file.seek(SeekFrom::Start(offset)).map_err(io_error)?;
317        file.write_all(&page.after_image).map_err(io_error)?;
318        after_page_write(page.page_no)?;
319    }
320
321    verify_installed_pages(file, effect)
322}
323
324#[cfg(test)]
325fn verify_state_transition(base_state: &PageStateCacheV3, effect: &QwalEnvelopeV3) -> Result<()> {
326    if base_state.identity() != effect.base_state {
327        return invalid("QWAL base page state mismatch");
328    }
329    let patches: Vec<_> = effect
330        .pages
331        .iter()
332        .map(|page| PageStatePatchV3::new(page.page_no, &page.after_image))
333        .collect();
334    let derived = base_state.overlay(effect.target_state.page_count, &patches)?;
335    if derived != effect.target_state {
336        return invalid("QWAL target page state mismatch");
337    }
338    Ok(())
339}
340
341pub(crate) fn verify_installed_pages(file: &mut File, effect: &QwalEnvelopeV3) -> Result<()> {
342    if file.metadata().map_err(io_error)?.len() != state_file_bytes(&effect.target_state, "target")?
343    {
344        return invalid("QWAL target file size mismatch");
345    }
346    if sqlite_page_size_from_file(file)? != effect.target_state.page_size {
347        return invalid("QWAL target page size mismatch");
348    }
349
350    let page_size = u64::from(effect.target_state.page_size);
351    let mut installed = vec![0; effect.target_state.page_size as usize];
352    for page in &effect.pages {
353        let offset = u64::from(page.page_no - 1)
354            .checked_mul(page_size)
355            .ok_or_else(|| Error::InvalidEntry("QWAL page offset overflows".into()))?;
356        file.seek(SeekFrom::Start(offset)).map_err(io_error)?;
357        file.read_exact(&mut installed).map_err(io_error)?;
358        if installed != page.after_image {
359            return invalid("QWAL installed page does not match its after-image");
360        }
361    }
362    Ok(())
363}
364
365#[cfg(test)]
366fn read_page_or_eof(file: &mut File, page: &mut [u8]) -> Result<bool> {
367    let mut read = 0;
368    while read < page.len() {
369        let count = file.read(&mut page[read..]).map_err(io_error)?;
370        if count == 0 {
371            if read == 0 {
372                return Ok(false);
373            }
374            return invalid("SQLite base file ends in a partial page");
375        }
376        read += count;
377    }
378    Ok(true)
379}
380
381fn page_size_from_header(header: &[u8]) -> Result<u32> {
382    if header.len() < SQLITE_HEADER_BYTES || &header[..SQLITE_MAGIC.len()] != SQLITE_MAGIC {
383        return invalid("invalid SQLite database header");
384    }
385    let encoded = u16::from_be_bytes([header[16], header[17]]);
386    let page_size = if encoded == 1 {
387        MAX_SQLITE_PAGE_SIZE
388    } else {
389        u32::from(encoded)
390    };
391    validate_page_size(page_size)?;
392    Ok(page_size)
393}
394
395pub(crate) fn sqlite_page_size_from_header(header: &[u8], file_bytes: u64) -> Result<u32> {
396    let page_size = page_size_from_header(header)?;
397    if file_bytes == 0 || !file_bytes.is_multiple_of(u64::from(page_size)) {
398        return invalid("SQLite file size is not a non-zero page-size multiple");
399    }
400    validate_header_page_count(header, file_bytes, u64::from(page_size))?;
401    Ok(page_size)
402}
403
404fn sqlite_page_size_from_file(file: &mut File) -> Result<u32> {
405    let file_bytes = file.metadata().map_err(io_error)?.len();
406    let mut header = [0u8; SQLITE_HEADER_BYTES];
407    file.seek(SeekFrom::Start(0)).map_err(io_error)?;
408    file.read_exact(&mut header).map_err(io_error)?;
409    sqlite_page_size_from_header(&header, file_bytes)
410}
411
412fn validate_page_size(page_size: u32) -> Result<()> {
413    if !(MIN_SQLITE_PAGE_SIZE..=MAX_SQLITE_PAGE_SIZE).contains(&page_size)
414        || !page_size.is_power_of_two()
415    {
416        return invalid("invalid SQLite page size");
417    }
418    Ok(())
419}
420
421fn state_file_bytes(state: &StateIdentityV3, label: &str) -> Result<u64> {
422    validate_page_size(state.page_size)?;
423    if state.page_count == 0 {
424        return invalid(format!("QWAL {label} page count must be positive"));
425    }
426    if state.state_root == LogHash::ZERO {
427        return invalid(format!("QWAL {label} state root must be non-zero"));
428    }
429    u64::from(state.page_size)
430        .checked_mul(u64::from(state.page_count))
431        .ok_or_else(|| Error::ResourceExhausted(format!("QWAL {label} file size overflows")))
432}
433
434fn validate_sqlite_header(page: &[u8], expected_page_size: u32) -> Result<()> {
435    let actual = page_size_from_header(page)?;
436    if actual != expected_page_size {
437        return invalid("QWAL page 1 changes the declared SQLite page size");
438    }
439    Ok(())
440}
441
442fn validate_header_page_count(header: &[u8], file_bytes: u64, page_size: u64) -> Result<()> {
443    let declared = u32::from_be_bytes([header[28], header[29], header[30], header[31]]);
444    // A zero page count is permitted by legacy SQLite files. When present, the
445    // count must describe the closed file exactly.
446    if declared != 0 && u64::from(declared) != file_bytes / page_size {
447        return invalid("SQLite header page count does not match file size");
448    }
449    Ok(())
450}
451
452fn validate_nonempty_bounded(field: &str, value: &str, max: usize) -> Result<()> {
453    if value.is_empty() || value.len() > max {
454        return invalid(format!("QWAL {field} must contain 1..={max} bytes"));
455    }
456    Ok(())
457}
458
459fn io_error(error: std::io::Error) -> Error {
460    Error::Io(error.to_string())
461}
462
463fn invalid<T>(message: impl Into<String>) -> Result<T> {
464    Err(Error::InvalidEntry(message.into()))
465}
466
467#[cfg(test)]
468mod tests {
469    use std::fs;
470
471    use rusqlite::{params, Connection};
472
473    use super::*;
474
475    #[test]
476    fn generation_capacity_is_1024_receipts_and_512_kib() {
477        assert_eq!(MAX_QWAL_V3_RECEIPTS, 1024);
478        assert_eq!(MAX_QWAL_V3_BYTES, 512 * 1024);
479    }
480
481    fn sqlite_header(page_size: u32, page_count: u32) -> Vec<u8> {
482        let mut page = vec![0; page_size as usize];
483        page[..SQLITE_MAGIC.len()].copy_from_slice(SQLITE_MAGIC);
484        let encoded = if page_size == MAX_SQLITE_PAGE_SIZE {
485            1u16
486        } else {
487            page_size as u16
488        };
489        page[16..18].copy_from_slice(&encoded.to_be_bytes());
490        page[18] = 1;
491        page[19] = 1;
492        page[20] = 0;
493        page[21] = 64;
494        page[22] = 32;
495        page[23] = 32;
496        page[28..32].copy_from_slice(&page_count.to_be_bytes());
497        page
498    }
499
500    fn write_pages(path: &Path, page_size: u32, fills: &[u8]) {
501        let mut bytes = Vec::with_capacity(page_size as usize * fills.len());
502        for (index, fill) in fills.iter().enumerate() {
503            let mut page = vec![*fill; page_size as usize];
504            if index == 0 {
505                page = sqlite_header(page_size, fills.len() as u32);
506                page[100..].fill(*fill);
507            }
508            bytes.extend_from_slice(&page);
509        }
510        fs::write(path, bytes).unwrap();
511    }
512
513    fn page_state(path: &Path) -> PageStateCacheV3 {
514        let page_size = sqlite_page_size(path).unwrap();
515        let bytes = fs::read(path).unwrap();
516        PageStateCacheV3::from_pages(page_size, bytes.chunks_exact(page_size as usize)).unwrap()
517    }
518
519    fn envelope(base: &Path, target: &Path, pages: Vec<QwalPageV3>) -> QwalEnvelopeV3 {
520        QwalEnvelopeV3 {
521            cluster_id: "cluster-a".into(),
522            epoch: 3,
523            configuration_id: 4,
524            recovery_generation: 5,
525            base_index: 8,
526            base_hash: LogHash::digest(&[b"base-anchor"]),
527            base_state: page_state(base).identity(),
528            target_state: page_state(target).identity(),
529            materializer_fingerprint: "sqlite-test-qwal-v3".into(),
530            receipts: vec![QwalReceiptV3 {
531                request_id: "request-a".into(),
532                request_digest: LogHash::digest(&[b"request"]),
533                result_blob: crate::encode_sql_result(&crate::SqlCommandResult {
534                    statement_results: Vec::new(),
535                })
536                .unwrap(),
537            }],
538            pages,
539        }
540    }
541
542    fn diff_pages(base: &Path, target: &Path) -> Vec<QwalPageV3> {
543        diff_closed_databases(base, target).unwrap()
544    }
545
546    fn apply(base: &Path, temp: &Path, effect: &QwalEnvelopeV3) -> Result<()> {
547        let mut cache = page_state(base);
548        let patches = effect
549            .pages
550            .iter()
551            .map(|page| PageStatePatchV3::new(page.page_no, &page.after_image))
552            .collect::<Vec<_>>();
553        if cache.apply_patch(effect.target_state.page_count, &patches)? != effect.target_state {
554            return invalid("QWAL target page state mismatch");
555        }
556        fs::copy(base, temp).map_err(io_error)?;
557        let mut file = File::options()
558            .read(true)
559            .write(true)
560            .open(temp)
561            .map_err(io_error)?;
562        apply_preverified_qwal_in_place(&mut file, effect, |_| Ok(()))
563    }
564
565    #[test]
566    fn qwal_roundtrips_through_its_only_canonical_encoding() {
567        let dir = tempfile::tempdir().unwrap();
568        let base = dir.path().join("base.db");
569        let target = dir.path().join("target.db");
570        write_pages(&base, 512, &[1, 2]);
571        write_pages(&target, 512, &[1, 3]);
572        let effect = envelope(&base, &target, diff_pages(&base, &target));
573
574        let encoded = effect.encode().unwrap();
575        assert!(encoded.starts_with(QWAL_V3_MAGIC));
576        assert_eq!(QwalEnvelopeV3::decode(&encoded).unwrap(), effect);
577    }
578
579    #[test]
580    fn decoder_rejects_legacy_trailing_and_corrupted_bytes() {
581        let dir = tempfile::tempdir().unwrap();
582        let base = dir.path().join("base.db");
583        let target = dir.path().join("target.db");
584        write_pages(&base, 512, &[1]);
585        write_pages(&target, 512, &[2]);
586        let effect = envelope(&base, &target, diff_pages(&base, &target));
587        let mut trailing = effect.encode().unwrap();
588        trailing.push(0);
589        assert!(QwalEnvelopeV3::decode(&trailing).is_err());
590
591        let mut corrupt = effect.encode().unwrap();
592        corrupt[0] ^= 0xff;
593        assert!(QwalEnvelopeV3::decode(&corrupt).is_err());
594
595        for old_magic in [b"QWAL\0\x01", b"QWAL\0\x02", b"QWAL\0\x03"] {
596            let mut old = old_magic.to_vec();
597            old.extend_from_slice(&effect.encode().unwrap()[QWAL_V3_MAGIC.len()..]);
598            assert!(QwalEnvelopeV3::decode(&old).is_err());
599        }
600    }
601
602    #[test]
603    fn receipts_preserve_order_and_reject_empty_duplicate_or_oversized_batches() {
604        let dir = tempfile::tempdir().unwrap();
605        let base = dir.path().join("base.db");
606        write_pages(&base, 512, &[1]);
607        let mut effect = envelope(&base, &base, Vec::new());
608        effect.receipts = (0..MAX_QWAL_V3_RECEIPTS)
609            .map(|index| QwalReceiptV3 {
610                request_id: format!("request-{index:03}"),
611                request_digest: LogHash::digest(&[&index.to_le_bytes()]),
612                result_blob: crate::encode_sql_result(&crate::SqlCommandResult {
613                    statement_results: Vec::new(),
614                })
615                .unwrap(),
616            })
617            .collect();
618        let decoded = QwalEnvelopeV3::decode(&effect.encode().unwrap()).unwrap();
619        assert_eq!(decoded.receipts, effect.receipts);
620
621        effect.receipts[MAX_QWAL_V3_RECEIPTS - 1].request_id =
622            effect.receipts[0].request_id.clone();
623        assert!(matches!(
624            effect.validate(),
625            Err(Error::InvalidEntry(message)) if message.contains("unique")
626        ));
627
628        effect.receipts.clear();
629        assert!(effect.validate().is_err());
630
631        effect.receipts = vec![
632            QwalReceiptV3 {
633                request_id: "duplicate".into(),
634                request_digest: LogHash::ZERO,
635                result_blob: crate::encode_sql_result(&crate::SqlCommandResult {
636                    statement_results: Vec::new(),
637                })
638                .unwrap(),
639            },
640            QwalReceiptV3 {
641                request_id: "duplicate".into(),
642                request_digest: LogHash::digest(&[b"different"]),
643                result_blob: crate::encode_sql_result(&crate::SqlCommandResult {
644                    statement_results: Vec::new(),
645                })
646                .unwrap(),
647            },
648        ];
649        assert!(effect.validate().is_err());
650
651        effect.receipts = (0..=MAX_QWAL_V3_RECEIPTS)
652            .map(|index| QwalReceiptV3 {
653                request_id: format!("request-{index}"),
654                request_digest: LogHash::ZERO,
655                result_blob: crate::encode_sql_result(&crate::SqlCommandResult {
656                    statement_results: Vec::new(),
657                })
658                .unwrap(),
659            })
660            .collect();
661        assert!(effect.validate().is_err());
662    }
663
664    #[test]
665    fn validation_rejects_zero_duplicate_unordered_short_and_out_of_range_pages() {
666        let dir = tempfile::tempdir().unwrap();
667        let base = dir.path().join("base.db");
668        let target = dir.path().join("target.db");
669        write_pages(&base, 512, &[1, 2]);
670        write_pages(&target, 512, &[2, 3]);
671        let page = diff_pages(&base, &target)[0].clone();
672
673        for pages in [
674            vec![QwalPageV3 {
675                page_no: 0,
676                after_image: vec![0; 512],
677            }],
678            vec![page.clone(), page.clone()],
679            vec![
680                QwalPageV3 {
681                    page_no: 2,
682                    after_image: vec![0; 512],
683                },
684                page.clone(),
685            ],
686            vec![QwalPageV3 {
687                page_no: 1,
688                after_image: vec![0; 511],
689            }],
690            vec![QwalPageV3 {
691                page_no: 3,
692                after_image: vec![0; 512],
693            }],
694        ] {
695            assert!(envelope(&base, &target, pages).validate().is_err());
696        }
697    }
698
699    #[test]
700    fn page_state_root_is_bound_to_base_contents_and_target_shape() {
701        let dir = tempfile::tempdir().unwrap();
702        let base = dir.path().join("base.db");
703        let wrong = dir.path().join("wrong.db");
704        let target = dir.path().join("target.db");
705        write_pages(&base, 512, &[1, 2]);
706        write_pages(&wrong, 512, &[9, 2]);
707        write_pages(&target, 512, &[1, 3]);
708        let effect = envelope(&base, &target, diff_pages(&base, &target));
709
710        assert!(verify_state_transition(&page_state(&wrong), &effect).is_err());
711
712        let mut reshaped = effect.clone();
713        reshaped.target_state.page_count += 1;
714        reshaped.pages.push(QwalPageV3 {
715            page_no: 3,
716            after_image: vec![7; 512],
717        });
718        reshaped.validate().unwrap();
719        assert!(verify_state_transition(&page_state(&base), &reshaped).is_err());
720
721        let mut wrong_page_size = effect;
722        wrong_page_size.base_state.page_size = 1024;
723        wrong_page_size.target_state.page_size = 1024;
724        assert!(wrong_page_size.validate().is_err());
725    }
726
727    #[test]
728    fn validation_rejects_zero_roots_counts_and_mixed_page_sizes() {
729        let dir = tempfile::tempdir().unwrap();
730        let base = dir.path().join("base.db");
731        write_pages(&base, 512, &[1]);
732        let effect = envelope(&base, &base, Vec::new());
733
734        let mut zero_root = effect.clone();
735        zero_root.base_state.state_root = LogHash::ZERO;
736        assert!(zero_root.validate().is_err());
737
738        let mut zero_count = effect.clone();
739        zero_count.target_state.page_count = 0;
740        assert!(zero_count.validate().is_err());
741
742        let mut mixed_sizes = effect;
743        mixed_sizes.target_state.page_size = 1024;
744        assert!(mixed_sizes.validate().is_err());
745    }
746
747    #[test]
748    fn page_one_must_remain_a_valid_sqlite_header() {
749        let dir = tempfile::tempdir().unwrap();
750        let base = dir.path().join("base.db");
751        let target = dir.path().join("target.db");
752        write_pages(&base, 512, &[1]);
753        write_pages(&target, 512, &[2]);
754        let mut effect = envelope(&base, &target, diff_pages(&base, &target));
755        effect.pages[0].after_image[0] = b'X';
756        assert!(effect.validate().is_err());
757    }
758
759    #[test]
760    fn closed_file_diff_and_apply_support_growth_and_shrink() {
761        for (base_fills, target_fills) in [
762            (vec![1], vec![2, 3, 4]),
763            (vec![1, 2, 3], vec![4]),
764            (vec![1, 2, 3], vec![1, 8, 3]),
765        ] {
766            let dir = tempfile::tempdir().unwrap();
767            let base = dir.path().join("base.db");
768            let target = dir.path().join("target.db");
769            let applied = dir.path().join("applied.db");
770            write_pages(&base, 512, &base_fills);
771            write_pages(&target, 512, &target_fills);
772            let pages = diff_pages(&base, &target);
773            let effect = envelope(&base, &target, pages);
774
775            apply(&base, &applied, &effect).unwrap();
776            assert_eq!(fs::read(&applied).unwrap(), fs::read(&target).unwrap());
777        }
778    }
779
780    #[test]
781    fn validation_rejects_sparse_growth_and_missing_new_pages_before_file_allocation() {
782        let dir = tempfile::tempdir().unwrap();
783        let base = dir.path().join("base.db");
784        let target = dir.path().join("target.db");
785        write_pages(&base, 512, &[1]);
786        write_pages(&target, 512, &[2, 3, 4]);
787
788        let mut gap = envelope(&base, &target, diff_pages(&base, &target));
789        gap.pages.retain(|page| page.page_no != 2);
790        assert!(gap.validate().is_err());
791
792        let mut sparse = envelope(&base, &base, Vec::new());
793        sparse.target_state.page_count = u32::MAX;
794        let absent = dir.path().join("must-not-be-created.db");
795        assert!(sparse.validate().is_err());
796        assert!(apply(&base, &absent, &sparse).is_err());
797        assert!(!absent.exists());
798    }
799
800    #[test]
801    fn shrink_needs_no_images_for_pages_removed_by_truncation() {
802        let dir = tempfile::tempdir().unwrap();
803        let base = dir.path().join("base.db");
804        let target = dir.path().join("target.db");
805        let applied = dir.path().join("applied.db");
806        write_pages(&base, 512, &[1, 2, 3]);
807        write_pages(&target, 512, &[1]);
808        let effect = envelope(&base, &target, diff_pages(&base, &target));
809        assert!(effect.pages.iter().all(|page| page.page_no == 1));
810        effect.validate().unwrap();
811        apply(&base, &applied, &effect).unwrap();
812        assert_eq!(fs::read(applied).unwrap(), fs::read(target).unwrap());
813    }
814
815    #[test]
816    fn page_size_parser_accepts_every_sqlite_page_size_and_rejects_invalid_values() {
817        let dir = tempfile::tempdir().unwrap();
818        for exponent in 9..=16 {
819            let size = 1u32 << exponent;
820            let path = dir.path().join(format!("{size}.db"));
821            write_pages(&path, size, &[1]);
822            assert_eq!(sqlite_page_size(path).unwrap(), size);
823        }
824        let invalid = dir.path().join("invalid.db");
825        let mut page = sqlite_header(512, 1);
826        page[16..18].copy_from_slice(&768u16.to_be_bytes());
827        fs::write(&invalid, page).unwrap();
828        assert!(sqlite_page_size(invalid).is_err());
829    }
830
831    #[test]
832    fn forged_target_root_is_rejected_before_any_write() {
833        let dir = tempfile::tempdir().unwrap();
834        let base = dir.path().join("base.db");
835        let target = dir.path().join("target.db");
836        let applied = dir.path().join("applied.db");
837        write_pages(&base, 512, &[1]);
838        write_pages(&target, 512, &[2]);
839        let mut effect = envelope(&base, &target, diff_pages(&base, &target));
840        effect.target_state.state_root = LogHash::digest(&[b"forged-target-root"]);
841
842        assert!(apply(&base, &applied, &effect).is_err());
843        assert!(!applied.exists());
844    }
845
846    #[test]
847    fn captured_sqlite_effect_reproduces_native_features_byte_for_byte() {
848        let dir = tempfile::tempdir().unwrap();
849        let base = dir.path().join("base.db");
850        let target = dir.path().join("target.db");
851        let applied = dir.path().join("applied.db");
852        {
853            let connection = Connection::open(&base).unwrap();
854            connection
855                .execute_batch(
856                    "PRAGMA page_size=4096;
857                     PRAGMA journal_mode=DELETE;
858                     PRAGMA foreign_keys=ON;
859                     CREATE TABLE parent(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT);
860                     CREATE TABLE child(
861                       id INTEGER PRIMARY KEY,
862                       parent_id INTEGER REFERENCES parent(id) ON DELETE CASCADE,
863                       payload BLOB,
864                       created_at TEXT DEFAULT CURRENT_TIMESTAMP
865                     );
866                     CREATE TABLE audit(message TEXT);
867                     CREATE TRIGGER child_audit AFTER INSERT ON child BEGIN
868                       INSERT INTO audit VALUES ('child:' || NEW.id);
869                     END;",
870                )
871                .unwrap();
872        }
873        fs::copy(&base, &target).unwrap();
874        {
875            let mut connection = Connection::open(&target).unwrap();
876            connection.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
877            let transaction = connection.transaction().unwrap();
878            let parent_id: i64 = transaction
879                .query_row(
880                    "INSERT INTO parent(name) VALUES ('native') RETURNING id",
881                    [],
882                    |row| row.get(0),
883                )
884                .unwrap();
885            let returned: (i64, Vec<u8>) = transaction
886                .query_row(
887                    "INSERT INTO child(id, parent_id, payload)
888                     VALUES (7, ?1, randomblob(32)) RETURNING id, payload",
889                    params![parent_id],
890                    |row| Ok((row.get(0)?, row.get(1)?)),
891                )
892                .unwrap();
893            assert_eq!(returned.0, 7);
894            assert_eq!(returned.1.len(), 32);
895            transaction.commit().unwrap();
896        }
897
898        let pages = diff_pages(&base, &target);
899        assert!(!pages.is_empty());
900        let effect = envelope(&base, &target, pages);
901        apply(&base, &applied, &effect).unwrap();
902
903        assert_eq!(
904            file_digest(&applied).unwrap(),
905            file_digest(&target).unwrap()
906        );
907        assert_eq!(fs::read(&applied).unwrap(), fs::read(&target).unwrap());
908        let inspection =
909            Connection::open_with_flags(&applied, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)
910                .unwrap();
911        assert_eq!(
912            inspection
913                .query_row("SELECT message FROM audit", [], |row| row
914                    .get::<_, String>(0))
915                .unwrap(),
916            "child:7"
917        );
918        assert_eq!(
919            inspection
920                .query_row("PRAGMA integrity_check", [], |row| row.get::<_, String>(0))
921                .unwrap(),
922            "ok"
923        );
924    }
925}