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#[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#[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#[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 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 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 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#[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
237pub 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#[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
292pub(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 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}