Skip to main content

max / synckit

Move blob's test module to a sibling file blob.rs goes 1516 lines to 987, its 23 tests moving to src/client/blob/tests.rs. Completes the synckit-client set.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01EEmeiSJnmyL98QzA5Dwsvz
Author: Max Johnson <me@maxj.phd> · 2026-09-03 23:26 UTC
Signed with PGP, not checked
Commit: ed1778a0628a67a597fe476d03db72b004c8b334
Parent: bce29d2
2 files changed, +500 insertions, -497 deletions
@@ -984,533 +984,4 @@
984 984 }
985 985
986 986 #[cfg(test)]
987 - mod tests {
988 - use super::*;
989 - use crate::types::*;
990 -
991 - mod resume {
992 - use super::super::*;
993 - use std::sync::Mutex;
994 -
995 - /// A store that answers with whatever the test put in it.
996 - #[derive(Default)]
997 - struct Fake {
998 - record: Mutex<Option<ResumeRecord>>,
999 - cleared: Mutex<bool>,
1000 - }
1001 - impl BlobResumeStore for Fake {
1002 - fn load(&self, _hash: &str) -> Result<Option<ResumeRecord>> {
1003 - Ok(self.record.lock().unwrap().clone())
1004 - }
1005 - fn begin(&self, _hash: &str, _session: &ResumeSession) -> Result<()> {
1006 - Ok(())
1007 - }
1008 - fn record_part(
1009 - &self,
1010 - _hash: &str,
1011 - _part: &ResumePart,
1012 - _chunks: &[ResumeChunk],
1013 - ) -> Result<()> {
1014 - Ok(())
1015 - }
1016 - fn clear(&self, _hash: &str) -> Result<()> {
1017 - *self.cleared.lock().unwrap() = true;
1018 - Ok(())
1019 - }
1020 - }
1021 -
1022 - /// A plausible session: 3 parts of 8 bytes over a 24-byte ciphertext,
1023 - /// with the first part done.
1024 - fn fake(age_secs: i64) -> Fake {
1025 - Fake {
1026 - record: Mutex::new(Some(ResumeRecord {
1027 - session: ResumeSession {
1028 - upload_id: "u".into(),
1029 - part_size: 8,
1030 - part_count: 3,
1031 - size_bytes: 24,
1032 - },
1033 - age_secs,
1034 - parts: vec![ResumePart {
1035 - part_number: 1,
1036 - etag: "e".into(),
1037 - }],
1038 - chunks: vec![],
1039 - })),
1040 - cleared: Mutex::new(false),
1041 - }
1042 - }
1043 -
1044 - #[test]
1045 - fn a_fresh_matching_record_is_taken() {
1046 - let store = fake(60);
1047 - assert!(SyncKitClient::load_resume(&store, "h", 24).is_some());
1048 - assert!(!*store.cleared.lock().unwrap());
1049 - }
1050 -
1051 - #[test]
1052 - fn a_session_past_the_reaper_window_is_dropped() {
1053 - // The server aborts abandoned sessions at 24h, so an older record
1054 - // names an upload_id that no longer exists. Resuming into it would
1055 - // cost a doomed transfer before failing.
1056 - let store = fake(RESUME_MAX_AGE_SECS + 1);
1057 - assert!(SyncKitClient::load_resume(&store, "h", 24).is_none());
1058 - assert!(
1059 - *store.cleared.lock().unwrap(),
1060 - "a dead record must be forgotten, not re-read next pass"
1061 - );
1062 - }
1063 -
1064 - #[test]
1065 - fn a_store_failure_is_reported_and_swallowed() {
1066 - // `best_effort` is the whole of the rule that nothing about the
1067 - // resume store may fail an upload: it takes the error, says so, and
1068 - // returns. Both halves matter and neither is a return value, so a
1069 - // body replaced by `()` would behave identically to any caller. The
1070 - // log is where the difference lives.
1071 - let noisy = crate::test_support::events_from(|| {
1072 - best_effort(
1073 - "record_part",
1074 - Err(SyncKitError::Internal("disk full".into())),
1075 - );
1076 - });
1077 - let line = noisy
1078 - .iter()
1079 - .find(|e| {
1080 - e.message
1081 - .as_deref()
1082 - .is_some_and(|m| m.contains("record_part") && m.contains("disk full"))
1083 - })
1084 - .expect("a store failure must name the operation and the cause");
1085 - assert!(
1086 - line.message
1087 - .as_deref()
1088 - .is_some_and(|m| m.contains("will not resume")),
1089 - "the line must say what the failure costs, which is a restart from zero"
1090 - );
1091 -
1092 - let quiet = crate::test_support::events_from(|| {
1093 - best_effort("record_part", Ok(()));
1094 - });
1095 - assert!(
1096 - quiet.is_empty(),
1097 - "a store that worked has nothing to report"
1098 - );
1099 - }
1100 -
1101 - /// Half the server's 24h orphan-reaper window, in seconds, written out
1102 - /// rather than read from [`RESUME_MAX_AGE_SECS`]. The point of the two
1103 - /// tests below is to pin that constant's value as well as the
1104 - /// comparison against it, and taking the number from the code under
1105 - /// test would make them agree with whatever it happened to hold.
1106 - const TWELVE_HOURS: i64 = 43_200;
1107 -
1108 - #[test]
1109 - fn a_record_on_the_twelve_hour_boundary_is_still_usable() {
1110 - // The comparison is `>`, not `>=`: the window is chosen to leave a
1111 - // slow transfer room to finish inside it, and a record that has just
1112 - // reached the boundary still names a session the server holds.
1113 - let store = fake(TWELVE_HOURS);
1114 - assert!(
1115 - SyncKitClient::load_resume(&store, "h", 24).is_some(),
1116 - "a record exactly at the limit is inside the window, not past it"
1117 - );
1118 - assert!(!*store.cleared.lock().unwrap());
1119 - }
1120 -
1121 - #[test]
1122 - fn a_record_one_second_past_twelve_hours_is_dropped() {
1123 - let store = fake(TWELVE_HOURS + 1);
1124 - assert!(SyncKitClient::load_resume(&store, "h", 24).is_none());
1125 - assert!(*store.cleared.lock().unwrap());
1126 - }
1127 -
1128 - /// The line `load_resume` writes when it throws a record away.
1129 - const DISCARD_LINE: &str = "discarding an unusable blob resume record";
1130 -
1131 - #[test]
1132 - fn only_a_faulty_record_is_reported_as_discarded() {
1133 - // The three ways a record is dropped are not one event. A stale
1134 - // session and a plan that does not tile the blob are faults, and an
1135 - // operator wondering why an upload restarted wants to see them. A
1136 - // record with no completed parts is the ordinary case of a run that
1137 - // died before its first part landed; logging that would put a line
1138 - // in front of somebody on every such retry, and it says nothing.
1139 - //
1140 - // The guard that draws that distinction returns nothing and changes
1141 - // nothing, so the log is the only place it is observable at all.
1142 -
1143 - let empty = fake(60);
1144 - empty.record.lock().unwrap().as_mut().unwrap().parts.clear();
1145 - let quiet = crate::test_support::events_from(|| {
1146 - assert!(SyncKitClient::load_resume(&empty, "h", 24).is_none());
1147 - });
1148 - assert!(
1149 - quiet
1150 - .iter()
1151 - .all(|e| e.message.as_deref() != Some(DISCARD_LINE)),
1152 - "a record that simply has nothing to save is not a fault to report"
1153 - );
1154 -
1155 - let stale = fake(TWELVE_HOURS + 1);
1156 - let logged = crate::test_support::events_from(|| {
1157 - assert!(SyncKitClient::load_resume(&stale, "h", 24).is_none());
1158 - });
1159 - let line = logged
1160 - .iter()
1161 - .find(|e| e.message.as_deref() == Some(DISCARD_LINE))
1162 - .expect("a stale session is a fault and must be reported");
1163 - assert_eq!(line.field("stale"), Some("true"));
1164 - assert_eq!(line.field("fits"), Some("true"), "it fits, it is just dead");
1165 -
1166 - let misfit = fake(60);
1167 - let logged = crate::test_support::events_from(|| {
1168 - assert!(SyncKitClient::load_resume(&misfit, "h", 999).is_none());
1169 - });
1170 - let line = logged
1171 - .iter()
1172 - .find(|e| e.message.as_deref() == Some(DISCARD_LINE))
1173 - .expect("a record that cannot describe this upload must be reported");
1174 - assert_eq!(line.field("stale"), Some("false"));
1175 - assert_eq!(line.field("fits"), Some("false"));
1176 - }
1177 -
1178 - #[test]
1179 - fn a_record_for_a_different_length_is_dropped() {
1180 - // Same content hash, different ciphertext length is a contradiction:
1181 - // whatever it describes, it is not this upload.
1182 - let store = fake(60);
1183 - assert!(SyncKitClient::load_resume(&store, "h", 999).is_none());
1184 - assert!(*store.cleared.lock().unwrap());
1185 - }
1186 -
1187 - #[test]
1188 - fn a_plan_that_does_not_tile_the_blob_is_dropped() {
1189 - let store = fake(60);
1190 - store
1191 - .record
1192 - .lock()
1193 - .unwrap()
1194 - .as_mut()
1195 - .unwrap()
1196 - .session
1197 - .part_count = 7;
1198 - assert!(SyncKitClient::load_resume(&store, "h", 24).is_none());
1199 - }
1200 -
1201 - #[test]
1202 - fn a_plan_with_a_zero_part_size_is_dropped() {
1203 - // part_size 0 is the hostile case the `> 0` guard exists for: it is
1204 - // also the divisor of the tiling check below it, so a guard that let
1205 - // it through would divide by zero rather than merely mis-resume.
1206 - let store = fake(60);
1207 - store
1208 - .record
1209 - .lock()
1210 - .unwrap()
1211 - .as_mut()
1212 - .unwrap()
1213 - .session
1214 - .part_size = 0;
1215 - assert!(SyncKitClient::load_resume(&store, "h", 24).is_none());
1216 - assert!(*store.cleared.lock().unwrap());
1217 - }
1218 -
1219 - #[test]
1220 - fn a_plan_with_no_parts_is_dropped_even_where_the_tiling_check_would_agree() {
1221 - // part_count 0 over a 0-byte session: 0.div_ceil(8) == 0, so the
1222 - // tiling check is satisfied and the `part_count > 0` guard is the
1223 - // only thing rejecting it. A record naming a completed part in a
1224 - // zero-part plan describes nothing.
1225 - let store = fake(60);
1226 - {
1227 - let mut held = store.record.lock().unwrap();
1228 - let session = &mut held.as_mut().unwrap().session;
1229 - session.part_count = 0;
1230 - session.size_bytes = 0;
1231 - }
1232 - assert!(SyncKitClient::load_resume(&store, "h", 0).is_none());
1233 - }
1234 -
1235 - #[test]
1236 - fn a_record_with_no_completed_parts_saves_nothing() {
1237 - // Not an error: the upload starts at part 1 either way. Dropping it
1238 - // means the session recorded is the one actually being used.
1239 - let store = fake(60);
1240 - store.record.lock().unwrap().as_mut().unwrap().parts.clear();
1241 - assert!(SyncKitClient::load_resume(&store, "h", 24).is_none());
1242 - }
1243 -
1244 - #[test]
1245 - fn a_store_that_errors_costs_a_restart_and_nothing_else() {
1246 - struct Broken;
1247 - impl BlobResumeStore for Broken {
1248 - fn load(&self, _: &str) -> Result<Option<ResumeRecord>> {
1249 - Err(SyncKitError::Internal("disk gone".into()))
1250 - }
1251 - fn begin(&self, _: &str, _: &ResumeSession) -> Result<()> {
1252 - Ok(())
1253 - }
1254 - fn record_part(&self, _: &str, _: &ResumePart, _: &[ResumeChunk]) -> Result<()> {
1255 - Ok(())
1256 - }
1257 - fn clear(&self, _: &str) -> Result<()> {
1258 - Ok(())
1259 - }
1260 - }
1261 - assert!(SyncKitClient::load_resume(&Broken, "h", 24).is_none());
1262 - }
1263 -
1264 - #[test]
1265 - fn only_a_recurring_failure_gives_up_the_session() {
1266 - assert!(is_resumable_failure(&SyncKitError::Server {
1267 - status: 503,
1268 - message: String::new(),
1269 - retry_after_secs: None,
1270 - }));
1271 - assert!(!is_resumable_failure(&SyncKitError::IntegrityFailed {
1272 - expected: "a".into(),
1273 - actual: "b".into(),
1274 - }));
1275 - assert!(!is_resumable_failure(&SyncKitError::Internal(
1276 - "geometry".into()
1277 - )));
1278 - }
1279 -
1280 - /// The header plus every sealed chunk, which is what the boundary
1281 - /// arithmetic walks.
1282 - fn header_len() -> usize {
1283 - crypto::blob_header_bytes(0).len()
1284 - }
1285 -
1286 - #[test]
1287 - fn a_fresh_upload_starts_at_the_top() {
1288 - assert_eq!(resume_boundary(4096, header_len(), 0), (0, 0));
1289 - }
1290 -
1291 - #[test]
1292 - fn a_boundary_inside_the_first_chunk_reports_its_offset() {
1293 - let h = header_len();
1294 - // 1000 bytes into chunk 0's sealed bytes.
1295 - assert_eq!(resume_boundary(4 << 20, h, h + 1000), (0, 1000));
1296 - }
1297 -
1298 - #[test]
1299 - fn a_boundary_past_a_whole_chunk_lands_in_the_next() {
1300 - let h = header_len();
1301 - let c0 = crypto::sealed_blob_chunk_len(4 << 20, 0);
1302 - assert_eq!(resume_boundary(4 << 20, h, h + c0), (1, 0));
1303 - assert_eq!(resume_boundary(4 << 20, h, h + c0 + 5), (1, 5));
1304 - }
1305 -
1306 - #[test]
1307 - fn a_boundary_past_the_last_chunk_means_nothing_is_left_to_send() {
1308 - let len = 4 << 20;
1309 - let cipher = crypto::blob_encrypted_len(len);
1310 - assert_eq!(
1311 - resume_boundary(len, header_len(), cipher),
1312 - (crypto::blob_chunk_count_for(len), 0)
1313 - );
1314 - }
1315 -
1316 - #[test]
1317 - fn every_boundary_of_a_real_blob_maps_back_to_the_bytes_it_names() {
1318 - // The invariant the resume depends on: skipping to a part boundary
1319 - // and re-emitting from `within` into the boundary chunk reproduces
1320 - // the ciphertext tail exactly. Checked against a real sealed blob.
1321 - let key = [7u8; 32];
1322 - let plaintext: Vec<u8> = (0..(crypto::BLOB_CHUNK_SIZE * 2 + 511))
1323 - .map(|i| i as u8)
1324 - .collect();
1325 - let hash = "a".repeat(64);
1326 - let whole = crypto::encrypt_blob_chunked(&plaintext, &key, &hash).unwrap();
1327 - let h = crypto::blob_header_bytes(plaintext.len()).len();
1328 - let count = crypto::blob_chunk_count_for(plaintext.len());
1329 -
1330 - for skip in [h + 1, h + 700 * 1024, h + 1_400_000, whole.len() - 3] {
1331 - let (index, within) = resume_boundary(plaintext.len(), h, skip);
1332 - assert!(index < count, "skip {skip} fell off the end");
1333 - // Where that chunk starts in the ciphertext.
1334 - let start: usize = h
1335 - + (0..index)
1336 - .map(|i| crypto::sealed_blob_chunk_len(plaintext.len(), i))
1337 - .sum::<usize>();
1338 - assert_eq!(start + within, skip, "boundary {skip} must be exact");
1339 - // And re-sealing it under its own nonce reproduces those bytes.
1340 - let sealed =
1341 - &whole[start..start + crypto::sealed_blob_chunk_len(plaintext.len(), index)];
1342 - let from = index as usize * crypto::BLOB_CHUNK_SIZE;
1343 - let to = (from + crypto::BLOB_CHUNK_SIZE).min(plaintext.len());
1344 - let again = crypto::reseal_blob_chunk(
1345 - &plaintext[from..to],
1346 - &key,
1347 - &hash,
1348 - index,
1349 - count,
1350 - &crypto::blob_chunk_nonce(sealed).unwrap(),
1351 - )
1352 - .unwrap();
1353 - assert_eq!(again, sealed, "chunk {index} must re-seal byte-identically");
1354 - }
1355 - }
1356 - }
1357 -
1358 - #[test]
1359 - fn blob_upload_url_response_deserialization() {
1360 - let json = r#"{"upload_url": "https://s3.example.com/upload", "already_exists": false}"#;
1361 - let resp: BlobUploadUrlResponse = serde_json::from_str(json).unwrap();
1362 - assert_eq!(resp.upload_url, "https://s3.example.com/upload");
1363 - assert!(!resp.already_exists);
1364 -
1365 - let json = r#"{"upload_url": "", "already_exists": true}"#;
1366 - let resp: BlobUploadUrlResponse = serde_json::from_str(json).unwrap();
1367 - assert!(resp.already_exists);
1368 - }
1369 -
1370 - #[test]
1371 - fn blob_upload_url_request_serialization() {
1372 - let req = BlobUploadUrlRequest {
1373 - hash: "sha256-abc123".to_string(),
1374 - size_bytes: 1024,
1375 - };
1376 -
1377 - let json = serde_json::to_string(&req).unwrap();
1378 - let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1379 - assert_eq!(parsed["hash"], "sha256-abc123");
1380 - assert_eq!(parsed["size_bytes"], 1024);
1381 - }
1382 -
1383 - #[test]
1384 - fn blob_content_hash_format_matches_consumer() {
1385 - use sha2::{Digest, Sha256};
1386 - // The integrity check in blob_download compares against this exact form:
1387 - // lowercase hex of SHA-256, the same string consumers store as the blob
1388 - // hash. If this drifts, every verified download would falsely reject.
1389 - let h = hex::encode(Sha256::digest(b"hello blob"));
1390 - assert_eq!(h.len(), 64);
1391 - assert!(
1392 - h.chars()
1393 - .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
1394 - );
1395 - }
1396 -
1397 - #[test]
1398 - fn blob_confirm_request_serialization() {
1399 - let req = BlobConfirmRequest {
1400 - hash: "sha256-def456".to_string(),
1401 - size_bytes: 2048,
1402 - };
1403 -
1404 - let json = serde_json::to_string(&req).unwrap();
1405 - let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1406 - assert_eq!(parsed["hash"], "sha256-def456");
1407 - assert_eq!(parsed["size_bytes"], 2048);
1408 - }
1409 -
1410 - // ── The in-memory and streaming size caps ──
1411 -
1412 - #[test]
1413 - fn the_blob_cap_is_four_gibibytes_exactly() {
1414 - // Pinned as a literal rather than as the same arithmetic the constant
1415 - // uses, because that arithmetic is what can be wrong. The two readings a
1416 - // single wrong operator produces here are 1_077_936_128 and 4_195_328:
1417 - // both look like plausible caps, and either would refuse legitimate
1418 - // media the SDK documents itself as carrying. Nothing else in the suite
1419 - // can see the difference, since no fixture is anywhere near any of the
1420 - // three values.
1421 - assert_eq!(MAX_BLOB_BYTES, 4_294_967_296, "4 GiB");
1422 - }
1423 -
1424 - /// A client holding a key but no session: every blob path gets past the
1425 - /// key check and stops at `require_token`, which is what makes
1426 - /// `NotAuthenticated` mean "the size check let this through".
1427 - fn keyed_but_unauthenticated() -> SyncKitClient {
1428 - let client = SyncKitClient::new(crate::SyncKitConfig {
1429 - server_url: "https://example.invalid".to_string(),
1430 - api_key: "test-api-key".to_string(),
1431 - });
1432 - client.set_master_key_raw([9u8; 32]);
1433 - client
1434 - }
1435 -
1436 - /// A sparse file of `len` bytes: `set_len` allocates nothing, so the
1437 - /// multi-gigabyte sizes the cap is written in terms of cost no disk. The
1438 - /// cap is read off `metadata`, which is all these tests reach.
1439 - fn sparse_file(len: u64) -> std::path::PathBuf {
1440 - use std::sync::atomic::{AtomicU64, Ordering};
1441 - static N: AtomicU64 = AtomicU64::new(0);
1442 - let mut p = std::env::temp_dir();
1443 - p.push(format!(
1444 - "synckit_cap_{}_{}",
1445 - std::process::id(),
1446 - N.fetch_add(1, Ordering::Relaxed)
1447 - ));
1448 - let f = std::fs::File::create(&p).unwrap();
1449 - f.set_len(len).unwrap();
1450 - p
1451 - }
1452 -
1453 - #[tokio::test]
1454 - async fn the_streaming_cap_accepts_a_blob_of_exactly_the_cap_and_refuses_one_byte_more() {
1455 - // Both sides of the bound. `>` differs from `>=` and from `==` only at
1456 - // the cap itself, so a test that only uploads something small cannot
1457 - // see any of them: at every reachable size all three agree.
1458 - let client = keyed_but_unauthenticated();
1459 - let hash = "b".repeat(64);
1460 -
1461 - let at_cap = sparse_file(MAX_BLOB_BYTES as u64);
1462 - let err = client
1463 - .blob_upload_streaming(&hash, &at_cap)
1464 - .await
1465 - .unwrap_err();
1466 - assert!(
1467 - matches!(err, SyncKitError::NotAuthenticated),
1468 - "a blob of exactly {MAX_BLOB_BYTES} bytes is under the cap and must reach the session check, got {err:?}"
1469 - );
1470 -
1471 - let over = sparse_file(MAX_BLOB_BYTES as u64 + 1);
1472 - let err = client
1473 - .blob_upload_streaming(&hash, &over)
1474 - .await
1475 - .unwrap_err();
1476 - match err {
1477 - SyncKitError::InvalidArgument(m) => {
1478 - assert!(m.contains("client cap"), "wrong rejection: {m}");
1479 - }
1480 - other => panic!("one byte over the cap must be refused, got {other:?}"),
1481 - }
1482 -
1483 - // And a small file is not refused by a cap that has been inverted.
Lines truncated
@@ -1,0 +1,530 @@
1 + //! Tests for [`super`].
2 +
3 + use super::*;
4 + use crate::types::*;
5 +
6 + mod resume {
7 + use super::super::*;
8 + use std::sync::Mutex;
9 +
10 + /// A store that answers with whatever the test put in it.
11 + #[derive(Default)]
12 + struct Fake {
13 + record: Mutex<Option<ResumeRecord>>,
14 + cleared: Mutex<bool>,
15 + }
16 + impl BlobResumeStore for Fake {
17 + fn load(&self, _hash: &str) -> Result<Option<ResumeRecord>> {
18 + Ok(self.record.lock().unwrap().clone())
19 + }
20 + fn begin(&self, _hash: &str, _session: &ResumeSession) -> Result<()> {
21 + Ok(())
22 + }
23 + fn record_part(
24 + &self,
25 + _hash: &str,
26 + _part: &ResumePart,
27 + _chunks: &[ResumeChunk],
28 + ) -> Result<()> {
29 + Ok(())
30 + }
31 + fn clear(&self, _hash: &str) -> Result<()> {
32 + *self.cleared.lock().unwrap() = true;
33 + Ok(())
34 + }
35 + }
36 +
37 + /// A plausible session: 3 parts of 8 bytes over a 24-byte ciphertext,
38 + /// with the first part done.
39 + fn fake(age_secs: i64) -> Fake {
40 + Fake {
41 + record: Mutex::new(Some(ResumeRecord {
42 + session: ResumeSession {
43 + upload_id: "u".into(),
44 + part_size: 8,
45 + part_count: 3,
46 + size_bytes: 24,
47 + },
48 + age_secs,
49 + parts: vec![ResumePart {
50 + part_number: 1,
51 + etag: "e".into(),
52 + }],
53 + chunks: vec![],
54 + })),
55 + cleared: Mutex::new(false),
56 + }
57 + }
58 +
59 + #[test]
60 + fn a_fresh_matching_record_is_taken() {
61 + let store = fake(60);
62 + assert!(SyncKitClient::load_resume(&store, "h", 24).is_some());
63 + assert!(!*store.cleared.lock().unwrap());
64 + }
65 +
66 + #[test]
67 + fn a_session_past_the_reaper_window_is_dropped() {
68 + // The server aborts abandoned sessions at 24h, so an older record
69 + // names an upload_id that no longer exists. Resuming into it would
70 + // cost a doomed transfer before failing.
71 + let store = fake(RESUME_MAX_AGE_SECS + 1);
72 + assert!(SyncKitClient::load_resume(&store, "h", 24).is_none());
73 + assert!(
74 + *store.cleared.lock().unwrap(),
75 + "a dead record must be forgotten, not re-read next pass"
76 + );
77 + }
78 +
79 + #[test]
80 + fn a_store_failure_is_reported_and_swallowed() {
81 + // `best_effort` is the whole of the rule that nothing about the
82 + // resume store may fail an upload: it takes the error, says so, and
83 + // returns. Both halves matter and neither is a return value, so a
84 + // body replaced by `()` would behave identically to any caller. The
85 + // log is where the difference lives.
86 + let noisy = crate::test_support::events_from(|| {
87 + best_effort(
88 + "record_part",
89 + Err(SyncKitError::Internal("disk full".into())),
90 + );
91 + });
92 + let line = noisy
93 + .iter()
94 + .find(|e| {
95 + e.message
96 + .as_deref()
97 + .is_some_and(|m| m.contains("record_part") && m.contains("disk full"))
98 + })
99 + .expect("a store failure must name the operation and the cause");
100 + assert!(
101 + line.message
102 + .as_deref()
103 + .is_some_and(|m| m.contains("will not resume")),
104 + "the line must say what the failure costs, which is a restart from zero"
105 + );
106 +
107 + let quiet = crate::test_support::events_from(|| {
108 + best_effort("record_part", Ok(()));
109 + });
110 + assert!(
111 + quiet.is_empty(),
112 + "a store that worked has nothing to report"
113 + );
114 + }
115 +
116 + /// Half the server's 24h orphan-reaper window, in seconds, written out
117 + /// rather than read from [`RESUME_MAX_AGE_SECS`]. The point of the two
118 + /// tests below is to pin that constant's value as well as the
119 + /// comparison against it, and taking the number from the code under
120 + /// test would make them agree with whatever it happened to hold.
121 + const TWELVE_HOURS: i64 = 43_200;
122 +
123 + #[test]
124 + fn a_record_on_the_twelve_hour_boundary_is_still_usable() {
125 + // The comparison is `>`, not `>=`: the window is chosen to leave a
126 + // slow transfer room to finish inside it, and a record that has just
127 + // reached the boundary still names a session the server holds.
128 + let store = fake(TWELVE_HOURS);
129 + assert!(
130 + SyncKitClient::load_resume(&store, "h", 24).is_some(),
131 + "a record exactly at the limit is inside the window, not past it"
132 + );
133 + assert!(!*store.cleared.lock().unwrap());
134 + }
135 +
136 + #[test]
137 + fn a_record_one_second_past_twelve_hours_is_dropped() {
138 + let store = fake(TWELVE_HOURS + 1);
139 + assert!(SyncKitClient::load_resume(&store, "h", 24).is_none());
140 + assert!(*store.cleared.lock().unwrap());
141 + }
142 +
143 + /// The line `load_resume` writes when it throws a record away.
144 + const DISCARD_LINE: &str = "discarding an unusable blob resume record";
145 +
146 + #[test]
147 + fn only_a_faulty_record_is_reported_as_discarded() {
148 + // The three ways a record is dropped are not one event. A stale
149 + // session and a plan that does not tile the blob are faults, and an
150 + // operator wondering why an upload restarted wants to see them. A
151 + // record with no completed parts is the ordinary case of a run that
152 + // died before its first part landed; logging that would put a line
153 + // in front of somebody on every such retry, and it says nothing.
154 + //
155 + // The guard that draws that distinction returns nothing and changes
156 + // nothing, so the log is the only place it is observable at all.
157 +
158 + let empty = fake(60);
159 + empty.record.lock().unwrap().as_mut().unwrap().parts.clear();
160 + let quiet = crate::test_support::events_from(|| {
161 + assert!(SyncKitClient::load_resume(&empty, "h", 24).is_none());
162 + });
163 + assert!(
164 + quiet
165 + .iter()
166 + .all(|e| e.message.as_deref() != Some(DISCARD_LINE)),
167 + "a record that simply has nothing to save is not a fault to report"
168 + );
169 +
170 + let stale = fake(TWELVE_HOURS + 1);
171 + let logged = crate::test_support::events_from(|| {
172 + assert!(SyncKitClient::load_resume(&stale, "h", 24).is_none());
173 + });
174 + let line = logged
175 + .iter()
176 + .find(|e| e.message.as_deref() == Some(DISCARD_LINE))
177 + .expect("a stale session is a fault and must be reported");
178 + assert_eq!(line.field("stale"), Some("true"));
179 + assert_eq!(line.field("fits"), Some("true"), "it fits, it is just dead");
180 +
181 + let misfit = fake(60);
182 + let logged = crate::test_support::events_from(|| {
183 + assert!(SyncKitClient::load_resume(&misfit, "h", 999).is_none());
184 + });
185 + let line = logged
186 + .iter()
187 + .find(|e| e.message.as_deref() == Some(DISCARD_LINE))
188 + .expect("a record that cannot describe this upload must be reported");
189 + assert_eq!(line.field("stale"), Some("false"));
190 + assert_eq!(line.field("fits"), Some("false"));
191 + }
192 +
193 + #[test]
194 + fn a_record_for_a_different_length_is_dropped() {
195 + // Same content hash, different ciphertext length is a contradiction:
196 + // whatever it describes, it is not this upload.
197 + let store = fake(60);
198 + assert!(SyncKitClient::load_resume(&store, "h", 999).is_none());
199 + assert!(*store.cleared.lock().unwrap());
200 + }
201 +
202 + #[test]
203 + fn a_plan_that_does_not_tile_the_blob_is_dropped() {
204 + let store = fake(60);
205 + store
206 + .record
207 + .lock()
208 + .unwrap()
209 + .as_mut()
210 + .unwrap()
211 + .session
212 + .part_count = 7;
213 + assert!(SyncKitClient::load_resume(&store, "h", 24).is_none());
214 + }
215 +
216 + #[test]
217 + fn a_plan_with_a_zero_part_size_is_dropped() {
218 + // part_size 0 is the hostile case the `> 0` guard exists for: it is
219 + // also the divisor of the tiling check below it, so a guard that let
220 + // it through would divide by zero rather than merely mis-resume.
221 + let store = fake(60);
222 + store
223 + .record
224 + .lock()
225 + .unwrap()
226 + .as_mut()
227 + .unwrap()
228 + .session
229 + .part_size = 0;
230 + assert!(SyncKitClient::load_resume(&store, "h", 24).is_none());
231 + assert!(*store.cleared.lock().unwrap());
232 + }
233 +
234 + #[test]
235 + fn a_plan_with_no_parts_is_dropped_even_where_the_tiling_check_would_agree() {
236 + // part_count 0 over a 0-byte session: 0.div_ceil(8) == 0, so the
237 + // tiling check is satisfied and the `part_count > 0` guard is the
238 + // only thing rejecting it. A record naming a completed part in a
239 + // zero-part plan describes nothing.
240 + let store = fake(60);
241 + {
242 + let mut held = store.record.lock().unwrap();
243 + let session = &mut held.as_mut().unwrap().session;
244 + session.part_count = 0;
245 + session.size_bytes = 0;
246 + }
247 + assert!(SyncKitClient::load_resume(&store, "h", 0).is_none());
248 + }
249 +
250 + #[test]
251 + fn a_record_with_no_completed_parts_saves_nothing() {
252 + // Not an error: the upload starts at part 1 either way. Dropping it
253 + // means the session recorded is the one actually being used.
254 + let store = fake(60);
255 + store.record.lock().unwrap().as_mut().unwrap().parts.clear();
256 + assert!(SyncKitClient::load_resume(&store, "h", 24).is_none());
257 + }
258 +
259 + #[test]
260 + fn a_store_that_errors_costs_a_restart_and_nothing_else() {
261 + struct Broken;
262 + impl BlobResumeStore for Broken {
263 + fn load(&self, _: &str) -> Result<Option<ResumeRecord>> {
264 + Err(SyncKitError::Internal("disk gone".into()))
265 + }
266 + fn begin(&self, _: &str, _: &ResumeSession) -> Result<()> {
267 + Ok(())
268 + }
269 + fn record_part(&self, _: &str, _: &ResumePart, _: &[ResumeChunk]) -> Result<()> {
270 + Ok(())
271 + }
272 + fn clear(&self, _: &str) -> Result<()> {
273 + Ok(())
274 + }
275 + }
276 + assert!(SyncKitClient::load_resume(&Broken, "h", 24).is_none());
277 + }
278 +
279 + #[test]
280 + fn only_a_recurring_failure_gives_up_the_session() {
281 + assert!(is_resumable_failure(&SyncKitError::Server {
282 + status: 503,
283 + message: String::new(),
284 + retry_after_secs: None,
285 + }));
286 + assert!(!is_resumable_failure(&SyncKitError::IntegrityFailed {
287 + expected: "a".into(),
288 + actual: "b".into(),
289 + }));
290 + assert!(!is_resumable_failure(&SyncKitError::Internal(
291 + "geometry".into()
292 + )));
293 + }
294 +
295 + /// The header plus every sealed chunk, which is what the boundary
296 + /// arithmetic walks.
297 + fn header_len() -> usize {
298 + crypto::blob_header_bytes(0).len()
299 + }
300 +
301 + #[test]
302 + fn a_fresh_upload_starts_at_the_top() {
303 + assert_eq!(resume_boundary(4096, header_len(), 0), (0, 0));
304 + }
305 +
306 + #[test]
307 + fn a_boundary_inside_the_first_chunk_reports_its_offset() {
308 + let h = header_len();
309 + // 1000 bytes into chunk 0's sealed bytes.
310 + assert_eq!(resume_boundary(4 << 20, h, h + 1000), (0, 1000));
311 + }
312 +
313 + #[test]
314 + fn a_boundary_past_a_whole_chunk_lands_in_the_next() {
315 + let h = header_len();
316 + let c0 = crypto::sealed_blob_chunk_len(4 << 20, 0);
317 + assert_eq!(resume_boundary(4 << 20, h, h + c0), (1, 0));
318 + assert_eq!(resume_boundary(4 << 20, h, h + c0 + 5), (1, 5));
319 + }
320 +
321 + #[test]
322 + fn a_boundary_past_the_last_chunk_means_nothing_is_left_to_send() {
323 + let len = 4 << 20;
324 + let cipher = crypto::blob_encrypted_len(len);
325 + assert_eq!(
326 + resume_boundary(len, header_len(), cipher),
327 + (crypto::blob_chunk_count_for(len), 0)
328 + );
329 + }
330 +
331 + #[test]
332 + fn every_boundary_of_a_real_blob_maps_back_to_the_bytes_it_names() {
333 + // The invariant the resume depends on: skipping to a part boundary
334 + // and re-emitting from `within` into the boundary chunk reproduces
335 + // the ciphertext tail exactly. Checked against a real sealed blob.
336 + let key = [7u8; 32];
337 + let plaintext: Vec<u8> = (0..(crypto::BLOB_CHUNK_SIZE * 2 + 511))
338 + .map(|i| i as u8)
339 + .collect();
340 + let hash = "a".repeat(64);
341 + let whole = crypto::encrypt_blob_chunked(&plaintext, &key, &hash).unwrap();
342 + let h = crypto::blob_header_bytes(plaintext.len()).len();
343 + let count = crypto::blob_chunk_count_for(plaintext.len());
344 +
345 + for skip in [h + 1, h + 700 * 1024, h + 1_400_000, whole.len() - 3] {
346 + let (index, within) = resume_boundary(plaintext.len(), h, skip);
347 + assert!(index < count, "skip {skip} fell off the end");
348 + // Where that chunk starts in the ciphertext.
349 + let start: usize = h
350 + + (0..index)
351 + .map(|i| crypto::sealed_blob_chunk_len(plaintext.len(), i))
352 + .sum::<usize>();
353 + assert_eq!(start + within, skip, "boundary {skip} must be exact");
354 + // And re-sealing it under its own nonce reproduces those bytes.
355 + let sealed =
356 + &whole[start..start + crypto::sealed_blob_chunk_len(plaintext.len(), index)];
357 + let from = index as usize * crypto::BLOB_CHUNK_SIZE;
358 + let to = (from + crypto::BLOB_CHUNK_SIZE).min(plaintext.len());
359 + let again = crypto::reseal_blob_chunk(
360 + &plaintext[from..to],
361 + &key,
362 + &hash,
363 + index,
364 + count,
365 + &crypto::blob_chunk_nonce(sealed).unwrap(),
366 + )
367 + .unwrap();
368 + assert_eq!(again, sealed, "chunk {index} must re-seal byte-identically");
369 + }
370 + }
371 + }
372 +
373 + #[test]
374 + fn blob_upload_url_response_deserialization() {
375 + let json = r#"{"upload_url": "https://s3.example.com/upload", "already_exists": false}"#;
376 + let resp: BlobUploadUrlResponse = serde_json::from_str(json).unwrap();
377 + assert_eq!(resp.upload_url, "https://s3.example.com/upload");
378 + assert!(!resp.already_exists);
379 +
380 + let json = r#"{"upload_url": "", "already_exists": true}"#;
381 + let resp: BlobUploadUrlResponse = serde_json::from_str(json).unwrap();
382 + assert!(resp.already_exists);
383 + }
384 +
385 + #[test]
386 + fn blob_upload_url_request_serialization() {
387 + let req = BlobUploadUrlRequest {
388 + hash: "sha256-abc123".to_string(),
389 + size_bytes: 1024,
390 + };
391 +
392 + let json = serde_json::to_string(&req).unwrap();
393 + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
394 + assert_eq!(parsed["hash"], "sha256-abc123");
395 + assert_eq!(parsed["size_bytes"], 1024);
396 + }
397 +
398 + #[test]
399 + fn blob_content_hash_format_matches_consumer() {
400 + use sha2::{Digest, Sha256};
401 + // The integrity check in blob_download compares against this exact form:
402 + // lowercase hex of SHA-256, the same string consumers store as the blob
403 + // hash. If this drifts, every verified download would falsely reject.
404 + let h = hex::encode(Sha256::digest(b"hello blob"));
405 + assert_eq!(h.len(), 64);
406 + assert!(
407 + h.chars()
408 + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
409 + );
410 + }
411 +
412 + #[test]
413 + fn blob_confirm_request_serialization() {
414 + let req = BlobConfirmRequest {
415 + hash: "sha256-def456".to_string(),
416 + size_bytes: 2048,
417 + };
418 +
419 + let json = serde_json::to_string(&req).unwrap();
420 + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
421 + assert_eq!(parsed["hash"], "sha256-def456");
422 + assert_eq!(parsed["size_bytes"], 2048);
423 + }
424 +
425 + // ── The in-memory and streaming size caps ──
426 +
427 + #[test]
428 + fn the_blob_cap_is_four_gibibytes_exactly() {
429 + // Pinned as a literal rather than as the same arithmetic the constant
430 + // uses, because that arithmetic is what can be wrong. The two readings a
431 + // single wrong operator produces here are 1_077_936_128 and 4_195_328:
432 + // both look like plausible caps, and either would refuse legitimate
433 + // media the SDK documents itself as carrying. Nothing else in the suite
434 + // can see the difference, since no fixture is anywhere near any of the
435 + // three values.
436 + assert_eq!(MAX_BLOB_BYTES, 4_294_967_296, "4 GiB");
437 + }
438 +
439 + /// A client holding a key but no session: every blob path gets past the
440 + /// key check and stops at `require_token`, which is what makes
441 + /// `NotAuthenticated` mean "the size check let this through".
442 + fn keyed_but_unauthenticated() -> SyncKitClient {
443 + let client = SyncKitClient::new(crate::SyncKitConfig {
444 + server_url: "https://example.invalid".to_string(),
445 + api_key: "test-api-key".to_string(),
446 + });
447 + client.set_master_key_raw([9u8; 32]);
448 + client
449 + }
450 +
451 + /// A sparse file of `len` bytes: `set_len` allocates nothing, so the
452 + /// multi-gigabyte sizes the cap is written in terms of cost no disk. The
453 + /// cap is read off `metadata`, which is all these tests reach.
454 + fn sparse_file(len: u64) -> std::path::PathBuf {
455 + use std::sync::atomic::{AtomicU64, Ordering};
456 + static N: AtomicU64 = AtomicU64::new(0);
457 + let mut p = std::env::temp_dir();
458 + p.push(format!(
459 + "synckit_cap_{}_{}",
460 + std::process::id(),
461 + N.fetch_add(1, Ordering::Relaxed)
462 + ));
463 + let f = std::fs::File::create(&p).unwrap();
464 + f.set_len(len).unwrap();
465 + p
466 + }
467 +
468 + #[tokio::test]
469 + async fn the_streaming_cap_accepts_a_blob_of_exactly_the_cap_and_refuses_one_byte_more() {
470 + // Both sides of the bound. `>` differs from `>=` and from `==` only at
471 + // the cap itself, so a test that only uploads something small cannot
472 + // see any of them: at every reachable size all three agree.
473 + let client = keyed_but_unauthenticated();
474 + let hash = "b".repeat(64);
475 +
476 + let at_cap = sparse_file(MAX_BLOB_BYTES as u64);
477 + let err = client
478 + .blob_upload_streaming(&hash, &at_cap)
479 + .await
480 + .unwrap_err();
481 + assert!(
482 + matches!(err, SyncKitError::NotAuthenticated),
483 + "a blob of exactly {MAX_BLOB_BYTES} bytes is under the cap and must reach the session check, got {err:?}"
484 + );
485 +
486 + let over = sparse_file(MAX_BLOB_BYTES as u64 + 1);
487 + let err = client
488 + .blob_upload_streaming(&hash, &over)
489 + .await
490 + .unwrap_err();
491 + match err {
492 + SyncKitError::InvalidArgument(m) => {
493 + assert!(m.contains("client cap"), "wrong rejection: {m}");
494 + }
495 + other => panic!("one byte over the cap must be refused, got {other:?}"),
496 + }
497 +
498 + // And a small file is not refused by a cap that has been inverted.
499 + let small = sparse_file(1_000);
500 + let err = client
Lines truncated