| 81 |
81 |
|
bail!("unsupported backup source scheme: {s}");
|
| 82 |
82 |
|
}
|
| 83 |
83 |
|
|
| 84 |
|
- |
/// Backups smaller than this are treated as a failed/truncated transfer rather
|
| 85 |
|
- |
/// than a real dump. The prod backup is ~885M; even a dev fixture is well above
|
| 86 |
|
- |
/// this. The floor exists to catch an empty or header-only file (an rsync that
|
| 87 |
|
- |
/// wrote zero bytes, an empty source); `gzip -t` catches mid-stream truncation
|
| 88 |
|
- |
/// of a `.gz`.
|
|
84 |
+ |
/// Absolute floor: backups smaller than this are an empty or header-only file
|
|
85 |
+ |
/// (an rsync that wrote zero bytes, an empty source). This is the fallback when
|
|
86 |
+ |
/// there is no prior backup to compare against (the first-ever fetch).
|
| 89 |
87 |
|
const MIN_BACKUP_BYTES: u64 = 64;
|
| 90 |
88 |
|
|
|
89 |
+ |
/// Plausibility floor as a fraction of the last verified backup's size. A real
|
|
90 |
+ |
/// dump never abruptly halves; a source-side truncation that still closed a
|
|
91 |
+ |
/// valid gzip would pass `gzip -t` and the absolute floor, but not this. Every
|
|
92 |
+ |
/// row in `backups` is a previously-verified dump, so the last one is a sound
|
|
93 |
+ |
/// reference. Integer-halved at the call site.
|
|
94 |
+ |
const MIN_BACKUP_FRACTION_DENOM: i64 = 2;
|
|
95 |
+ |
|
| 91 |
96 |
|
/// Verify a freshly-downloaded backup before it is allowed to become the live
|
| 92 |
|
- |
/// dump: reject anything implausibly small, and for a gzip require a complete,
|
| 93 |
|
- |
/// valid stream (`gzip -t` fails on truncation/corruption). `is_gz` is taken
|
| 94 |
|
- |
/// from the *destination* name, not the temp path (which carries a `.partial`
|
| 95 |
|
- |
/// suffix).
|
| 96 |
|
- |
async fn verify_backup(tmp_path: &str, is_gz: bool) -> Result<()> {
|
|
97 |
+ |
/// dump: reject anything below `min_bytes` (the plausibility floor derived from
|
|
98 |
+ |
/// history), and for a gzip require a complete, valid stream (`gzip -t` fails on
|
|
99 |
+ |
/// truncation/corruption). `is_gz` is taken from the *destination* name, not the
|
|
100 |
+ |
/// temp path (which carries a `.partial` suffix).
|
|
101 |
+ |
async fn verify_backup(tmp_path: &str, is_gz: bool, min_bytes: u64) -> Result<()> {
|
| 97 |
102 |
|
let meta = tokio::fs::metadata(tmp_path)
|
| 98 |
103 |
|
.await
|
| 99 |
104 |
|
.with_context(|| format!("stat fetched backup {tmp_path}"))?;
|
| 100 |
105 |
|
anyhow::ensure!(
|
| 101 |
|
- |
meta.len() >= MIN_BACKUP_BYTES,
|
| 102 |
|
- |
"fetched backup {tmp_path} is implausibly small ({} bytes); treating as a failed/truncated transfer",
|
|
106 |
+ |
meta.len() >= min_bytes,
|
|
107 |
+ |
"fetched backup {tmp_path} is implausibly small ({} bytes, floor {min_bytes}); \
|
|
108 |
+ |
treating as a failed/truncated transfer",
|
| 103 |
109 |
|
meta.len(),
|
| 104 |
110 |
|
);
|
| 105 |
111 |
|
if is_gz {
|
| 138 |
144 |
|
let tmp_path = format!("{local_path}.partial");
|
| 139 |
145 |
|
let is_gz = local_path.ends_with(".gz");
|
| 140 |
146 |
|
|
|
147 |
+ |
// Plausibility floor: half the last verified backup's size, never below the
|
|
148 |
+ |
// absolute floor. The first-ever fetch (no prior row) falls back to the
|
|
149 |
+ |
// absolute floor.
|
|
150 |
+ |
let last_size: Option<i64> = sqlx::query_scalar(
|
|
151 |
+ |
"SELECT byte_size FROM backups ORDER BY fetched_at DESC LIMIT 1",
|
|
152 |
+ |
)
|
|
153 |
+ |
.fetch_optional(pool)
|
|
154 |
+ |
.await?;
|
|
155 |
+ |
let min_bytes = last_size
|
|
156 |
+ |
.map(|s| ((s / MIN_BACKUP_FRACTION_DENOM) as u64).max(MIN_BACKUP_BYTES))
|
|
157 |
+ |
.unwrap_or(MIN_BACKUP_BYTES);
|
|
158 |
+ |
|
| 141 |
159 |
|
let parsed = parse_source(&source)?;
|
| 142 |
160 |
|
let downloaded: Result<()> = async {
|
| 143 |
161 |
|
match parsed {
|
| 179 |
197 |
|
);
|
| 180 |
198 |
|
}
|
| 181 |
199 |
|
}
|
| 182 |
|
- |
verify_backup(&tmp_path, is_gz).await
|
|
200 |
+ |
verify_backup(&tmp_path, is_gz, min_bytes).await
|
| 183 |
201 |
|
}
|
| 184 |
202 |
|
.await;
|
| 185 |
203 |
|
|
| 197 |
215 |
|
let meta = tokio::fs::metadata(&local_path).await?;
|
| 198 |
216 |
|
let size = meta.len() as i64;
|
| 199 |
217 |
|
|
|
218 |
+ |
// Record the new backup and prune stale rows in one transaction, so a crash
|
|
219 |
+ |
// between the two can't leave the insert without the prune (or, worse, lose
|
|
220 |
+ |
// the insert while keeping a half-applied delete). The on-disk file is
|
|
221 |
+ |
// overwritten each fetch (single `local_path`), so rows older than 30 days
|
|
222 |
+ |
// reference a path that no longer exists — keep the table from growing.
|
|
223 |
+ |
let mut tx = pool.begin().await?;
|
| 200 |
224 |
|
sqlx::query(
|
| 201 |
225 |
|
"INSERT INTO backups (fetched_at, source, local_path, byte_size) VALUES (?, ?, ?, ?)",
|
| 202 |
226 |
|
)
|
| 204 |
228 |
|
.bind(&source)
|
| 205 |
229 |
|
.bind(&local_path)
|
| 206 |
230 |
|
.bind(size)
|
| 207 |
|
- |
.execute(pool)
|
|
231 |
+ |
.execute(&mut *tx)
|
| 208 |
232 |
|
.await?;
|
| 209 |
|
- |
|
| 210 |
|
- |
// Retention: prune rows fetched more than 30 days ago. The on-disk file
|
| 211 |
|
- |
// is overwritten each fetch (single `local_path`), so old rows reference
|
| 212 |
|
- |
// a path that no longer exists — keep the table from growing for no
|
| 213 |
|
- |
// good reason.
|
| 214 |
233 |
|
sqlx::query(
|
| 215 |
234 |
|
"DELETE FROM backups WHERE fetched_at < datetime('now', '-30 days')",
|
| 216 |
235 |
|
)
|
| 217 |
|
- |
.execute(pool)
|
|
236 |
+ |
.execute(&mut *tx)
|
| 218 |
237 |
|
.await?;
|
|
238 |
+ |
tx.commit().await?;
|
| 219 |
239 |
|
|
| 220 |
240 |
|
Ok(FetchedBackup { source, local_path, byte_size: Some(size) })
|
| 221 |
241 |
|
}
|
| 289 |
309 |
|
}
|
| 290 |
310 |
|
|
| 291 |
311 |
|
#[tokio::test]
|
|
312 |
+ |
async fn fetch_rejects_a_dump_far_below_the_last_backup_size() {
|
|
313 |
+ |
// Plausibility floor: a complete, valid gzip that is far smaller than the
|
|
314 |
+ |
// last verified backup is a likely source-side truncation and is rejected
|
|
315 |
+ |
// even though `gzip -t` passes — the gap the 64-byte absolute floor missed.
|
|
316 |
+ |
let tmp = tempfile::tempdir().unwrap();
|
|
317 |
+ |
let src = tmp.path().join("src.sql.gz");
|
|
318 |
+ |
write_valid_gz(&src).await; // ~4 KB valid gzip
|
|
319 |
+ |
let dest = tmp.path().join("backups/latest.sql.gz");
|
|
320 |
+ |
let topo = Arc::new(topo_with_backup(
|
|
321 |
+ |
format!("file://{}", src.display()),
|
|
322 |
+ |
dest.to_string_lossy().into_owned(),
|
|
323 |
+ |
));
|
|
324 |
+ |
let pool = mem_pool().await;
|
|
325 |
+ |
let cfg = Arc::new(Config::for_tests());
|
|
326 |
+ |
|
|
327 |
+ |
// Seed a prior verified backup far larger than the incoming one; the floor
|
|
328 |
+ |
// becomes 500_000, well above the ~4 KB dump.
|
|
329 |
+ |
sqlx::query("INSERT INTO backups (fetched_at, source, local_path, byte_size) VALUES (?, 'x', ?, 1000000)")
|
|
330 |
+ |
.bind(Utc::now().to_rfc3339())
|
|
331 |
+ |
.bind(dest.to_string_lossy().into_owned())
|
|
332 |
+ |
.execute(&pool).await.unwrap();
|
|
333 |
+ |
|
|
334 |
+ |
let err = fetch(&pool, &cfg, &topo).await.unwrap_err().to_string();
|
|
335 |
+ |
assert!(err.contains("implausibly small"), "{err}");
|
|
336 |
+ |
assert!(!dest.exists(), "a rejected dump never becomes the live backup");
|
|
337 |
+ |
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM backups")
|
|
338 |
+ |
.fetch_one(&pool).await.unwrap();
|
|
339 |
+ |
assert_eq!(count.0, 1, "the rejected fetch records no new row");
|
|
340 |
+ |
}
|
|
341 |
+ |
|
|
342 |
+ |
#[tokio::test]
|
| 292 |
343 |
|
async fn fetch_rejects_truncated_gz_and_leaves_no_live_file() {
|
| 293 |
344 |
|
let tmp = tempfile::tempdir().unwrap();
|
| 294 |
345 |
|
let src = tmp.path().join("src.sql.gz");
|