max / synckit
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
14 files changed,
+1829 insertions,
-2 deletions
| @@ -115,6 +115,11 @@ | |||
| 115 | 115 | path = "tests/integration/main.rs" | |
| 116 | 116 | ||
| 117 | 117 | [dev-dependencies] | |
| 118 | + | # `test-util` for the paused clock. The retry loop's backoff guard gates only a | |
| 119 | + | # `tokio::time::sleep`, so nothing but elapsed time can observe it, and freezing | |
| 120 | + | # the clock is what lets a test assert the delays without waiting seven real | |
| 121 | + | # seconds. Resolver v3 keeps dev-dependency features out of a consumer build. | |
| 122 | + | tokio = { version = "1", features = ["test-util"] } | |
| 118 | 123 | # The contracts in `types::Hlc`, `conflict`, and `crypto` are universally | |
| 119 | 124 | # quantified (an order is an order for every pair, a round-trip round-trips for | |
| 120 | 125 | # every input), while their tests were all examples. `proptest-regressions/` is |
| @@ -1144,6 +1144,13 @@ | |||
| 1144 | 1144 | ]; | |
| 1145 | 1145 | ||
| 1146 | 1146 | let (clean, conflicts) = detect_conflicts(remote, &local, DeviceId::new(our_device)); | |
| 1147 | + | // The negative side of `is_empty`: every other assertion in this file is | |
| 1148 | + | // `assert!(clean.is_empty())`, which a constant-`true` `is_empty` also | |
| 1149 | + | // satisfies. Two clean changes must report non-empty. | |
| 1150 | + | assert!( | |
| 1151 | + | !clean.is_empty(), | |
| 1152 | + | "two uncontested rows must not report an empty CleanChanges" | |
| 1153 | + | ); | |
| 1147 | 1154 | assert_eq!(clean.len(), 2); | |
| 1148 | 1155 | assert_eq!(conflicts.len(), 1); | |
| 1149 | 1156 | assert_eq!(conflicts[0].remote.entry.row_id, "r1"); | |
| @@ -2092,6 +2099,91 @@ | |||
| 2092 | 2099 | )); | |
| 2093 | 2100 | } | |
| 2094 | 2101 | ||
| 2102 | + | /// The drift window is a stated contract (5 minutes), not an arbitrary | |
| 2103 | + | /// number: it is the honest inter-device skew SyncKit promises to tolerate, | |
| 2104 | + | /// and every other test in the tree refers to it symbolically, so an | |
| 2105 | + | /// arithmetic slip in `5 * 60 * 1000` would change no other outcome. | |
| 2106 | + | #[test] | |
| 2107 | + | fn max_hlc_drift_is_five_minutes() { | |
| 2108 | + | assert_eq!(MAX_HLC_DRIFT_MS, 300_000); | |
| 2109 | + | } | |
| 2110 | + | ||
| 2111 | + | /// The poisoning threshold is exclusive: an entry sitting exactly on | |
| 2112 | + | /// `now + MAX_HLC_DRIFT_MS` is still honest, and only the next millisecond | |
| 2113 | + | /// is poisoned. Both sides of the boundary, because `>` and `>=` differ | |
| 2114 | + | /// only at equality. | |
| 2115 | + | #[test] | |
| 2116 | + | fn poisoning_threshold_is_exclusive_at_the_drift_boundary() { | |
| 2117 | + | let now = Utc::now(); | |
| 2118 | + | let now_ms = now.timestamp_millis(); | |
| 2119 | + | ||
| 2120 | + | let at_boundary = Hlc { | |
| 2121 | + | wall_ms: now_ms + MAX_HLC_DRIFT_MS, | |
| 2122 | + | counter: 0, | |
| 2123 | + | node: remote_node(), | |
| 2124 | + | }; | |
| 2125 | + | assert!( | |
| 2126 | + | !is_clock_poisoned(&at_boundary, now), | |
| 2127 | + | "an entry exactly at the drift limit is within tolerated skew" | |
| 2128 | + | ); | |
| 2129 | + | ||
| 2130 | + | let one_past = Hlc { | |
| 2131 | + | wall_ms: now_ms + MAX_HLC_DRIFT_MS + 1, | |
| 2132 | + | counter: 0, | |
| 2133 | + | node: remote_node(), | |
| 2134 | + | }; | |
| 2135 | + | assert!( | |
| 2136 | + | is_clock_poisoned(&one_past, now), | |
| 2137 | + | "one millisecond past the drift limit is poisoned" | |
| 2138 | + | ); | |
| 2139 | + | ||
| 2140 | + | let one_before = Hlc { | |
| 2141 | + | wall_ms: now_ms + MAX_HLC_DRIFT_MS - 1, | |
| 2142 | + | counter: 0, | |
| 2143 | + | node: remote_node(), | |
| 2144 | + | }; | |
| 2145 | + | assert!(!is_clock_poisoned(&one_before, now)); | |
| 2146 | + | } | |
| 2147 | + | ||
| 2148 | + | /// The boundary as `resolve_lww_at` sees it: a remote sitting exactly on the | |
| 2149 | + | /// limit is honest, so it wins on raw HLC order; one millisecond further out | |
| 2150 | + | /// loses to the older local. Same two inputs, opposite resolutions, so the | |
| 2151 | + | /// threshold cannot be shifted by a millisecond without this failing. | |
| 2152 | + | #[test] | |
| 2153 | + | fn lww_keeps_a_remote_at_the_drift_boundary_and_rejects_the_next_ms() { | |
| 2154 | + | let now = Utc::now(); | |
| 2155 | + | let now_ms = now.timestamp_millis(); | |
| 2156 | + | ||
| 2157 | + | let mut local = make_entry("tasks", "r1", ChangeOp::Update, now); | |
| 2158 | + | local.hlc = Hlc { | |
| 2159 | + | wall_ms: now_ms, | |
| 2160 | + | counter: 0, | |
| 2161 | + | node: local_node(), | |
| 2162 | + | }; | |
| 2163 | + | ||
| 2164 | + | let mut at_boundary = make_pulled("tasks", "r1", ChangeOp::Update, now, Uuid::new_v4(), 1); | |
| 2165 | + | at_boundary.entry.hlc = Hlc { | |
| 2166 | + | wall_ms: now_ms + MAX_HLC_DRIFT_MS, | |
| 2167 | + | counter: 0, | |
| 2168 | + | node: remote_node(), | |
| 2169 | + | }; | |
| 2170 | + | assert!(matches!( | |
| 2171 | + | resolve_lww_at(&local, &at_boundary, now), | |
| 2172 | + | Resolution::KeepRemote | |
| 2173 | + | )); | |
| 2174 | + | ||
| 2175 | + | let mut one_past = make_pulled("tasks", "r1", ChangeOp::Update, now, Uuid::new_v4(), 1); | |
| 2176 | + | one_past.entry.hlc = Hlc { | |
| 2177 | + | wall_ms: now_ms + MAX_HLC_DRIFT_MS + 1, | |
| 2178 | + | counter: 0, | |
| 2179 | + | node: remote_node(), | |
| 2180 | + | }; | |
| 2181 | + | assert!(matches!( | |
| 2182 | + | resolve_lww_at(&local, &one_past, now), | |
| 2183 | + | Resolution::KeepLocal | |
| 2184 | + | )); | |
| 2185 | + | } | |
| 2186 | + | ||
| 2095 | 2187 | #[test] | |
| 2096 | 2188 | fn canonical_payload_sorts_map_keys() { | |
| 2097 | 2189 | // Pins the exact-HLC tiebreak's convergence invariant: serde_json must |
| @@ -2237,4 +2237,83 @@ | |||
| 2237 | 2237 | assert!(encrypt_data_aad(b"x", &gck, &ctx).is_err()); | |
| 2238 | 2238 | } | |
| 2239 | 2239 | } | |
| 2240 | + | ||
| 2241 | + | /// The `total_len > encrypted.len()` bound is a strict inequality, and the | |
| 2242 | + | /// equality case has to reach the chunk stream. A header claiming exactly | |
| 2243 | + | /// the ciphertext length is not an over-claim on the allocation bound (it | |
| 2244 | + | /// allocates no more than the input already occupies), so it must be | |
| 2245 | + | /// rejected further down, where the sealed-chunk arithmetic finds the | |
| 2246 | + | /// stream too short. Relaxing the bound to `>=` would short-circuit here | |
| 2247 | + | /// and report the wrong reason, which is what this pins. | |
| 2248 | + | #[test] | |
| 2249 | + | fn v3_total_len_equal_to_input_is_rejected_by_the_chunk_stream_not_the_bound() { | |
| 2250 | + | let key = generate_master_key(); | |
| 2251 | + | let plaintext = vec![9u8; 100]; | |
| 2252 | + | let mut sealed = encrypt_blob_chunked(&plaintext, &key, "hash").unwrap(); | |
| 2253 | + | // 100 plaintext + tag(4) + header(13) + nonce/tag(40) = 157. | |
| 2254 | + | assert_eq!(sealed.len(), 157, "v3 layout for a single 100-byte chunk"); | |
| 2255 | + | ||
| 2256 | + | // total_len lives at body[5..13], i.e. absolute [9..17]. | |
| 2257 | + | let equal = sealed.len() as u64; | |
| 2258 | + | sealed[9..17].copy_from_slice(&equal.to_le_bytes()); | |
| 2259 | + | ||
| 2260 | + | let err = decrypt_blob_chunked(&sealed, &key, "hash") | |
| 2261 | + | .expect_err("a 157-byte plaintext claim cannot be satisfied by 140 stream bytes"); | |
| 2262 | + | let SyncKitError::Crypto(msg) = &err else { | |
| 2263 | + | panic!("expected a crypto error, got {err:?}"); | |
| 2264 | + | }; | |
| 2265 | + | assert!( | |
| 2266 | + | msg.contains("truncated mid-chunk"), | |
| 2267 | + | "equality must fall through the length bound and fail on the stream: {msg}" | |
| 2268 | + | ); | |
| 2269 | + | assert!( | |
| 2270 | + | !msg.contains("exceeds encrypted input"), | |
| 2271 | + | "total_len == encrypted.len() is not an over-claim: {msg}" | |
| 2272 | + | ); | |
| 2273 | + | ||
| 2274 | + | // One byte past the bound is the over-claim, and must be caught here. | |
| 2275 | + | let over = sealed.len() as u64 + 1; | |
| 2276 | + | sealed[9..17].copy_from_slice(&over.to_le_bytes()); | |
| 2277 | + | let err = decrypt_blob_chunked(&sealed, &key, "hash") | |
| 2278 | + | .expect_err("a claim larger than the input is rejected"); | |
| 2279 | + | let SyncKitError::Crypto(msg) = &err else { | |
| 2280 | + | panic!("expected a crypto error, got {err:?}"); | |
| 2281 | + | }; | |
| 2282 | + | assert!( | |
| 2283 | + | msg.contains("exceeds encrypted input"), | |
| 2284 | + | "one byte over the input length is the allocation bound: {msg}" | |
| 2285 | + | ); | |
| 2286 | + | } | |
| 2287 | + | ||
| 2288 | + | /// v2 wire overhead is a sum of the tag and the AEAD overhead, pinned to | |
| 2289 | + | /// the number rather than recomputed from the same expression: an assertion | |
| 2290 | + | /// written as `WIRE_V2_TAG_BYTES.len() + ENCRYPTION_OVERHEAD` would hold | |
| 2291 | + | /// however that expression was mutated. | |
| 2292 | + | #[test] | |
| 2293 | + | fn v2_wire_overhead_is_forty_four_bytes() { | |
| 2294 | + | assert_eq!(ENCRYPTION_OVERHEAD_V2, 44); | |
| 2295 | + | // And the constant describes what the encoder actually emits. | |
| 2296 | + | let key = generate_master_key(); | |
| 2297 | + | let ctx = AeadContext::blob("abc"); | |
| 2298 | + | let wire = encrypt_bytes_aad(&[3u8; 70], &key, &ctx).unwrap(); | |
| 2299 | + | assert_eq!(wire.len(), 70 + 44); | |
| 2300 | + | } | |
| 2301 | + | ||
| 2302 | + | /// The Argon2 memory floor is 8192 KiB exactly. Both sides of the bound are | |
| 2303 | + | /// asserted: 8191 is a downgrade and must be refused, 8192 must derive. | |
| 2304 | + | /// The pre-existing test used 8 KiB, which any plausible floor rejects. | |
| 2305 | + | #[test] | |
| 2306 | + | fn argon2_memory_floor_admits_8192_kib_and_refuses_8191() { | |
| 2307 | + | let salt = [7u8; 32]; | |
| 2308 | + | let err = derive_wrapping_key_with_params("pw", &salt, 8191, 1, 1) | |
| 2309 | + | .expect_err("one KiB under the floor is a KDF downgrade"); | |
| 2310 | + | assert!( | |
| 2311 | + | matches!(err, SyncKitError::InvalidEnvelope(_)), | |
| 2312 | + | "out-of-range params are an envelope error, got {err:?}" | |
| 2313 | + | ); | |
| 2314 | + | assert!( | |
| 2315 | + | derive_wrapping_key_with_params("pw", &salt, 8192, 1, 1).is_ok(), | |
| 2316 | + | "the floor itself is inside the accepted range" | |
| 2317 | + | ); | |
| 2318 | + | } | |
| 2240 | 2319 | } |
| @@ -1359,6 +1359,68 @@ | |||
| 1359 | 1359 | ); | |
| 1360 | 1360 | } | |
| 1361 | 1361 | ||
| 1362 | + | /// The counter carried forward has to come from the clock that actually | |
| 1363 | + | /// owns the winning wall component. Here the local clock wins the wall | |
| 1364 | + | /// (500 > 200 and > now_ms), so the remote's much larger counter at its own | |
| 1365 | + | /// older wall is stale and must be discarded. Merging the two counters | |
| 1366 | + | /// instead (taking the max whenever either wall matches) would inflate the | |
| 1367 | + | /// counter to 41 and burn 37 tiebreak slots that no event ever used. | |
| 1368 | + | #[test] | |
| 1369 | + | fn hlc_observe_discards_a_stale_remote_counter() { | |
| 1370 | + | let me = DeviceId::new(Uuid::from_u128(1)); | |
| 1371 | + | let them = DeviceId::new(Uuid::from_u128(2)); | |
| 1372 | + | let local = Hlc { | |
| 1373 | + | wall_ms: 500, | |
| 1374 | + | counter: 3, | |
| 1375 | + | node: me, | |
| 1376 | + | }; | |
| 1377 | + | let remote = Hlc { | |
| 1378 | + | wall_ms: 200, | |
| 1379 | + | counter: 40, | |
| 1380 | + | node: them, | |
| 1381 | + | }; | |
| 1382 | + | let merged = Hlc::observe(local, remote, 100, me); | |
| 1383 | + | assert_eq!( | |
| 1384 | + | merged, | |
| 1385 | + | Hlc { | |
| 1386 | + | wall_ms: 500, | |
| 1387 | + | counter: 4, | |
| 1388 | + | node: me | |
| 1389 | + | }, | |
| 1390 | + | "the remote counter belongs to wall 200 and must not carry to 500" | |
| 1391 | + | ); | |
| 1392 | + | } | |
| 1393 | + | ||
| 1394 | + | /// The mirror image: the remote wins the wall component (800), so our own | |
| 1395 | + | /// counter at wall 100 is the stale one. The result must continue the | |
| 1396 | + | /// remote's counter, not ours, or the merged clock claims 51 events at a | |
| 1397 | + | /// wall component where only 8 exist. | |
| 1398 | + | #[test] | |
| 1399 | + | fn hlc_observe_discards_a_stale_local_counter() { | |
| 1400 | + | let me = DeviceId::new(Uuid::from_u128(1)); | |
| 1401 | + | let them = DeviceId::new(Uuid::from_u128(2)); | |
| 1402 | + | let local = Hlc { | |
| 1403 | + | wall_ms: 100, | |
| 1404 | + | counter: 50, | |
| 1405 | + | node: me, | |
| 1406 | + | }; | |
| 1407 | + | let remote = Hlc { | |
| 1408 | + | wall_ms: 800, | |
| 1409 | + | counter: 7, | |
| 1410 | + | node: them, | |
| 1411 | + | }; | |
| 1412 | + | let merged = Hlc::observe(local, remote, 200, me); | |
| 1413 | + | assert_eq!( | |
| 1414 | + | merged, | |
| 1415 | + | Hlc { | |
| 1416 | + | wall_ms: 800, | |
| 1417 | + | counter: 8, | |
| 1418 | + | node: me | |
| 1419 | + | }, | |
| 1420 | + | "our counter belongs to wall 100 and must not carry to 800" | |
| 1421 | + | ); | |
| 1422 | + | } | |
| 1423 | + | ||
| 1362 | 1424 | #[test] | |
| 1363 | 1425 | fn hlc_counter_overflow_rolls_into_wall_not_backwards() { | |
| 1364 | 1426 | let me = DeviceId::new(Uuid::from_u128(1)); |
| @@ -971,6 +971,7 @@ | |||
| 971 | 971 | ||
| 972 | 972 | #[cfg(test)] | |
| 973 | 973 | mod tests { | |
| 974 | + | use super::*; | |
| 974 | 975 | use crate::types::*; | |
| 975 | 976 | ||
| 976 | 977 | mod resume { | |
| @@ -1069,6 +1070,40 @@ | |||
| 1069 | 1070 | assert!(SyncKitClient::load_resume(&store, "h", 24).is_none()); | |
| 1070 | 1071 | } | |
| 1071 | 1072 | ||
| 1073 | + | #[test] | |
| 1074 | + | fn a_plan_with_a_zero_part_size_is_dropped() { | |
| 1075 | + | // part_size 0 is the hostile case the `> 0` guard exists for: it is | |
| 1076 | + | // also the divisor of the tiling check below it, so a guard that let | |
| 1077 | + | // it through would divide by zero rather than merely mis-resume. | |
| 1078 | + | let store = fake(60); | |
| 1079 | + | store | |
| 1080 | + | .record | |
| 1081 | + | .lock() | |
| 1082 | + | .unwrap() | |
| 1083 | + | .as_mut() | |
| 1084 | + | .unwrap() | |
| 1085 | + | .session | |
| 1086 | + | .part_size = 0; | |
| 1087 | + | assert!(SyncKitClient::load_resume(&store, "h", 24).is_none()); | |
| 1088 | + | assert!(*store.cleared.lock().unwrap()); | |
| 1089 | + | } | |
| 1090 | + | ||
| 1091 | + | #[test] | |
| 1092 | + | fn a_plan_with_no_parts_is_dropped_even_where_the_tiling_check_would_agree() { | |
| 1093 | + | // part_count 0 over a 0-byte session: 0.div_ceil(8) == 0, so the | |
| 1094 | + | // tiling check is satisfied and the `part_count > 0` guard is the | |
| 1095 | + | // only thing rejecting it. A record naming a completed part in a | |
| 1096 | + | // zero-part plan describes nothing. | |
| 1097 | + | let store = fake(60); | |
| 1098 | + | { | |
| 1099 | + | let mut held = store.record.lock().unwrap(); | |
| 1100 | + | let session = &mut held.as_mut().unwrap().session; | |
| 1101 | + | session.part_count = 0; | |
| 1102 | + | session.size_bytes = 0; | |
| 1103 | + | } | |
| 1104 | + | assert!(SyncKitClient::load_resume(&store, "h", 0).is_none()); | |
| 1105 | + | } | |
| 1106 | + | ||
| 1072 | 1107 | #[test] | |
| 1073 | 1108 | fn a_record_with_no_completed_parts_saves_nothing() { | |
| 1074 | 1109 | // Not an error: the upload starts at part 1 either way. Dropping it | |
| @@ -1243,4 +1278,111 @@ | |||
| 1243 | 1278 | assert_eq!(parsed["hash"], "sha256-def456"); | |
| 1244 | 1279 | assert_eq!(parsed["size_bytes"], 2048); | |
| 1245 | 1280 | } | |
| 1281 | + | ||
| 1282 | + | // ── The in-memory and streaming size caps ── | |
| 1283 | + | ||
| 1284 | + | #[test] | |
| 1285 | + | fn the_blob_cap_is_four_gibibytes_exactly() { | |
| 1286 | + | // Pinned as a literal rather than as the same arithmetic the constant | |
| 1287 | + | // uses, because that arithmetic is what can be wrong. The two readings a | |
| 1288 | + | // single wrong operator produces here are 1_077_936_128 and 4_195_328: | |
| 1289 | + | // both look like plausible caps, and either would refuse legitimate | |
| 1290 | + | // media the SDK documents itself as carrying. Nothing else in the suite | |
| 1291 | + | // can see the difference, since no fixture is anywhere near any of the | |
| 1292 | + | // three values. | |
| 1293 | + | assert_eq!(MAX_BLOB_BYTES, 4_294_967_296, "4 GiB"); | |
| 1294 | + | } | |
| 1295 | + | ||
| 1296 | + | /// A client holding a key but no session: every blob path gets past the | |
| 1297 | + | /// key check and stops at `require_token`, which is what makes | |
| 1298 | + | /// `NotAuthenticated` mean "the size check let this through". | |
| 1299 | + | fn keyed_but_unauthenticated() -> SyncKitClient { | |
| 1300 | + | let client = SyncKitClient::new(crate::SyncKitConfig { | |
| 1301 | + | server_url: "https://example.invalid".to_string(), | |
| 1302 | + | api_key: "test-api-key".to_string(), | |
| 1303 | + | }); | |
| 1304 | + | client.set_master_key_raw([9u8; 32]); | |
| 1305 | + | client | |
| 1306 | + | } | |
| 1307 | + | ||
| 1308 | + | /// A sparse file of `len` bytes: `set_len` allocates nothing, so the | |
| 1309 | + | /// multi-gigabyte sizes the cap is written in terms of cost no disk. The | |
| 1310 | + | /// cap is read off `metadata`, which is all these tests reach. | |
| 1311 | + | fn sparse_file(len: u64) -> std::path::PathBuf { | |
| 1312 | + | use std::sync::atomic::{AtomicU64, Ordering}; | |
| 1313 | + | static N: AtomicU64 = AtomicU64::new(0); | |
| 1314 | + | let mut p = std::env::temp_dir(); | |
| 1315 | + | p.push(format!( | |
| 1316 | + | "synckit_cap_{}_{}", | |
| 1317 | + | std::process::id(), | |
| 1318 | + | N.fetch_add(1, Ordering::Relaxed) | |
| 1319 | + | )); | |
| 1320 | + | let f = std::fs::File::create(&p).unwrap(); | |
| 1321 | + | f.set_len(len).unwrap(); | |
| 1322 | + | p | |
| 1323 | + | } | |
| 1324 | + | ||
| 1325 | + | #[tokio::test] | |
| 1326 | + | async fn the_streaming_cap_accepts_a_blob_of_exactly_the_cap_and_refuses_one_byte_more() { | |
| 1327 | + | // Both sides of the bound. `>` differs from `>=` and from `==` only at | |
| 1328 | + | // the cap itself, so a test that only uploads something small cannot | |
| 1329 | + | // see any of them: at every reachable size all three agree. | |
| 1330 | + | let client = keyed_but_unauthenticated(); | |
| 1331 | + | let hash = "b".repeat(64); | |
| 1332 | + | ||
| 1333 | + | let at_cap = sparse_file(MAX_BLOB_BYTES as u64); | |
| 1334 | + | let err = client | |
| 1335 | + | .blob_upload_streaming(&hash, &at_cap) | |
| 1336 | + | .await | |
| 1337 | + | .unwrap_err(); | |
| 1338 | + | assert!( | |
| 1339 | + | matches!(err, SyncKitError::NotAuthenticated), | |
| 1340 | + | "a blob of exactly {MAX_BLOB_BYTES} bytes is under the cap and must reach the session check, got {err:?}" | |
| 1341 | + | ); | |
| 1342 | + | ||
| 1343 | + | let over = sparse_file(MAX_BLOB_BYTES as u64 + 1); | |
| 1344 | + | let err = client | |
| 1345 | + | .blob_upload_streaming(&hash, &over) | |
| 1346 | + | .await | |
| 1347 | + | .unwrap_err(); | |
| 1348 | + | match err { | |
| 1349 | + | SyncKitError::InvalidArgument(m) => { | |
| 1350 | + | assert!(m.contains("client cap"), "wrong rejection: {m}"); | |
| 1351 | + | } | |
| 1352 | + | other => panic!("one byte over the cap must be refused, got {other:?}"), | |
| 1353 | + | } | |
| 1354 | + | ||
| 1355 | + | // And a small file is not refused by a cap that has been inverted. | |
| 1356 | + | let small = sparse_file(1_000); | |
| 1357 | + | let err = client | |
| 1358 | + | .blob_upload_streaming(&hash, &small) | |
| 1359 | + | .await | |
| 1360 | + | .unwrap_err(); | |
| 1361 | + | assert!( | |
| 1362 | + | matches!(err, SyncKitError::NotAuthenticated), | |
| 1363 | + | "a 1000-byte blob must reach the session check, got {err:?}" | |
| 1364 | + | ); | |
| 1365 | + | ||
| 1366 | + | for p in [at_cap, over, small] { | |
| 1367 | + | let _ = std::fs::remove_file(p); | |
| 1368 | + | } | |
| 1369 | + | } | |
| 1370 | + | ||
| 1371 | + | #[tokio::test] | |
| 1372 | + | async fn the_in_memory_cap_passes_an_ordinary_blob_through_to_the_put() { | |
| 1373 | + | // The reachable half of the same bound: a blob far under the cap must | |
| 1374 | + | // not be rejected by it, so the call fails at the transport instead. | |
| 1375 | + | // (The unreachable half is the cap itself, which would need a 4 GiB | |
| 1376 | + | // allocation to reach.) A relative URL is a reqwest builder error, | |
| 1377 | + | // classified as permanent, so no network attempt is made. | |
| 1378 | + | let client = keyed_but_unauthenticated(); | |
| 1379 | + | let err = client | |
| 1380 | + | .blob_upload(&"c".repeat(64), "not-a-url", vec![7u8; 5_000]) | |
| 1381 | + | .await | |
| 1382 | + | .unwrap_err(); | |
| 1383 | + | assert!( | |
| 1384 | + | matches!(err, SyncKitError::Http(_)), | |
| 1385 | + | "a 5000-byte blob is under the cap and must reach the PUT, got {err:?}" | |
| 1386 | + | ); | |
| 1387 | + | } | |
| 1246 | 1388 | } |
| @@ -1900,4 +1900,226 @@ | |||
| 1900 | 1900 | .unwrap_err(); | |
| 1901 | 1901 | assert!(matches!(err, SyncKitError::Internal(_))); | |
| 1902 | 1902 | } | |
| 1903 | + | ||
| 1904 | + | // ── the retry loop's sleep, observed on a frozen clock ── | |
| 1905 | + | // | |
| 1906 | + | // Every other retry test counts requests, and a request count cannot see | |
| 1907 | + | // `if attempt < max_attempts`: that guard gates only `tokio::time::sleep`, | |
| 1908 | + | // so flipping it to `>` (never sleep) or `<=` (sleep once more, after the | |
| 1909 | + | // final attempt) leaves the number of attempts untouched. These tests stamp | |
| 1910 | + | // the paused clock inside the operation itself, which makes each gap | |
| 1911 | + | // between attempts exactly the delay that was slept. | |
| 1912 | + | ||
| 1913 | + | /// The gaps between consecutive clock stamps. | |
| 1914 | + | fn gaps(stamps: &[tokio::time::Instant]) -> Vec<Duration> { | |
| 1915 | + | stamps.windows(2).map(|w| w[1] - w[0]).collect() | |
| 1916 | + | } | |
| 1917 | + | ||
| 1918 | + | /// Assert a slept gap lands inside `jittered`'s +/-20% window for `base_ms`. | |
| 1919 | + | /// The windows for 1s, 2s and 4s do not overlap, so a gap identifies which | |
| 1920 | + | /// backoff step produced it. | |
| 1921 | + | #[track_caller] | |
| 1922 | + | fn assert_slept(got: Duration, base_ms: u64) { | |
| 1923 | + | let low = Duration::from_millis(base_ms - base_ms / 5); | |
| 1924 | + | let high = Duration::from_millis(base_ms + base_ms / 5); | |
| 1925 | + | assert!( | |
| 1926 | + | got >= low && got <= high, | |
| 1927 | + | "slept {got:?}, outside the +/-20% window [{low:?}, {high:?}] around {base_ms}ms" | |
| 1928 | + | ); | |
| 1929 | + | } | |
| 1930 | + | ||
| 1931 | + | #[tokio::test(start_paused = true)] | |
| 1932 | + | async fn retry_request_sleeps_before_each_replay_and_not_after_the_last() { | |
| 1933 | + | let client = SyncKitClient::new(test_config()); | |
| 1934 | + | let stamps = std::sync::Mutex::new(Vec::new()); | |
| 1935 | + | let start = tokio::time::Instant::now(); | |
| 1936 | + | ||
| 1937 | + | let err = client | |
| 1938 | + | .retry_request(Idempotency::ReadOnly, || { | |
| 1939 | + | stamps.lock().unwrap().push(tokio::time::Instant::now()); | |
| 1940 | + | async { Result::<reqwest::Response>::Err(server_err(503, None)) } | |
| 1941 | + | }) | |
| 1942 | + | .await | |
| 1943 | + | .unwrap_err(); | |
| 1944 | + | let end = tokio::time::Instant::now(); | |
| 1945 | + | ||
| 1946 | + | assert!(matches!(err, SyncKitError::Server { status: 503, .. })); | |
| 1947 | + | let stamps = stamps.into_inner().unwrap(); | |
| 1948 | + | assert_eq!(stamps.len(), 4, "MAX_RETRIES is 3, so 1 try plus 3 replays"); | |
| 1949 | + | ||
| 1950 | + | // Nothing is slept before the first try. | |
| 1951 | + | assert_eq!(stamps[0] - start, Duration::ZERO); | |
| 1952 | + | let gaps = gaps(&stamps); | |
| 1953 | + | assert_slept(gaps[0], 1_000); | |
| 1954 | + | assert_slept(gaps[1], 2_000); | |
| 1955 | + | assert_slept(gaps[2], 4_000); | |
| 1956 | + | // And nothing after the last: the loop gives up the instant the final | |
| 1957 | + | // attempt fails. A guard that fired on `attempt == max_attempts` would | |
| 1958 | + | // burn an 8s backoff here for no replay. | |
| 1959 | + | assert_eq!( | |
| 1960 | + | end - stamps[3], | |
| 1961 | + | Duration::ZERO, | |
| 1962 | + | "slept after the final attempt, which buys nothing" | |
| 1963 | + | ); | |
| 1964 | + | } | |
| 1965 | + | ||
| 1966 | + | #[tokio::test(start_paused = true)] | |
| 1967 | + | async fn retry_request_neither_replays_nor_sleeps_an_unsafe_operation() { | |
| 1968 | + | let client = SyncKitClient::new(test_config()); | |
| 1969 | + | let calls = std::sync::Mutex::new(0u32); | |
| 1970 | + | let start = tokio::time::Instant::now(); | |
| 1971 | + | ||
| 1972 | + | let err = client | |
| 1973 | + | .retry_request(Idempotency::Unsafe, || { | |
| 1974 | + | *calls.lock().unwrap() += 1; | |
| 1975 | + | async { Result::<reqwest::Response>::Err(server_err(503, None)) } | |
| 1976 | + | }) | |
| 1977 | + | .await | |
| 1978 | + | .unwrap_err(); | |
| 1979 | + | ||
| 1980 | + | assert!(matches!(err, SyncKitError::Server { status: 503, .. })); | |
| 1981 | + | assert_eq!(*calls.lock().unwrap(), 1, "Unsafe gets exactly one attempt"); | |
| 1982 | + | // max_attempts is 0 here, so the sleep guard must not fire even once: | |
| 1983 | + | // the caller is told the request failed with no delay bought for a | |
| 1984 | + | // replay that is never going to happen. | |
| 1985 | + | assert_eq!(tokio::time::Instant::now() - start, Duration::ZERO); | |
| 1986 | + | } | |
| 1987 | + | ||
| 1988 | + | #[tokio::test(start_paused = true)] | |
| 1989 | + | async fn retry_request_json_sleeps_before_each_replay_and_not_after_the_last() { | |
| 1990 | + | let client = SyncKitClient::new(test_config()); | |
| 1991 | + | let stamps = std::sync::Mutex::new(Vec::new()); | |
| 1992 | + | let start = tokio::time::Instant::now(); | |
| 1993 | + | ||
| 1994 | + | let err = client | |
| 1995 | + | .retry_request_json::<_, _, serde_json::Value>(Idempotency::ReadOnly, || { | |
| 1996 | + | stamps.lock().unwrap().push(tokio::time::Instant::now()); | |
| 1997 | + | async { Result::<reqwest::Response>::Err(server_err(503, None)) } | |
| 1998 | + | }) | |
| 1999 | + | .await | |
| 2000 | + | .unwrap_err(); | |
| 2001 | + | let end = tokio::time::Instant::now(); | |
| 2002 | + | ||
| 2003 | + | assert!(matches!(err, SyncKitError::Server { status: 503, .. })); | |
| 2004 | + | let stamps = stamps.into_inner().unwrap(); | |
| 2005 | + | assert_eq!(stamps.len(), 4, "MAX_RETRIES is 3, so 1 try plus 3 replays"); | |
| 2006 | + | assert_eq!(stamps[0] - start, Duration::ZERO); | |
| 2007 | + | let gaps = gaps(&stamps); | |
| 2008 | + | assert_slept(gaps[0], 1_000); | |
| 2009 | + | assert_slept(gaps[1], 2_000); | |
| 2010 | + | assert_slept(gaps[2], 4_000); | |
| 2011 | + | assert_eq!( | |
| 2012 | + | end - stamps[3], | |
| 2013 | + | Duration::ZERO, | |
| 2014 | + | "slept after the final attempt, which buys nothing" | |
| 2015 | + | ); | |
| 2016 | + | } | |
| 2017 | + | ||
| 2018 | + | #[tokio::test] | |
| 2019 | + | async fn retry_request_json_sleeps_between_body_read_failures() { | |
| 2020 | + | // The body-read arm has its own copy of the guard, reached only when | |
| 2021 | + | // the request succeeds and the parse does not. | |
| 2022 | + | ensure_crypto_provider(); | |
| 2023 | + | let server = wiremock::MockServer::start().await; | |
| 2024 | + | wiremock::Mock::given(wiremock::matchers::any()) | |
| 2025 | + | .respond_with(wiremock::ResponseTemplate::new(200)) | |
| 2026 | + | .mount(&server) | |
| 2027 | + | .await; | |
| 2028 | + | ||
| 2029 | + | // Fetch every response BEFORE freezing the clock. An empty body is | |
| 2030 | + | // Content-Length 0, so hyper ends the stream without another socket | |
| 2031 | + | // read: inside the loop there is no I/O left, and the only thing that | |
| 2032 | + | // can advance a paused clock is the loop's own sleep. | |
| 2033 | + | let mut responses = std::collections::VecDeque::new(); | |
| 2034 | + | for _ in 0..4 { | |
| 2035 | + | responses.push_back(reqwest::get(server.uri()).await.unwrap()); | |
| 2036 | + | } | |
| 2037 | + | let responses = std::sync::Mutex::new(responses); | |
| 2038 | + | let stamps = std::sync::Mutex::new(Vec::new()); | |
| 2039 | + | ||
| 2040 | + | let client = SyncKitClient::new(test_config()); | |
| 2041 | + | tokio::time::pause(); | |
| 2042 | + | let start = tokio::time::Instant::now(); | |
| 2043 | + | let err = client | |
| 2044 | + | .retry_request_json::<_, _, serde_json::Value>(Idempotency::ReadOnly, || { | |
| 2045 | + | stamps.lock().unwrap().push(tokio::time::Instant::now()); | |
| 2046 | + | let resp = responses | |
| 2047 | + | .lock() | |
| 2048 | + | .unwrap() | |
| 2049 | + | .pop_front() | |
| 2050 | + | .expect("the loop asked for a fifth attempt"); | |
| 2051 | + | async move { Result::<reqwest::Response>::Ok(resp) } | |
| 2052 | + | }) | |
| 2053 | + | .await | |
| 2054 | + | .unwrap_err(); | |
| 2055 | + | let end = tokio::time::Instant::now(); | |
| 2056 | + | ||
| 2057 | + | // An empty body is not JSON, so every attempt fails in the parse. | |
| 2058 | + | assert!(matches!(err, SyncKitError::Json(_)), "got {err:?}"); | |
| 2059 | + | let stamps = stamps.into_inner().unwrap(); | |
| 2060 | + | assert_eq!(stamps.len(), 4, "MAX_RETRIES is 3, so 1 try plus 3 replays"); | |
| 2061 | + | assert_eq!(stamps[0] - start, Duration::ZERO); | |
| 2062 | + | let gaps = gaps(&stamps); | |
| 2063 | + | assert_slept(gaps[0], 1_000); | |
| 2064 | + | assert_slept(gaps[1], 2_000); | |
| 2065 | + | assert_slept(gaps[2], 4_000); | |
| 2066 | + | assert_eq!( | |
| 2067 | + | end - stamps[3], | |
| 2068 | + | Duration::ZERO, | |
| 2069 | + | "slept after the final attempt, which buys nothing" | |
| 2070 | + | ); | |
| 2071 | + | } | |
| 2072 | + | ||
| 2073 | + | /// Serve one chunked HTTP/1.1 response carrying `len` bytes and no | |
| 2074 | + | /// `Content-Length`. The whole response goes out in a single write so the | |
| 2075 | + | /// body reaches `bytes_stream` as one item. | |
| 2076 | + | async fn chunked_body_of_len(len: usize) -> reqwest::Response { | |
| 2077 | + | use tokio::io::{AsyncReadExt, AsyncWriteExt}; | |
| 2078 | + | ensure_crypto_provider(); | |
| 2079 | + | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); | |
| 2080 | + | let addr = listener.local_addr().unwrap(); | |
| 2081 | + | tokio::spawn(async move { | |
| 2082 | + | let (mut sock, _) = listener.accept().await.unwrap(); | |
| 2083 | + | // Drain the request head before replying. A single `read` is not | |
| 2084 | + | // enough: TCP may split the request across segments, and answering | |
| 2085 | + | // a half-read request races the client's own write. | |
| 2086 | + | let mut request = Vec::new(); | |
| 2087 | + | let mut scratch = [0u8; 256]; | |
| 2088 | + | while !request.windows(4).any(|w| w == b"\r\n\r\n") { | |
| 2089 | + | let n = sock.read(&mut scratch).await.unwrap(); | |
| 2090 | + | assert_ne!(n, 0, "the client closed before sending a request head"); | |
| 2091 | + | request.extend_from_slice(&scratch[..n]); | |
| 2092 | + | } | |
| 2093 | + | let mut out = b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n".to_vec(); | |
| 2094 | + | out.extend_from_slice(format!("{len:x}\r\n").as_bytes()); | |
| 2095 | + | out.extend_from_slice(&vec![b'x'; len]); | |
| 2096 | + | out.extend_from_slice(b"\r\n0\r\n\r\n"); | |
| 2097 | + | sock.write_all(&out).await.unwrap(); | |
| 2098 | + | sock.flush().await.unwrap(); | |
| 2099 | + | }); | |
| 2100 | + | reqwest::get(format!("http://{addr}/")).await.unwrap() | |
| 2101 | + | } | |
| 2102 | + | ||
| 2103 | + | #[tokio::test] | |
| 2104 | + | async fn read_body_capped_rejects_an_oversized_body_that_declares_no_length() { | |
| 2105 | + | // Every other oversized case is caught by the Content-Length fast path, | |
| 2106 | + | // which leaves the running `buf.len() + chunk.len()` total unobserved. | |
| 2107 | + | // A chunked body has no declared length, so that accumulation is the | |
| 2108 | + | // only thing standing between the cap and an unbounded read: with the | |
| 2109 | + | // buffer still empty it is the incoming chunk's own size that has to | |
| 2110 | + | // trip the limit. | |
| 2111 | + | let resp = chunked_body_of_len(100).await; | |
| 2112 | + | assert_eq!( | |
| 2113 | + | resp.content_length(), | |
| 2114 | + | None, | |
| 2115 | + | "the fast path must not be what rejects this" | |
| 2116 | + | ); | |
| 2117 | + | let err = read_body_capped(resp, 50).await.unwrap_err(); | |
| 2118 | + | match err { | |
| 2119 | + | SyncKitError::Internal(msg) => { | |
| 2120 | + | assert!(msg.contains("50"), "message did not name the cap: {msg}"); | |
| 2121 | + | } | |
| 2122 | + | other => panic!("expected Internal, got {other:?}"), | |
| 2123 | + | } | |
| 2124 | + | } | |
| 1903 | 2125 | } |
| @@ -787,6 +787,19 @@ | |||
| 787 | 787 | .pk(&["key"]) | |
| 788 | 788 | .exclude_where("{row}.key NOT LIKE 'sync\\_%' ESCAPE '\\'"), | |
| 789 | 789 | SyncTable::full("reffer", &["id", "ext_id"]).references_unsynced(), | |
| 790 | + | // `kind` is NOT NULL *with a default*, the only shape in which | |
| 791 | + | // omitting a column and binding an explicit NULL differ observably. | |
| 792 | + | SyncTable::full("note", &["id", "body", "kind"]), | |
| 793 | + | // A preserved column that is also a whitelist column, so a payload | |
| 794 | + | // can carry it and the ON CONFLICT SET has to refuse it. | |
| 795 | + | SyncTable::full("vault", &["id", "label", "token"]).preserve_local(&["token"]), | |
| 796 | + | // Partial update on a composite key: two WHERE bindings, not one. | |
| 797 | + | SyncTable::full("pairflag", &["a", "b", "flag"]) | |
| 798 | + | .pk(&["a", "b"]) | |
| 799 | + | .partial_update(&["flag"]), | |
| 800 | + | // INTEGER PRIMARY KEY, so a text id is a datatype mismatch: a SQLite | |
| 801 | + | // failure that is not a constraint violation. | |
| 802 | + | SyncTable::full("tally", &["id", "label"]), | |
| 790 | 803 | ]) | |
| 791 | 804 | } | |
| 792 | 805 | ||
| @@ -804,6 +817,10 @@ | |||
| 804 | 817 | CREATE TABLE samp (hash TEXT PRIMARY KEY, name TEXT, deleted_at INTEGER); | |
| 805 | 818 | CREATE TABLE cfg (key TEXT PRIMARY KEY, value TEXT); | |
| 806 | 819 | CREATE TABLE reffer (id TEXT PRIMARY KEY, ext_id INTEGER NOT NULL REFERENCES ghost(id)); | |
| 820 | + | CREATE TABLE note (id TEXT PRIMARY KEY, body TEXT, kind TEXT NOT NULL DEFAULT 'plain'); | |
| 821 | + | CREATE TABLE vault (id TEXT PRIMARY KEY, label TEXT, token TEXT); | |
| 822 | + | CREATE TABLE pairflag (a TEXT, b TEXT, flag INTEGER, PRIMARY KEY (a, b)); | |
| 823 | + | CREATE TABLE tally (id INTEGER PRIMARY KEY, label TEXT); | |
| 807 | 824 | ", | |
| 808 | 825 | ) | |
| 809 | 826 | .unwrap(); | |
| @@ -1408,4 +1425,223 @@ | |||
| 1408 | 1425 | .collect(); | |
| 1409 | 1426 | assert_eq!(read, vec![0, 1], "the payload key names the row to update"); | |
| 1410 | 1427 | } | |
| 1428 | + | ||
| 1429 | + | #[test] | |
| 1430 | + | fn a_non_constraint_sqlite_failure_rolls_the_whole_batch_back() { | |
| 1431 | + | let mut conn = db(); | |
| 1432 | + | // tally.id is an INTEGER PRIMARY KEY, so a text id SQLite cannot coerce | |
| 1433 | + | // raises SQLITE_MISMATCH, not SQLITE_CONSTRAINT. Only a constraint | |
| 1434 | + | // violation is survivable; anything else means the batch cannot be | |
| 1435 | + | // trusted, so it must surface as Err rather than as a deferred row. | |
| 1436 | + | let changes = ResolvedChanges::for_test(vec![ | |
| 1437 | + | upsert("parent", "p1", json!({"id":"p1","name":"a"})), | |
| 1438 | + | upsert("tally", "t1", json!({"id":"notanint","label":"x"})), | |
| 1439 | + | ]); | |
| 1440 | + | let e = apply_remote_changes(&mut conn, &schema(), &changes, "") | |
| 1441 | + | .expect_err("a non-constraint SQLite error must not be swallowed as deferred"); | |
| 1442 | + | assert!(matches!(e, crate::error::SyncKitError::Database(_))); | |
| 1443 | + | assert_eq!( | |
| 1444 | + | conn.query_row("SELECT COUNT(*) FROM parent", [], |r| r.get::<_, i64>(0)) | |
| 1445 | + | .unwrap(), | |
| 1446 | + | 0, | |
| 1447 | + | "the valid row earlier in the same batch rolls back with it" | |
| 1448 | + | ); | |
| 1449 | + | assert_eq!( | |
| 1450 | + | conn.query_row("SELECT COUNT(*) FROM tally", [], |r| r.get::<_, i64>(0)) | |
| 1451 | + | .unwrap(), | |
| 1452 | + | 0 | |
| 1453 | + | ); | |
| 1454 | + | } | |
| 1455 | + | ||
| 1456 | + | #[test] | |
| 1457 | + | fn a_null_for_a_defaulted_not_null_column_is_omitted_not_bound() { | |
| 1458 | + | let mut conn = db(); | |
| 1459 | + | // note.kind is NOT NULL DEFAULT 'plain'. Omitting it (what the NOT NULL | |
| 1460 | + | // set is read for) takes the default; binding an explicit NULL would | |
| 1461 | + | // violate instead, so the two are distinguishable here. | |
| 1462 | + | let o = apply( | |
| 1463 | + | &mut conn, | |
| 1464 | + | &[upsert( | |
| 1465 | + | "note", | |
| 1466 | + | "n1", | |
| 1467 | + | json!({"id":"n1","body":"b1","kind":null}), | |
| 1468 | + | )], | |
| 1469 | + | ); | |
| 1470 | + | assert_eq!(o.applied, 1); | |
| 1471 | + | assert!(o.deferred.is_empty(), "an omitted column takes its default"); | |
| 1472 | + | let kind: String = conn | |
| 1473 | + | .query_row("SELECT kind FROM note WHERE id='n1'", [], |r| r.get(0)) | |
| 1474 | + | .unwrap(); | |
| 1475 | + | assert_eq!(kind, "plain"); | |
| 1476 | + | ||
| 1477 | + | // Same on the update leg: the column is left out of the ON CONFLICT SET, | |
| 1478 | + | // so a local value stands rather than being nulled. | |
| 1479 | + | conn.execute("UPDATE note SET kind='code' WHERE id='n1'", []) | |
| 1480 | + | .unwrap(); | |
| 1481 | + | let o2 = apply( | |
| 1482 | + | &mut conn, | |
| 1483 | + | &[upsert( | |
| 1484 | + | "note", | |
| 1485 | + | "n1", | |
| 1486 | + | json!({"id":"n1","body":"b2","kind":null}), | |
| 1487 | + | )], | |
| 1488 | + | ); | |
| 1489 | + | assert_eq!(o2.applied, 1); | |
| 1490 | + | assert!(o2.deferred.is_empty()); | |
| 1491 | + | let (body, kind): (String, String) = conn | |
| 1492 | + | .query_row("SELECT body, kind FROM note WHERE id='n1'", [], |r| { | |
| 1493 | + | Ok((r.get(0)?, r.get(1)?)) | |
| 1494 | + | }) | |
| 1495 | + | .unwrap(); | |
| 1496 | + | assert_eq!(body, "b2", "a nullable column still updates"); | |
| 1497 | + | assert_eq!( | |
| 1498 | + | kind, "code", | |
| 1499 | + | "a NOT NULL column the payload nulled keeps its local value" | |
| 1500 | + | ); | |
| 1501 | + | } | |
| 1502 | + | ||
| 1503 | + | #[test] | |
| 1504 | + | fn a_preserved_column_carried_in_the_payload_is_still_not_overwritten() { | |
| 1505 | + | let mut conn = db(); | |
| 1506 | + | // vault.token is both a whitelist column and preserve_local, so the | |
| 1507 | + | // payload can carry it and the ON CONFLICT SET must still refuse it. | |
| 1508 | + | // Only the preserve filter keeps it out; the PK filter would not. | |
| 1509 | + | let o = apply( | |
| 1510 | + | &mut conn, | |
| 1511 | + | &[upsert( | |
| 1512 | + | "vault", | |
| 1513 | + | "v1", | |
| 1514 | + | json!({"id":"v1","label":"l1","token":"seed"}), | |
| 1515 | + | )], | |
| 1516 | + | ); | |
| 1517 | + | assert_eq!(o.applied, 1); | |
| 1518 | + | conn.execute("UPDATE vault SET token='local' WHERE id='v1'", []) | |
| 1519 | + | .unwrap(); | |
| 1520 | + | apply( | |
| 1521 | + | &mut conn, | |
| 1522 | + | &[upsert( | |
| 1523 | + | "vault", | |
| 1524 | + | "v1", | |
| 1525 | + | json!({"id":"v1","label":"l2","token":"remote"}), | |
| 1526 | + | )], | |
| 1527 | + | ); | |
| 1528 | + | let (label, token): (String, String) = conn | |
| 1529 | + | .query_row("SELECT label, token FROM vault WHERE id='v1'", [], |r| { | |
| 1530 | + | Ok((r.get(0)?, r.get(1)?)) | |
| 1531 | + | }) | |
| 1532 | + | .unwrap(); | |
| 1533 | + | assert_eq!(label, "l2", "a non-preserved column still updates"); | |
| 1534 | + | assert_eq!( | |
| 1535 | + | token, "local", | |
| 1536 | + | "preserve_local outranks a payload that carries the column" | |
| 1537 | + | ); | |
| 1538 | + | } | |
| 1539 | + | ||
| 1540 | + | #[test] | |
| 1541 | + | fn a_pre_existing_orphan_survives_a_sweep_of_its_own_table() { | |
| 1542 | + | let mut conn = db(); | |
| 1543 | + | // An orphan left by an earlier relaxed apply. No entry in this batch | |
| 1544 | + | // describes it, so there is nothing to hold and deleting it would lose | |
| 1545 | + | // the row for good. | |
| 1546 | + | conn.execute_batch( | |
| 1547 | + | "PRAGMA foreign_keys=OFF; | |
| 1548 | + | INSERT INTO child (id, parent_id, note) VALUES ('ghost1', 'gone', 'g'); | |
| 1549 | + | PRAGMA foreign_keys=ON;", | |
| 1550 | + | ) | |
| 1551 | + | .unwrap(); | |
| 1552 | + | // `reffer` turns the batch-wide relaxation on, so the sweep runs over | |
| 1553 | + | // `child`. The batch names the table but not this row. | |
| 1554 | + | let o = apply( | |
| 1555 | + | &mut conn, | |
| 1556 | + | &[ | |
| 1557 | + | upsert("reffer", "r1", json!({"id":"r1","ext_id":404})), | |
| 1558 | + | upsert("parent", "p1", json!({"id":"p1","name":"a"})), | |
| 1559 | + | upsert( | |
| 1560 | + | "child", | |
| 1561 | + | "c1", | |
| 1562 | + | json!({"id":"c1","parent_id":"p1","note":"n"}), | |
| 1563 | + | ), | |
| 1564 | + | ], | |
| 1565 | + | ); | |
| 1566 | + | assert_eq!(o.applied, 3); | |
| 1567 | + | assert!( | |
| 1568 | + | o.deferred.is_empty(), | |
| 1569 | + | "a row this batch never pulled cannot be deferred for retry" | |
| 1570 | + | ); | |
| 1571 | + | let ids: Vec<String> = conn | |
| 1572 | + | .prepare("SELECT id FROM child ORDER BY id") | |
| 1573 | + | .unwrap() | |
| 1574 | + | .query_map([], |r| r.get(0)) | |
| 1575 | + | .unwrap() | |
| 1576 | + | .map(std::result::Result::unwrap) | |
| 1577 | + | .collect(); | |
| 1578 | + | assert_eq!( | |
| 1579 | + | ids, | |
| 1580 | + | vec!["c1".to_string(), "ghost1".to_string()], | |
| 1581 | + | "matching the table alone is not matching the row" | |
| 1582 | + | ); | |
| 1583 | + | } | |
| 1584 | + | ||
| 1585 | + | #[test] | |
| 1586 | + | fn a_composite_partial_update_binds_every_key_component() { | |
| 1587 | + | let mut conn = db(); | |
| 1588 | + | conn.execute_batch("INSERT INTO pairflag (a, b, flag) VALUES ('x','y',0), ('x','z',0);") | |
| 1589 | + | .unwrap(); | |
| 1590 | + | let o = apply( | |
| 1591 | + | &mut conn, | |
| 1592 | + | &[ChangeEntry { | |
| 1593 | + | op: ChangeOp::Update, | |
| 1594 | + | ..upsert("pairflag", "x:z", json!({"a":"x","b":"z","flag":1})) | |
| 1595 | + | }], | |
| 1596 | + | ); | |
| 1597 | + | assert_eq!(o.applied, 1); | |
| 1598 | + | let flags: Vec<(String, i64)> = conn | |
| 1599 | + | .prepare("SELECT b, flag FROM pairflag ORDER BY b") | |
| 1600 | + | .unwrap() | |
| 1601 | + | .query_map([], |r| Ok((r.get(0)?, r.get(1)?))) | |
| 1602 | + | .unwrap() | |
| 1603 | + | .map(std::result::Result::unwrap) | |
| 1604 | + | .collect(); | |
| 1605 | + | // Each key component needs its own placeholder: reusing the first would | |
| 1606 | + | // compare `b` against the value of `a` and touch the wrong row, or none. | |
| 1607 | + | assert_eq!( | |
| 1608 | + | flags, | |
| 1609 | + | vec![("y".to_string(), 0), ("z".to_string(), 1)], | |
| 1610 | + | "only the row the whole composite key names is updated" | |
| 1611 | + | ); | |
| 1612 | + | } | |
| 1613 | + | ||
| 1614 | + | #[test] | |
| 1615 | + | fn a_delete_with_no_payload_falls_back_to_the_wire_row_id() { | |
| 1616 | + | let mut conn = db(); | |
| 1617 | + | apply( | |
| 1618 | + | &mut conn, | |
| 1619 | + | &[ | |
| 1620 | + | upsert("parent", "p1", json!({"id":"p1","name":"a"})), | |
| 1621 | + | upsert("parent", "p2", json!({"id":"p2","name":"b"})), | |
| 1622 | + | ], | |
| 1623 | + | ); | |
| 1624 | + | // An older client wrote the key into the wire row id and sent no payload. | |
| 1625 | + | // A single-PK table can still be addressed from it. | |
| 1626 | + | let mut change = delete("parent", "p1", json!({})); | |
| 1627 | + | change.data = None; | |
| 1628 | + | let o = apply(&mut conn, &[change]); | |
| 1629 | + | assert_eq!(o.applied, 1); | |
| 1630 | + | assert!( | |
| 1631 | + | o.rejected.is_empty(), | |
| 1632 | + | "a single-PK delete is reconstructable from the row id alone" | |
| 1633 | + | ); | |
| 1634 | + | let left: Vec<String> = conn | |
| 1635 | + | .prepare("SELECT id FROM parent ORDER BY id") | |
| 1636 | + | .unwrap() | |
| 1637 | + | .query_map([], |r| r.get(0)) | |
| 1638 | + | .unwrap() | |
| 1639 | + | .map(std::result::Result::unwrap) | |
| 1640 | + | .collect(); | |
| 1641 | + | assert_eq!( | |
| 1642 | + | left, | |
| 1643 | + | vec!["p2".to_string()], | |
| 1644 | + | "the row the wire id names, and only it, goes" | |
| 1645 | + | ); | |
| 1646 | + | } | |
| 1411 | 1647 | } |
| @@ -311,6 +311,10 @@ | |||
| 311 | 311 | #[derive(Clone, Default)] | |
| 312 | 312 | struct FakeBlobs { | |
| 313 | 313 | store: Arc<Mutex<HashMap<String, Vec<u8>>>>, | |
| 314 | + | /// Every `confirm` call, in order. Recorded because whether confirm | |
| 315 | + | /// happens is the whole difference between an upload and a dedup, and | |
| 316 | + | /// the counters `upload_blobs` returns are identical either way. | |
| 317 | + | confirms: Arc<Mutex<Vec<(String, u64)>>>, | |
| 314 | 318 | } | |
| 315 | 319 | impl FakeBlobs { | |
| 316 | 320 | fn put_raw(&self, hash: &str, bytes: Vec<u8>) { | |
| @@ -319,6 +323,9 @@ | |||
| 319 | 323 | fn has(&self, hash: &str) -> bool { | |
| 320 | 324 | self.store.lock().unwrap().contains_key(hash) | |
| 321 | 325 | } | |
| 326 | + | fn confirms(&self) -> Vec<(String, u64)> { | |
| 327 | + | self.confirms.lock().unwrap().clone() | |
| 328 | + | } | |
| 322 | 329 | } | |
| 323 | 330 | impl BlobTransport for FakeBlobs { | |
| 324 | 331 | async fn upload_file(&self, hash: &str, path: &Path) -> Result<BlobUploadOutcome> { | |
| @@ -329,7 +336,8 @@ | |||
| 329 | 336 | self.store.lock().unwrap().insert(hash.to_string(), data); | |
| 330 | 337 | Ok(BlobUploadOutcome::Uploaded) | |
| 331 | 338 | } | |
| 332 | - | async fn confirm(&self, _hash: &str, _size: u64) -> Result<()> { | |
| 339 | + | async fn confirm(&self, hash: &str, size: u64) -> Result<()> { | |
| 340 | + | self.confirms.lock().unwrap().push((hash.to_string(), size)); | |
| 333 | 341 | Ok(()) | |
| 334 | 342 | } | |
| 335 | 343 | async fn download_url(&self, hash: &str) -> Result<String> { | |
| @@ -594,4 +602,87 @@ | |||
| 594 | 602 | .unwrap(); | |
| 595 | 603 | assert_eq!(co, 1, "reconcile flips a missing-file row to cloud_only"); | |
| 596 | 604 | } | |
| 605 | + | ||
| 606 | + | #[tokio::test] | |
| 607 | + | async fn upload_confirms_exactly_what_it_sent_and_never_what_the_server_already_had() { | |
| 608 | + | // `upload_blobs` returns 1 in both cases, so the count cannot tell an | |
| 609 | + | // upload from a dedup. The confirm call can: it is the write that | |
| 610 | + | // commits the blob server-side, and firing it for content nobody sent | |
| 611 | + | // (or skipping it for content that was sent) is the whole failure. | |
| 612 | + | let dir = tempdir(); | |
| 613 | + | let (db, policy) = setup(&dir); | |
| 614 | + | let server = FakeBlobs::default(); | |
| 615 | + | let content = b"confirm-me-please".to_vec(); // 17 bytes, not 0 or 1 | |
| 616 | + | let h = hash_of(&content); | |
| 617 | + | seed_meta(&db, &h, "wav", content.len() as u64, 0); | |
| 618 | + | std::fs::write( | |
| 619 | + | policy.local_path(&BlobRef { | |
| 620 | + | hash: h.clone(), | |
| 621 | + | ext: "wav".into(), | |
| 622 | + | size: 0, | |
| 623 | + | }), | |
| 624 | + | &content, | |
| 625 | + | ) | |
| 626 | + | .unwrap(); | |
| 627 | + | ||
| 628 | + | assert_eq!(upload_blobs(&db, &server, &policy).await.unwrap(), 1); | |
| 629 | + | assert_eq!( | |
| 630 | + | server.confirms(), | |
| 631 | + | vec![(h.clone(), 17)], | |
| 632 | + | "an upload that sent bytes must confirm them, once, at the row's size" | |
| 633 | + | ); | |
| 634 | + | ||
| 635 | + | // Second pass: the server now holds the hash, so it answers | |
| 636 | + | // AlreadyPresent and nothing may be confirmed. | |
| 637 | + | assert_eq!(upload_blobs(&db, &server, &policy).await.unwrap(), 1); | |
| 638 | + | assert_eq!( | |
| 639 | + | server.confirms(), | |
| 640 | + | vec![(h, 17)], | |
| 641 | + | "a deduped blob sent no bytes, so there is nothing to confirm" | |
| 642 | + | ); | |
| 643 | + | } | |
| 644 | + | ||
| 645 | + | #[tokio::test] | |
| 646 | + | async fn the_client_transport_forwards_each_call_to_the_sdk_method() { | |
| 647 | + | // The impl is four one-line forwards, and a forward that answers on its | |
| 648 | + | // own instead of calling through is invisible to every other test in | |
| 649 | + | // this file (they all run against FakeBlobs). Pin it by giving the | |
| 650 | + | // client nothing to work with: each SDK method has its own refusal, and | |
| 651 | + | // a body that did not call through could not produce it. | |
| 652 | + | let client = SyncKitClient::new(crate::SyncKitConfig { | |
| 653 | + | server_url: "https://example.invalid".to_string(), | |
| 654 | + | api_key: "test-api-key".to_string(), | |
| 655 | + | }); | |
| 656 | + | let hash = "a".repeat(64); | |
| 657 | + | ||
| 658 | + | // blob_upload_streaming asks for the master key before anything else. | |
| 659 | + | let e = BlobTransport::upload_file(&client, &hash, Path::new("/nonexistent")) | |
| 660 | + | .await | |
| 661 | + | .unwrap_err(); | |
| 662 | + | assert!(matches!(e, SyncKitError::NoMasterKey), "upload_file: {e:?}"); | |
| 663 | + | ||
| 664 | + | // blob_confirm and blob_download_url both need a session. | |
| 665 | + | let e = BlobTransport::confirm(&client, &hash, 4096) | |
| 666 | + | .await | |
| 667 | + | .unwrap_err(); | |
| 668 | + | assert!( | |
| 669 | + | matches!(e, SyncKitError::NotAuthenticated), | |
| 670 | + | "confirm: {e:?}" | |
| 671 | + | ); | |
| 672 | + | let e = BlobTransport::download_url(&client, &hash) | |
| 673 | + | .await | |
| 674 | + | .unwrap_err(); | |
| 675 | + | assert!( | |
| 676 | + | matches!(e, SyncKitError::NotAuthenticated), | |
| 677 | + | "download_url: {e:?}" | |
| 678 | + | ); | |
| 679 | + | ||
| 680 | + | // blob_download GETs the presigned URL first, so an unusable URL is its | |
| 681 | + | // refusal. A relative URL is a reqwest builder error, which the retry | |
| 682 | + | // layer classifies as permanent, so this makes no network attempt. | |
| 683 | + | let e = BlobTransport::download(&client, &hash, "not-a-url") | |
| 684 | + | .await | |
| 685 | + | .unwrap_err(); | |
| 686 | + | assert!(matches!(e, SyncKitError::Http(_)), "download: {e:?}"); | |
| 687 | + | } | |
| 597 | 688 | } |
| @@ -2065,4 +2065,92 @@ | |||
| 2065 | 2065 | .unwrap(); | |
| 2066 | 2066 | assert_eq!(oldest_kept, "50"); | |
| 2067 | 2067 | } | |
| 2068 | + | ||
| 2069 | + | /// `stamp_pending` has to leave the advanced clock on disk, not only on the | |
| 2070 | + | /// changelog rows. The wall component is high (5_000_000) and the row count | |
| 2071 | + | /// is three, so the persisted clock is a value no fresh `Hlc::zero` can | |
| 2072 | + | /// coincide with, and the second stamp runs at a LOWER now_ms (1000) so a | |
| 2073 | + | /// clock that failed to persist would visibly restart at 1000 instead of | |
| 2074 | + | /// continuing the counter at 5_000_000. | |
| 2075 | + | #[test] | |
| 2076 | + | fn stamp_pending_persists_the_advanced_clock() { | |
| 2077 | + | let (conn, n) = device(1); | |
| 2078 | + | for id in ["r1", "r2", "r3"] { | |
| 2079 | + | conn.execute("INSERT INTO note (id, name) VALUES (?1, 'v')", [id]) | |
| 2080 | + | .unwrap(); | |
| 2081 | + | } | |
| 2082 | + | assert_eq!(stamp_pending(&conn, n, 5_000_000).unwrap(), 3); | |
| 2083 | + | ||
| 2084 | + | // Three ticks at one wall component: adopt 5_000_000 with counter 0, | |
| 2085 | + | // then bump twice. | |
| 2086 | + | assert_eq!( | |
| 2087 | + | load_clock(&conn, n).unwrap(), | |
| 2088 | + | Hlc { | |
| 2089 | + | wall_ms: 5_000_000, | |
| 2090 | + | counter: 2, | |
| 2091 | + | node: n | |
| 2092 | + | }, | |
| 2093 | + | "the clock reached by stamping must survive a reload" | |
| 2094 | + | ); | |
| 2095 | + | ||
| 2096 | + | conn.execute("INSERT INTO note (id, name) VALUES ('r4', 'v')", []) | |
| 2097 | + | .unwrap(); | |
| 2098 | + | assert_eq!(stamp_pending(&conn, n, 1000).unwrap(), 1); | |
| 2099 | + | let r4 = load_local_pending(&conn, n) | |
| 2100 | + | .unwrap() | |
| 2101 | + | .into_iter() | |
| 2102 | + | .find(|e| e.row_id == "r4") | |
| 2103 | + | .unwrap(); | |
| 2104 | + | assert_eq!( | |
| 2105 | + | r4.hlc, | |
| 2106 | + | Hlc { | |
| 2107 | + | wall_ms: 5_000_000, | |
| 2108 | + | counter: 3, | |
| 2109 | + | node: n | |
| 2110 | + | }, | |
| 2111 | + | "a later stamp at an earlier now_ms must continue the reloaded clock" | |
| 2112 | + | ); | |
| 2113 | + | } | |
| 2114 | + | ||
| 2115 | + | /// `observe` has to leave the merged clock on disk: the whole point is that | |
| 2116 | + | /// a subsequent local write outranks the remote it observed. The remote sits | |
| 2117 | + | /// far in the future (9_000_000_000) and the local stamp that follows runs | |
| 2118 | + | /// at now_ms 2000, so if the merge were not persisted the new stamp would | |
| 2119 | + | /// land at wall 2000 and lose to the remote by seven orders of magnitude. | |
| 2120 | + | #[test] | |
| 2121 | + | fn observe_persists_the_merged_clock() { | |
| 2122 | + | let (conn, n) = device(1); | |
| 2123 | + | let remote = Hlc { | |
| 2124 | + | wall_ms: 9_000_000_000, | |
| 2125 | + | counter: 5, | |
| 2126 | + | node: node(2), | |
| 2127 | + | }; | |
| 2128 | + | observe(&conn, n, [remote], 1000).unwrap(); | |
| 2129 | + | assert_eq!( | |
| 2130 | + | load_clock(&conn, n).unwrap(), | |
| 2131 | + | Hlc { | |
| 2132 | + | wall_ms: 9_000_000_000, | |
| 2133 | + | counter: 6, | |
| 2134 | + | node: n | |
| 2135 | + | }, | |
| 2136 | + | "observe must persist the remote wall and a strictly greater counter" | |
| 2137 | + | ); | |
| 2138 | + | ||
| 2139 | + | conn.execute("INSERT INTO note (id, name) VALUES ('r', 'v')", []) | |
| 2140 | + | .unwrap(); | |
| 2141 | + | assert_eq!(stamp_pending(&conn, n, 2000).unwrap(), 1); | |
| 2142 | + | let stamped = load_local_pending(&conn, n).unwrap().remove(0).hlc; | |
| 2143 | + | assert_eq!( | |
| 2144 | + | stamped, | |
| 2145 | + | Hlc { | |
| 2146 | + | wall_ms: 9_000_000_000, | |
| 2147 | + | counter: 7, | |
| 2148 | + | node: n | |
| 2149 | + | } | |
| 2150 | + | ); | |
| 2151 | + | assert!( | |
| 2152 | + | stamped > remote, | |
| 2153 | + | "a local write after observing must outrank the observed remote" | |
| 2154 | + | ); | |
| 2155 | + | } | |
| 2068 | 2156 | } |
| @@ -876,4 +876,36 @@ | |||
| 876 | 876 | // The failure carries the manifest, so the diff names what moved. | |
| 877 | 877 | assert!(msg.contains("cols=id,name,archived"), "{msg}"); | |
| 878 | 878 | } | |
| 879 | + | ||
| 880 | + | /// `group_scope()` is public API for a consumer asserting exactly which | |
| 881 | + | /// tables are group-scoped, and nothing in the engine calls it (every | |
| 882 | + | /// production site reads the field), so this is the only place the accessor | |
| 883 | + | /// is observed. Three tables, three different answers, so a body returning | |
| 884 | + | /// any fixed value disagrees with at least two rows: the two scoped tables | |
| 885 | + | /// name different columns, and the third is personal-only. | |
| 886 | + | #[test] | |
| 887 | + | fn group_scope_reports_the_declared_provenance_column_per_table() { | |
| 888 | + | let schema = SyncSchema::new(vec![ | |
| 889 | + | SyncTable::full("project", &["id", "name"]).group_scoped("group_id"), | |
| 890 | + | SyncTable::full("note", &["id", "body"]).group_scoped("owner_group"), | |
| 891 | + | SyncTable::full("task", &["id", "project_id", "title", "done"]), | |
| 892 | + | ]); | |
| 893 | + | ||
| 894 | + | let expected: [(&str, Option<&'static str>); 3] = [ | |
| 895 | + | ("project", Some("group_id")), | |
| 896 | + | ("note", Some("owner_group")), | |
| 897 | + | ("task", None), | |
| 898 | + | ]; | |
| 899 | + | for (table, want) in expected { | |
| 900 | + | let t = schema | |
| 901 | + | .tables() | |
| 902 | + | .iter() | |
| 903 | + | .find(|t| t.name() == table) | |
| 904 | + | .unwrap_or_else(|| panic!("{table} is in the schema")); | |
| 905 | + | assert_eq!(t.group_scope(), want, "group_scope() for {table}"); | |
| 906 | + | // The accessor is the field, not a constant: the engine reads the | |
| 907 | + | // field directly (migrate.rs, apply.rs) and the two must not drift. | |
| 908 | + | assert_eq!(t.group_scope(), t.group_scope, "accessor for {table}"); | |
| 909 | + | } | |
| 910 | + | } | |
| 879 | 911 | } |
| @@ -1496,8 +1496,14 @@ | |||
| 1496 | 1496 | ) | |
| 1497 | 1497 | .unwrap(); | |
| 1498 | 1498 | } | |
| 1499 | + | let before: i64 = conn | |
| 1500 | + | .query_row("SELECT COUNT(*) FROM sync_changelog", [], |r| r.get(0)) | |
| 1501 | + | .unwrap(); | |
| 1499 | 1502 | // Cap to 3: keep the 3 most recent rows, drop older PUSHED ones only. | |
| 1500 | 1503 | let dropped = enforce_changelog_retention(&conn, 3).unwrap(); | |
| 1504 | + | let after: i64 = conn | |
| 1505 | + | .query_row("SELECT COUNT(*) FROM sync_changelog", [], |r| r.get(0)) | |
| 1506 | + | .unwrap(); | |
| 1501 | 1507 | let unpushed: i64 = conn | |
| 1502 | 1508 | .query_row( | |
| 1503 | 1509 | "SELECT COUNT(*) FROM sync_changelog WHERE pushed = 0", | |
| @@ -1506,7 +1512,75 @@ | |||
| 1506 | 1512 | ) | |
| 1507 | 1513 | .unwrap(); | |
| 1508 | 1514 | assert_eq!(unpushed, 2, "unpushed entries are never dropped"); | |
| 1509 | - | assert!(dropped >= 1); | |
| 1515 | + | // The 3 newest rows by id are the 2 unpushed plus the newest pushed one, | |
| 1516 | + | // so exactly the 4 oldest pushed rows go. A count that is not 4 - a | |
| 1517 | + | // hardcoded 0 or 1, or the row count of some other query - disagrees. | |
| 1518 | + | assert_eq!( | |
| 1519 | + | dropped, 4, | |
| 1520 | + | "the four oldest pushed rows are the only ones dropped" | |
| 1521 | + | ); | |
| 1522 | + | assert_eq!( | |
| 1523 | + | dropped, | |
| 1524 | + | (before - after) as u64, | |
| 1525 | + | "the returned count is the number of rows actually deleted" | |
| 1526 | + | ); | |
| 1527 | + | assert_eq!(after, 3, "the cap is the number of surviving rows here"); | |
| 1528 | + | } | |
| 1529 | + | ||
| 1530 | + | #[test] | |
| 1531 | + | fn retention_keeps_exactly_cap_rows_and_drops_the_next_one() { | |
| 1532 | + | let dir = tempdir(); | |
| 1533 | + | let (da, _) = device(&dir.join("retention_edge.db"), 27); | |
| 1534 | + | let conn = da.open().unwrap(); | |
| 1535 | + | conn.execute("DELETE FROM sync_changelog", []).unwrap(); | |
| 1536 | + | // 6 pushed rows, ids ascending with insertion order. | |
| 1537 | + | for i in 0..6 { | |
| 1538 | + | conn.execute( | |
| 1539 | + | "INSERT INTO sync_changelog (table_name, op, row_id, data, pushed) VALUES ('note','INSERT',?1,'{}',1)", | |
| 1540 | + | [format!("p{i}")], | |
| 1541 | + | ) | |
| 1542 | + | .unwrap(); | |
| 1543 | + | } | |
| 1544 | + | let ids: Vec<i64> = { | |
| 1545 | + | let mut st = conn | |
| 1546 | + | .prepare("SELECT id FROM sync_changelog ORDER BY id ASC") | |
| 1547 | + | .unwrap(); | |
| 1548 | + | let rows: Vec<i64> = st | |
| 1549 | + | .query_map([], |r| r.get(0)) | |
| 1550 | + | .unwrap() | |
| 1551 | + | .map(|r| r.unwrap()) | |
| 1552 | + | .collect(); | |
| 1553 | + | rows | |
| 1554 | + | }; | |
| 1555 | + | assert_eq!(ids.len(), 6); | |
| 1556 | + | ||
| 1557 | + | // Cap 4 of 6 keeps the newest four and drops two. Both sides of the | |
| 1558 | + | // boundary are named: ids[1] is the last row dropped, ids[2] the first | |
| 1559 | + | // row kept, so an off-by-one in the LIMIT changes the answer. | |
| 1560 | + | let dropped = enforce_changelog_retention(&conn, 4).unwrap(); | |
| 1561 | + | assert_eq!(dropped, 2, "6 rows capped at 4 drops 2"); | |
| 1562 | + | let survivors: Vec<i64> = { | |
| 1563 | + | let mut st = conn | |
| 1564 | + | .prepare("SELECT id FROM sync_changelog ORDER BY id ASC") | |
| 1565 | + | .unwrap(); | |
| 1566 | + | st.query_map([], |r| r.get(0)) | |
| 1567 | + | .unwrap() | |
| 1568 | + | .map(|r| r.unwrap()) | |
| 1569 | + | .collect() | |
| 1570 | + | }; | |
| 1571 | + | assert_eq!(survivors, ids[2..].to_vec(), "the newest four rows survive"); | |
| 1572 | + | assert!( | |
| 1573 | + | !survivors.contains(&ids[1]), | |
| 1574 | + | "the row just past the cap is gone" | |
| 1575 | + | ); | |
| 1576 | + | ||
| 1577 | + | // Re-running at the same cap is a no-op: nothing is left to drop, so a | |
| 1578 | + | // constant return value of 1 or 2 disagrees with 0 here. | |
| 1579 | + | assert_eq!( | |
| 1580 | + | enforce_changelog_retention(&conn, 4).unwrap(), | |
| 1581 | + | 0, | |
| 1582 | + | "a second pass at the same cap drops nothing" | |
| 1583 | + | ); | |
| 1510 | 1584 | } | |
| 1511 | 1585 | ||
| 1512 | 1586 | #[tokio::test] |
| @@ -339,3 +339,133 @@ | |||
| 339 | 339 | "1MB upload should add exactly the v3 chunked overhead" | |
| 340 | 340 | ); | |
| 341 | 341 | } | |
| 342 | + | ||
| 343 | + | // ── The v3 framing boundary on the download path ── | |
| 344 | + | // | |
| 345 | + | // `blob_download` decides three things from lengths alone: that four bytes are | |
| 346 | + | // in hand before it reads the format tag, that the 4-byte tag plus the 13-byte | |
| 347 | + | // header (17 bytes) are in hand before it parses the header, and that a chunk is | |
| 348 | + | // complete before it decrypts. Each is a comparison against a literal, and a | |
| 349 | + | // wrong one is invisible to a test that only serves whole, well-formed blobs: | |
| 350 | + | // every such body is far past all three boundaries, so the comparisons agree. | |
| 351 | + | // The bodies below sit exactly on them. | |
| 352 | + | ||
| 353 | + | /// The message `blob_download` refused `body` with, served at `path`. | |
| 354 | + | async fn download_refusal( | |
| 355 | + | kit: &MockKit, | |
| 356 | + | client: &SyncKitClient, | |
| 357 | + | path: &str, | |
| 358 | + | hash: &str, | |
| 359 | + | body: Vec<u8>, | |
| 360 | + | ) -> String { | |
| 361 | + | kit.get(path).bytes(body).await; | |
| 362 | + | match client.blob_download(hash, &kit.url(path)).await { | |
| 363 | + | Err(SyncKitError::Crypto(message)) => message, | |
| 364 | + | Err(other) => panic!("expected a Crypto refusal at {path}, got {other:?}"), | |
| 365 | + | Ok(_) => panic!("{path} must not be accepted as a blob"), | |
| 366 | + | } | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | #[tokio::test] | |
| 370 | + | async fn a_v3_body_that_stops_short_of_its_header_is_refused_as_a_missing_header() { | |
| 371 | + | let kit = MockKit::start().await; | |
| 372 | + | let (client, _key) = kit.keyed(); | |
| 373 | + | let hash = hex::encode(sha2::Sha256::digest(b"never served")); | |
| 374 | + | let header = synckit_client::crypto::blob_header_bytes(5_000); | |
| 375 | + | assert_eq!( | |
| 376 | + | header.len(), | |
| 377 | + | 17, | |
| 378 | + | "4-byte format tag plus the 13-byte header" | |
| 379 | + | ); | |
| 380 | + | ||
| 381 | + | // Exactly the format tag: enough to know the format, nothing to parse. The | |
| 382 | + | // reader must hold on for the header rather than take the four bytes as one. | |
| 383 | + | let tag_only = | |
| 384 | + | download_refusal(&kit, &client, "/s3/tag-only", &hash, header[..4].to_vec()).await; | |
| 385 | + | assert_eq!( | |
| 386 | + | tag_only, "v3 blob ended before its header", | |
| 387 | + | "four bytes is the tag and no more" | |
| 388 | + | ); | |
| 389 | + | ||
| 390 | + | // Between the two boundaries: past the tag, short of the header. | |
| 391 | + | let partial = download_refusal( | |
| 392 | + | &kit, | |
| 393 | + | &client, | |
| 394 | + | "/s3/partial-header", | |
| 395 | + | &hash, | |
| 396 | + | header[..10].to_vec(), | |
| 397 | + | ) | |
| 398 | + | .await; | |
| 399 | + | assert_eq!( | |
| 400 | + | partial, "v3 blob ended before its header", | |
| 401 | + | "ten bytes is still short of the 17-byte header" | |
| 402 | + | ); | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | #[tokio::test] | |
| 406 | + | async fn a_v3_body_of_exactly_its_header_is_parsed_and_then_found_to_have_no_chunks() { | |
| 407 | + | // Dead on the boundary: the header is complete, so it must be parsed, and | |
| 408 | + | // the refusal must be about the missing chunks rather than the header. A | |
| 409 | + | // reader that waits for one more byte before parsing gives the other | |
| 410 | + | // message, and no whole-blob fixture can tell the two apart. | |
| 411 | + | let kit = MockKit::start().await; | |
| 412 | + | let (client, _key) = kit.keyed(); | |
| 413 | + | let hash = hex::encode(sha2::Sha256::digest(b"never served")); | |
| 414 | + | let header = synckit_client::crypto::blob_header_bytes(5_000); | |
| 415 | + | ||
| 416 | + | let message = download_refusal(&kit, &client, "/s3/header-only", &hash, header).await; | |
| 417 | + | assert_eq!( | |
| 418 | + | message, "v3 blob ended mid-chunk or had trailing bytes", | |
| 419 | + | "a complete header with no chunk behind it is a truncated blob, not a missing header" | |
| 420 | + | ); | |
| 421 | + | } | |
| 422 | + | ||
| 423 | + | #[tokio::test] | |
| 424 | + | async fn a_v3_blob_one_byte_short_or_one_byte_long_is_refused() { | |
| 425 | + | let kit = MockKit::start().await; | |
| 426 | + | let (client, key) = kit.keyed(); | |
| 427 | + | ||
| 428 | + | // Two chunks plus a remainder, so the last chunk is a short one and the | |
| 429 | + | // truncation lands inside it. | |
| 430 | + | let plaintext: Vec<u8> = (0..(synckit_client::crypto::BLOB_CHUNK_SIZE + 4_321)) | |
| 431 | + | .map(|i| i as u8) | |
| 432 | + | .collect(); | |
| 433 | + | let hash = hex::encode(sha2::Sha256::digest(&plaintext)); | |
| 434 | + | let blob = synckit_client::crypto::encrypt_blob_chunked(&plaintext, &key, &hash).unwrap(); | |
| 435 | + | ||
| 436 | + | // The control: intact, these exact bytes decrypt. Without it the two | |
| 437 | + | // refusals below would also pass if the reader refused everything. | |
| 438 | + | kit.get("/s3/intact").bytes(blob.clone()).await; | |
| 439 | + | assert_eq!( | |
| 440 | + | client | |
| 441 | + | .blob_download(&hash, &kit.url("/s3/intact")) | |
| 442 | + | .await | |
| 443 | + | .unwrap(), | |
| 444 | + | plaintext, | |
| 445 | + | "the intact blob must round-trip" | |
| 446 | + | ); | |
| 447 | + | ||
| 448 | + | let short = download_refusal( | |
| 449 | + | &kit, | |
| 450 | + | &client, | |
| 451 | + | "/s3/one-short", | |
| 452 | + | &hash, | |
| 453 | + | blob[..blob.len() - 1].to_vec(), | |
| 454 | + | ) | |
| 455 | + | .await; | |
| 456 | + | assert_eq!( | |
| 457 | + | short, "v3 blob ended mid-chunk or had trailing bytes", | |
| 458 | + | "a chunk one byte short is incomplete and must never be decrypted" | |
| 459 | + | ); | |
| 460 | + | ||
| 461 | + | // One byte past the end: every chunk is complete and the plaintext hashes | |
| 462 | + | // correctly, so nothing but the leftover byte is wrong. A reader that only | |
| 463 | + | // counted chunks would accept this. | |
| 464 | + | let mut long = blob.clone(); | |
| 465 | + | long.push(0); | |
| 466 | + | let long = download_refusal(&kit, &client, "/s3/one-long", &hash, long).await; | |
| 467 | + | assert_eq!( | |
| 468 | + | long, "v3 blob ended mid-chunk or had trailing bytes", | |
| 469 | + | "a trailing byte is not part of any chunk and must be refused" | |
| 470 | + | ); | |
| 471 | + | } |
| @@ -765,3 +765,302 @@ | |||
| 765 | 765 | .await; | |
| 766 | 766 | } | |
| 767 | 767 | } | |
| 768 | + | ||
| 769 | + | // ── Hostile part plans ── | |
| 770 | + | // | |
| 771 | + | // `part_size` and `part_count` come from the server and drive both the | |
| 772 | + | // allocation and the cut points of the sealed stream, so every bound on them is | |
| 773 | + | // arithmetic the client cannot get wrong quietly. Each case below is written as | |
| 774 | + | // a pair: the value that must be accepted by a gate and the adjacent one that | |
| 775 | + | // must not, told apart by which message came back. A test that only checked | |
| 776 | + | // "is_err" would pass with any gate firing, including the wrong one. | |
| 777 | + | ||
| 778 | + | /// Mount a start response carrying an arbitrary plan, plus the abort the client | |
| 779 | + | /// makes on its way out. No part-URL route is mounted: every case here must be | |
| 780 | + | /// refused before a part is requested. | |
| 781 | + | async fn mount_hostile_plan(kit: &MockKit, part_size: u64, part_count: u32) { | |
| 782 | + | kit.post(START_PATH) | |
| 783 | + | .json(json!({ | |
| 784 | + | "upload_id": "hostile-upload-id", | |
| 785 | + | "part_size": part_size, | |
| 786 | + | "part_count": part_count, | |
| 787 | + | "already_exists": false, | |
| 788 | + | })) | |
| 789 | + | .await; | |
| 790 | + | kit.post(ABORT_PATH).code(204).empty().await; | |
| 791 | + | } | |
| 792 | + | ||
| 793 | + | /// Run a streaming upload of a 5000-byte blob against `(part_size, part_count)` | |
| 794 | + | /// and return the internal-error message it was refused with. | |
| 795 | + | async fn plan_rejection(part_size: u64, part_count: u32) -> String { | |
| 796 | + | let kit = MockKit::start().await; | |
| 797 | + | let (client, _key) = kit.keyed(); | |
| 798 | + | let plaintext: Vec<u8> = (0..5_000u32).map(|i| i as u8).collect(); | |
| 799 | + | let hash = hex::encode(sha2::Sha256::digest(&plaintext)); | |
| 800 | + | let file = temp_blob("hostile.bin", &plaintext); | |
| 801 | + | mount_hostile_plan(&kit, part_size, part_count).await; | |
| 802 | + | ||
| 803 | + | let err = client | |
| 804 | + | .blob_upload_streaming(&hash, &file) | |
| 805 | + | .await | |
| 806 | + | .unwrap_err(); | |
| 807 | + | std::fs::remove_file(&file).ok(); | |
| 808 | + | assert_eq!( | |
| 809 | + | kit.hits(PARTS_PATH).await, | |
| 810 | + | 0, | |
| 811 | + | "a plan refused up front must not mint a single part URL" | |
| 812 | + | ); | |
| 813 | + | match err { | |
| 814 | + | SyncKitError::Internal(message) => message, | |
| 815 | + | other => panic!("expected an Internal rejection, got {other:?}"), | |
| 816 | + | } | |
| 817 | + | } | |
| 818 | + | ||
| 819 | + | #[tokio::test] | |
| 820 | + | async fn a_plan_with_no_bytes_per_part_or_no_parts_is_refused_as_empty() { | |
| 821 | + | // part_size 0 is also the divisor of the tiling check below the guard, so a | |
| 822 | + | // guard that let it through would divide by zero rather than mis-upload. | |
| 823 | + | assert!( | |
| 824 | + | plan_rejection(0, 3).await.contains("empty multipart plan"), | |
| 825 | + | "part_size 0 must be refused as an empty plan" | |
| 826 | + | ); | |
| 827 | + | // part_count 0 is the other half of the same `||`: with an `&&` in its | |
| 828 | + | // place, a plan that is empty in only one of the two ways gets through. | |
| 829 | + | assert!( | |
| 830 | + | plan_rejection(1024 * 1024, 0) | |
| 831 | + | .await | |
| 832 | + | .contains("empty multipart plan"), | |
| 833 | + | "part_count 0 must be refused as an empty plan" | |
| 834 | + | ); | |
| 835 | + | } | |
| 836 | + | ||
| 837 | + | #[tokio::test] | |
| 838 | + | async fn the_part_size_ceiling_admits_exactly_one_gibibyte_and_refuses_one_byte_more() { | |
| 839 | + | // At the ceiling the plan is legal geometry and is judged on whether it | |
| 840 | + | // tiles the blob (it does not: 5000 bytes is one part, not two). One byte | |
| 841 | + | // over is refused by the ceiling itself. The two messages name which gate | |
| 842 | + | // fired, which is the only thing that separates `>` from `>=` and `==`. | |
| 843 | + | let at = plan_rejection(1 << 30, 2).await; | |
| 844 | + | assert!( | |
| 845 | + | at.contains("does not match"), | |
| 846 | + | "a part_size of exactly 1 GiB is under the ceiling: {at}" | |
| 847 | + | ); | |
| 848 | + | let over = plan_rejection((1 << 30) + 1, 2).await; | |
| 849 | + | assert!( | |
| 850 | + | over.contains("exceeds the 1073741824-byte ceiling"), | |
| 851 | + | "one byte over the ceiling must be refused by it: {over}" | |
| 852 | + | ); | |
| 853 | + | } | |
| 854 | + | ||
| 855 | + | #[tokio::test] | |
| 856 | + | async fn the_part_count_ceiling_admits_exactly_ten_thousand_and_refuses_one_more() { | |
| 857 | + | // S3's own hard limit, so 10_000 parts is a legal plan and must reach the | |
| 858 | + | // tiling check; 10_001 is not. | |
| 859 | + | let at = plan_rejection(1024 * 1024, 10_000).await; | |
| 860 | + | assert!( | |
| 861 | + | at.contains("does not match"), | |
| 862 | + | "a part_count of exactly 10000 is under the ceiling: {at}" | |
| 863 | + | ); | |
| 864 | + | let over = plan_rejection(1024 * 1024, 10_001).await; | |
| 865 | + | assert!( | |
| 866 | + | over.contains("exceeds the 10000-part ceiling"), | |
| 867 | + | "one part over the ceiling must be refused by it: {over}" | |
| 868 | + | ); | |
| 869 | + | } | |
| 870 | + | ||
| 871 | + | /// Mints one part URL per request with a caller-chosen `part_number` and | |
| 872 | + | /// `content_length`, so a test can make the server's signed geometry disagree | |
| 873 | + | /// with the bytes the client holds. | |
| 874 | + | struct LyingPartsResponder { | |
| 875 | + | part_number: i64, | |
| 876 | + | content_length: u64, | |
| 877 | + | base: String, | |
| 878 | + | } | |
| 879 | + | ||
| 880 | + | impl wiremock::Respond for LyingPartsResponder { | |
| 881 | + | fn respond(&self, _req: &wiremock::Request) -> ResponseTemplate { | |
| 882 | + | ResponseTemplate::new(200).set_body_json(json!({ | |
| 883 | + | "parts": [{ | |
| 884 | + | "part_number": self.part_number, | |
| 885 | + | "content_length": self.content_length, | |
| 886 | + | "url": format!("{}{PART_PUT_PATH}?partNumber={}", self.base, self.part_number), | |
| 887 | + | }], | |
| 888 | + | })) | |
| 889 | + | } | |
| 890 | + | } | |
| 891 | + | ||
| 892 | + | /// A single-part session whose minted URL carries the given geometry. Returns | |
| 893 | + | /// the error the upload was refused with, or `None` if it went through. | |
| 894 | + | async fn minted_part_rejection(part_number: i64, content_length_delta: i64) -> Option<String> { | |
| 895 | + | let kit = MockKit::start().await; | |
| 896 | + | let (client, _key) = kit.keyed(); | |
| 897 | + | let plaintext: Vec<u8> = (0..5_000u32).map(|i| i as u8).collect(); | |
| 898 | + | let hash = hex::encode(sha2::Sha256::digest(&plaintext)); | |
| 899 | + | let file = temp_blob("mismatched-part.bin", &plaintext); | |
| 900 | + | let cipher_len = synckit_client::crypto::blob_encrypted_len(plaintext.len()); | |
| 901 | + | ||
| 902 | + | // One part holding the whole blob, so the plan itself is beyond reproach and | |
| 903 | + | // only the minted URL disagrees. | |
| 904 | + | kit.post(START_PATH) | |
| 905 | + | .json(json!({ | |
| 906 | + | "upload_id": "mismatch-upload-id", | |
| 907 | + | "part_size": cipher_len, | |
| 908 | + | "part_count": 1, | |
| 909 | + | "already_exists": false, | |
| 910 | + | })) | |
| 911 | + | .await; | |
| 912 | + | kit.post(PARTS_PATH) | |
| 913 | + | .responder(LyingPartsResponder { | |
| 914 | + | part_number, | |
| 915 | + | content_length: (cipher_len as i64 + content_length_delta) as u64, | |
| 916 | + | base: kit.uri(), | |
| 917 | + | }) | |
| 918 | + | .await; | |
| 919 | + | kit.put(PART_PUT_PATH) | |
| 920 | + | .reply(ResponseTemplate::new(200).append_header("ETag", "\"part-etag\"")) | |
| 921 | + | .await; | |
| 922 | + | kit.post(COMPLETE_PATH).code(204).empty().await; | |
| 923 | + | kit.post(ABORT_PATH).code(204).empty().await; | |
| 924 | + | ||
| 925 | + | let outcome = client.blob_upload_streaming(&hash, &file).await; | |
| 926 | + | std::fs::remove_file(&file).ok(); | |
| 927 | + | match outcome { | |
| 928 | + | Ok(_) => { | |
| 929 | + | assert_eq!(kit.hits(PART_PUT_PATH).await, 1, "an accepted plan is PUT"); | |
| 930 | + | None | |
| 931 | + | } | |
| 932 | + | Err(SyncKitError::Internal(message)) => { | |
| 933 | + | assert_eq!( | |
| 934 | + | kit.hits(PART_PUT_PATH).await, | |
| 935 | + | 0, | |
| 936 | + | "a part whose geometry is disputed must not be sent anyway" | |
| 937 | + | ); | |
| 938 | + | Some(message) | |
| 939 | + | } | |
| 940 | + | Err(other) => panic!("expected an Internal rejection, got {other:?}"), | |
| 941 | + | } | |
| 942 | + | } | |
| 943 | + | ||
| 944 | + | #[tokio::test] | |
| 945 | + | async fn a_minted_part_url_that_disagrees_with_the_bytes_in_hand_is_refused() { | |
| 946 | + | // The agreeing case first, so the two disagreements below are known to be | |
| 947 | + | // the only difference: part 1, exactly the bytes the client sealed. | |
| 948 | + | assert!( | |
| 949 | + | minted_part_rejection(1, 0).await.is_none(), | |
| 950 | + | "a URL signed for the part the client actually holds must be used" | |
| 951 | + | ); | |
| 952 | + | ||
| 953 | + | // Signed for a different part: PUTting anyway would store the bytes at the | |
| 954 | + | // wrong index and assemble a scrambled object. | |
| 955 | + | let wrong_number = minted_part_rejection(2, 0) | |
| 956 | + | .await | |
| 957 | + | .expect("a URL signed for part 2 must not be used for part 1"); | |
| 958 | + | assert!( | |
| 959 | + | wrong_number.contains("part geometry mismatch"), | |
| 960 | + | "wrong part_number: {wrong_number}" | |
| 961 | + | ); | |
| 962 | + | ||
| 963 | + | // Signed for a different length: Content-Length is a signed header, so this | |
| 964 | + | // fails SigV4 at S3 with a far less legible error if it is sent. | |
| 965 | + | let wrong_length = minted_part_rejection(1, -1) | |
| 966 | + | .await | |
| 967 | + | .expect("a URL signed for one byte less must not be used"); | |
| 968 | + | assert!( | |
| 969 | + | wrong_length.contains("part geometry mismatch"), | |
| 970 | + | "wrong content_length: {wrong_length}" | |
| 971 | + | ); | |
| 972 | + | } | |
| 973 | + | ||
| 974 | + | #[tokio::test] | |
| 975 | + | async fn a_resume_that_lands_exactly_on_a_chunk_boundary_seals_the_chunk_afresh() { | |
| 976 | + | // The other resume test picks part boundaries that can never coincide with | |
| 977 | + | // a chunk boundary, which is the common case but only one side of the | |
| 978 | + | // question. Here the first part is exactly the header plus chunk 0, so the | |
| 979 | + | // resume restarts with `within == 0`: nothing of the boundary chunk is at | |
| 980 | + | // S3, and it must therefore be sealed from scratch. Demanding a recorded | |
| 981 | + | // nonce here would fail the upload outright, since no nonce was ever | |
| 982 | + | // recorded for a chunk that was never sent. | |
| 983 | + | let kit = MockKit::start().await; | |
| 984 | + | let key = synckit_client::crypto::generate_master_key(); | |
| 985 | + | let store = resume_store("aligned"); | |
| 986 | + | ||
| 987 | + | let plaintext: Vec<u8> = (0..(synckit_client::crypto::BLOB_CHUNK_SIZE * 2 + 4_321)) | |
| 988 | + | .map(|i| i as u8) | |
| 989 | + | .collect(); | |
| 990 | + | let hash = hex::encode(sha2::Sha256::digest(&plaintext)); | |
| 991 | + | let file = temp_blob("aligned-resume.bin", &plaintext); | |
| 992 | + | let cipher_len = synckit_client::crypto::blob_encrypted_len(plaintext.len()); | |
| 993 | + | // Header plus one whole sealed chunk: the one part size that puts the | |
| 994 | + | // second part's first byte on chunk 1's first byte. | |
| 995 | + | let part_size = synckit_client::crypto::blob_header_bytes(plaintext.len()).len() | |
| 996 | + | + synckit_client::crypto::sealed_blob_chunk_len(plaintext.len(), 0); | |
| 997 | + | let part_count = cipher_len.div_ceil(part_size); | |
| 998 | + | assert_eq!(part_count, 3, "three parts, resuming at the second"); | |
| 999 | + | ||
| 1000 | + | // ── First attempt: one part lands, then the transfer dies ── | |
| 1001 | + | let client = kit.authed(); | |
| 1002 | + | client.set_master_key_raw(key); | |
| 1003 | + | client.set_resume_store(Arc::clone(&store)); | |
| 1004 | + | ||
| 1005 | + | mount_session_without_put(&kit, cipher_len, part_size).await; | |
| 1006 | + | kit.put(PART_PUT_PATH) | |
| 1007 | + | .responder(DiesAfter { | |
| 1008 | + | ok: 1, | |
| 1009 | + | seen: std::sync::atomic::AtomicUsize::new(0), | |
| 1010 | + | }) | |
| 1011 | + | .await; | |
| 1012 | + | kit.post(ABORT_PATH).code(204).empty().await; | |
| 1013 | + | ||
| 1014 | + | let err = client | |
| 1015 | + | .blob_upload_streaming(&hash, &file) | |
| 1016 | + | .await | |
| 1017 | + | .unwrap_err(); | |
| 1018 | + | assert!( | |
| 1019 | + | matches!(err, SyncKitError::Server { status: 403, .. }), | |
| 1020 | + | "got {err:?}" | |
| 1021 | + | ); | |
| 1022 | + | let first: Vec<Vec<u8>> = put_bodies(&kit).await.into_iter().take(1).collect(); | |
| 1023 | + | assert_eq!( | |
| 1024 | + | first[0].len(), | |
| 1025 | + | part_size, | |
| 1026 | + | "the first part is the header and chunk 0 exactly" | |
| 1027 | + | ); | |
| 1028 | + | ||
| 1029 | + | let record = store.load(&hash).unwrap().expect("a session was recorded"); | |
| 1030 | + | assert_eq!(record.usable_parts().len(), 1); | |
| 1031 | + | assert!( | |
| 1032 | + | record.chunk(1).is_none(), | |
| 1033 | + | "chunk 1 was never sent, so no nonce for it can have been recorded" | |
| 1034 | + | ); | |
| 1035 | + | ||
| 1036 | + | // ── Second attempt ── | |
| 1037 | + | kit.reset().await; | |
| 1038 | + | mount_session(&kit, cipher_len, part_size).await; | |
| 1039 | + | kit.post(ABORT_PATH).code(204).empty().await; | |
| 1040 | + | ||
| 1041 | + | let restarted = kit.authed(); | |
| 1042 | + | restarted.set_master_key_raw(key); | |
| 1043 | + | restarted.set_resume_store(Arc::clone(&store)); | |
| 1044 | + | restarted.blob_upload_streaming(&hash, &file).await.unwrap(); | |
| 1045 | + | ||
| 1046 | + | let resumed = put_bodies(&kit).await; | |
| 1047 | + | assert_eq!( | |
| 1048 | + | resumed.len(), | |
| 1049 | + | part_count - 1, | |
| 1050 | + | "only the missing parts go up" | |
| 1051 | + | ); | |
| 1052 | + | ||
| 1053 | + | let assembled: Vec<u8> = first | |
| 1054 | + | .iter() | |
| 1055 | + | .chain(resumed.iter()) | |
| 1056 | + | .flat_map(Clone::clone) | |
| 1057 | + | .collect(); | |
| 1058 | + | assert_eq!(assembled.len(), cipher_len); | |
| 1059 | + | assert_eq!( | |
| 1060 | + | synckit_client::crypto::decrypt_blob_chunked(&assembled, &key, &hash).unwrap(), | |
| 1061 | + | plaintext, | |
| 1062 | + | "a resume aligned to a chunk boundary must still assemble" | |
| 1063 | + | ); | |
| 1064 | + | ||
| 1065 | + | std::fs::remove_file(&file).ok(); | |
| 1066 | + | } |
| @@ -363,3 +363,278 @@ | |||
| 363 | 363 | assert_eq!(a.hlc, b.hlc, "the rotation changed a row's clock"); | |
| 364 | 364 | } | |
| 365 | 365 | } | |
| 366 | + | ||
| 367 | + | // ── The re-encrypt loop pages, and the old key stops working ── | |
| 368 | + | ||
| 369 | + | /// The rows the paging test carries, split into the two server pages below. | |
| 370 | + | /// Distinct payloads per row so a re-encryption that swapped two rows, or | |
| 371 | + | /// re-sealed one row's plaintext under another's AAD, shows up as a mismatch | |
| 372 | + | /// rather than as two interchangeable blobs. | |
| 373 | + | fn paged_rows() -> [Vec<(&'static str, &'static str, serde_json::Value)>; 2] { | |
| 374 | + | [ | |
| 375 | + | vec![ | |
| 376 | + | ("tasks", "row-1", json!({"title": "first page, first row"})), | |
| 377 | + | ("tasks", "row-2", json!({"title": "first page, second row"})), | |
| 378 | + | ], | |
| 379 | + | vec![ | |
| 380 | + | ("notes", "row-3", json!({"body": "second page, first row"})), | |
| 381 | + | ("notes", "row-4", json!({"body": "second page, second row"})), | |
| 382 | + | ], | |
| 383 | + | ] | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | /// A `/keys/rotate/entries` page: the rows sealed under `old_key`, numbered from | |
| 387 | + | /// `first_seq`, with the server's `has_more` verdict. | |
| 388 | + | fn entries_page( | |
| 389 | + | old_key: &[u8; 32], | |
| 390 | + | rows: &[(&'static str, &'static str, serde_json::Value)], | |
| 391 | + | first_seq: i64, | |
| 392 | + | has_more: bool, | |
| 393 | + | ) -> serde_json::Value { | |
| 394 | + | let entries: Vec<serde_json::Value> = rows | |
| 395 | + | .iter() | |
| 396 | + | .enumerate() | |
| 397 | + | .map(|(i, (table, row_id, payload))| { | |
| 398 | + | let ctx = synckit_client::crypto::AeadContext::entry(table, row_id); | |
| 399 | + | let sealed = synckit_client::crypto::encrypt_json_aad(payload, old_key, &ctx).unwrap(); | |
| 400 | + | json!({ "seq": first_seq + i as i64, "table": table, "row_id": row_id, "data": sealed }) | |
| 401 | + | }) | |
| 402 | + | .collect(); | |
| 403 | + | json!({ "entries": entries, "has_more": has_more }) | |
| 404 | + | } | |
| 405 | + | ||
| 406 | + | /// A server that hands back two pages of work drives two re-encrypt rounds and | |
| 407 | + | /// two batch pushes, and every payload it gets back is sealed under the NEW key | |
| 408 | + | /// only. | |
| 409 | + | /// | |
| 410 | + | /// The `has_more = true` page is the point: it is what forces | |
| 411 | + | /// `reencrypt_batch`'s `Ok(!has_more)` to say "not done", so a client that | |
| 412 | + | /// stopped after the first page would leave page two readable under the old key | |
| 413 | + | /// forever. Both halves are asserted, the second page really was pulled and | |
| 414 | + | /// pushed, and the old key really is dead against all four re-encrypted rows. | |
| 415 | + | /// | |
| 416 | + | /// `pending_key` puts the rotation on the resume path so the new key is one this | |
| 417 | + | /// test knows and can decrypt with, rather than a fresh key only the client saw. | |
| 418 | + | #[tokio::test] | |
| 419 | + | async fn a_second_entries_page_is_pulled_re_encrypted_and_pushed() { | |
| 420 | + | let kit = MockKit::start().await; | |
| 421 | + | let old_key = synckit_client::crypto::generate_master_key(); | |
| 422 | + | let new_key = synckit_client::crypto::generate_master_key(); | |
| 423 | + | assert_ne!( | |
| 424 | + | old_key, new_key, | |
| 425 | + | "the two keys must differ or the old-key assertions below prove nothing" | |
| 426 | + | ); | |
| 427 | + | let [page_one, page_two] = paged_rows(); | |
| 428 | + | ||
| 429 | + | kit.get(KEYS_PATH) | |
| 430 | + | .json(json!({ | |
| 431 | + | "encrypted_key": synckit_client::crypto::wrap_master_key(&old_key, ROTATE_PW).unwrap(), | |
| 432 | + | "key_version": 1, | |
| 433 | + | "key_id": 1, | |
| 434 | + | "pending_key": { | |
| 435 | + | "encrypted_key": synckit_client::crypto::wrap_master_key(&new_key, ROTATE_PW).unwrap(), | |
| 436 | + | "key_id": 2, | |
| 437 | + | }, | |
| 438 | + | })) | |
| 439 | + | .await; | |
| 440 | + | kit.post(ROTATE_PATH).json(begin_body(4)).await; | |
| 441 | + | // Page one says has_more, page two drains. Mounted in order so the first | |
| 442 | + | // `once()` mock answers the first pull and the second answers the next. | |
| 443 | + | kit.post(ENTRIES_PATH) | |
| 444 | + | .once() | |
| 445 | + | .json(entries_page(&old_key, &page_one, 1, true)) | |
| 446 | + | .await; | |
| 447 | + | kit.post(ENTRIES_PATH) | |
| 448 | + | .once() | |
| 449 | + | .json(entries_page(&old_key, &page_two, 3, false)) | |
| 450 | + | .await; | |
| 451 | + | // Anything past those two pages would be a third round the loop must not run. | |
| 452 | + | kit.post(ENTRIES_PATH) | |
| 453 | + | .json(json!({ "entries": [], "has_more": false })) | |
| 454 | + | .await; | |
| 455 | + | kit.post(BATCH_PATH) | |
| 456 | + | .json(json!({ "updated_count": 2 })) | |
| 457 | + | .await; | |
| 458 | + | kit.post(COMPLETE_PATH).empty().await; | |
| 459 | + | ||
| 460 | + | kit.authed() | |
| 461 | + | .rotate_key(DeviceId::new(Uuid::new_v4()), ROTATE_PW) | |
| 462 | + | .await | |
| 463 | + | .expect("a two-page rotation should complete"); | |
| 464 | + | ||
| 465 | + | assert_eq!( | |
| 466 | + | kit.hits(ENTRIES_PATH).await, | |
| 467 | + | 2, | |
| 468 | + | "one pull per page: has_more on page one must run a second round, and page two must end it" | |
| 469 | + | ); | |
| 470 | + | assert_eq!( | |
| 471 | + | kit.hits(BATCH_PATH).await, | |
| 472 | + | 2, | |
| 473 | + | "each page is pushed back as its own batch" | |
| 474 | + | ); | |
| 475 | + | ||
| 476 | + | // Flatten what the client pushed and check it row by row against what it was | |
| 477 | + | // given, in seq order. | |
| 478 | + | let pushed: Vec<serde_json::Value> = kit | |
| 479 | + | .bodies("POST", BATCH_PATH) | |
| 480 | + | .await | |
| 481 | + | .iter() | |
| 482 | + | .flat_map(|body| { | |
| 483 | + | body["entries"] | |
| 484 | + | .as_array() | |
| 485 | + | .expect("a batch body carries entries") | |
| 486 | + | .clone() | |
| 487 | + | }) | |
| 488 | + | .collect(); | |
| 489 | + | let all_rows: Vec<_> = page_one.iter().chain(page_two.iter()).collect(); | |
| 490 | + | assert_eq!( | |
| 491 | + | pushed.len(), | |
| 492 | + | all_rows.len(), | |
| 493 | + | "every row from both pages must come back re-encrypted" | |
| 494 | + | ); | |
| 495 | + | ||
| 496 | + | for (i, (entry, (table, row_id, payload))) in pushed.iter().zip(&all_rows).enumerate() { | |
| 497 | + | let seq = i as i64 + 1; | |
| 498 | + | assert_eq!( | |
| 499 | + | entry["seq"].as_i64(), | |
| 500 | + | Some(seq), | |
| 501 | + | "row {row_id} came back under the wrong seq" | |
| 502 | + | ); | |
| 503 | + | let ctx = synckit_client::crypto::AeadContext::entry(table, row_id); | |
| 504 | + | let opened = synckit_client::crypto::decrypt_json_aad(&entry["data"], &new_key, &ctx) | |
| 505 | + | .unwrap_or_else(|e| panic!("row {row_id} does not open under the new key: {e}")); | |
| 506 | + | assert_eq!( | |
| 507 | + | opened, *payload, | |
| 508 | + | "row {row_id} came back holding a different payload" | |
| 509 | + | ); | |
| 510 | + | // The whole point of a rotation: the old key is retired. A no-op | |
| 511 | + | // re-encrypt would leave this opening cleanly. | |
| 512 | + | assert!( | |
| 513 | + | synckit_client::crypto::decrypt_json_aad(&entry["data"], &old_key, &ctx).is_err(), | |
| 514 | + | "row {row_id} still opens under the OLD key, so it was never re-encrypted" | |
| 515 | + | ); | |
| 516 | + | } | |
| 517 | + | } | |
| 518 | + | ||
| 519 | + | // ── The straggler round cap ── | |
| 520 | + | ||
| 521 | + | /// `MAX_ROTATION_ROUNDS` from `src/client/rotation.rs`. Private there, so it is | |
| 522 | + | /// restated here; the assertions below pin the cap exactly, which is the only | |
| 523 | + | /// way the counter's arithmetic is observable at all (a counter that never | |
| 524 | + | /// advances and a counter that advances differ nowhere except at the cap). | |
| 525 | + | const MAX_ROTATION_ROUNDS: usize = 100_000; | |
| 526 | + | ||
| 527 | + | /// A wiremock responder that counts what it served. | |
| 528 | + | /// | |
| 529 | + | /// The house `MockKit` would answer this test's routes, but its `hits()` reads | |
| 530 | + | /// wiremock's recorded-request log, and this test provokes 200,000 requests: the | |
| 531 | + | /// log would hold every one of them in memory for the duration. Counting in the | |
| 532 | + | /// responder and turning recording off keeps the test's footprint flat. | |
| 533 | + | struct Counted { | |
| 534 | + | hits: std::sync::Arc<std::sync::atomic::AtomicUsize>, | |
| 535 | + | code: u16, | |
| 536 | + | body: serde_json::Value, | |
| 537 | + | } | |
| 538 | + | ||
| 539 | + | impl wiremock::Respond for Counted { | |
| 540 | + | fn respond(&self, _request: &wiremock::Request) -> ResponseTemplate { | |
| 541 | + | self.hits.fetch_add(1, std::sync::atomic::Ordering::Relaxed); | |
| 542 | + | ResponseTemplate::new(self.code).set_body_json(self.body.clone()) | |
| 543 | + | } | |
| 544 | + | } | |
| 545 | + | ||
| 546 | + | /// A server that answers `POST /keys/rotate/complete` with 409 forever must not | |
| 547 | + | /// spin the straggler loop forever: `rotate_key` gives up at | |
| 548 | + | /// `MAX_ROTATION_ROUNDS` and returns the round-cap `Internal` error. | |
| 549 | + | /// | |
| 550 | + | /// This is the only test that can see the straggler counter at all. Every other | |
| 551 | + | /// rotation test either never gets a 409 or gets exactly one, and on those the | |
| 552 | + | /// counter's value is never read; only crossing the cap turns it into an | |
| 553 | + | /// observable. The exact-count assertion is deliberate: it pins both the | |
| 554 | + | /// increment (a counter that stalled at zero never returns) and the boundary | |
| 555 | + | /// (the cap fires on the round that reaches it, not one round later). | |
| 556 | + | #[tokio::test] | |
| 557 | + | async fn a_server_that_reports_stragglers_forever_stops_at_the_round_cap() { | |
| 558 | + | use std::sync::Arc; | |
| 559 | + | use std::sync::atomic::{AtomicUsize, Ordering}; | |
| 560 | + | ||
| 561 | + | ensure_crypto_provider(); | |
| 562 | + | let old_key = synckit_client::crypto::generate_master_key(); | |
| 563 | + | ||
| 564 | + | // Recording off: see `Counted`. | |
| 565 | + | let server = wiremock::MockServer::builder() | |
| 566 | + | .disable_request_recording() | |
| 567 | + | .start() | |
| 568 | + | .await; | |
| 569 | + | ||
| 570 | + | let completes = Arc::new(AtomicUsize::new(0)); | |
| 571 | + | let entries_pulls = Arc::new(AtomicUsize::new(0)); | |
| 572 | + | ||
| 573 | + | wiremock::Mock::given(wiremock::matchers::method("GET")) | |
| 574 | + | .and(wiremock::matchers::path(KEYS_PATH)) | |
| 575 | + | .respond_with(ResponseTemplate::new(200).set_body_json(get_keys_body(&old_key))) | |
| 576 | + | .mount(&server) | |
| 577 | + | .await; | |
| 578 | + | wiremock::Mock::given(wiremock::matchers::method("POST")) | |
| 579 | + | .and(wiremock::matchers::path(ROTATE_PATH)) | |
| 580 | + | .respond_with(ResponseTemplate::new(200).set_body_json(begin_body(0))) | |
| 581 | + | .mount(&server) | |
| 582 | + | .await; | |
| 583 | + | // Nothing left to re-encrypt, so each straggler round costs one pull and | |
| 584 | + | // returns immediately: the loop that is being bounded is the straggler loop, | |
| 585 | + | // not the re-encrypt loop inside it. | |
| 586 | + | wiremock::Mock::given(wiremock::matchers::method("POST")) | |
| 587 | + | .and(wiremock::matchers::path(ENTRIES_PATH)) | |
| 588 | + | .respond_with(Counted { | |
| 589 | + | hits: Arc::clone(&entries_pulls), | |
| 590 | + | code: 200, | |
| 591 | + | body: json!({ "entries": [], "has_more": false }), | |
| 592 | + | }) | |
| 593 | + | .mount(&server) | |
| 594 | + | .await; | |
| 595 | + | // The stall: stragglers, always, no matter how many rounds the client runs. | |
| 596 | + | wiremock::Mock::given(wiremock::matchers::method("POST")) | |
| 597 | + | .and(wiremock::matchers::path(COMPLETE_PATH)) | |
| 598 | + | .respond_with(Counted { | |
| 599 | + | hits: Arc::clone(&completes), | |
| 600 | + | code: 409, | |
| 601 | + | body: json!({ "message": "stragglers" }), | |
| 602 | + | }) | |
| 603 | + | .mount(&server) | |
| 604 | + | .await; | |
| 605 | + | ||
| 606 | + | let client = SyncKitClient::new(SyncKitConfig { | |
| 607 | + | server_url: server.uri(), | |
| 608 | + | api_key: "test-api-key".to_string(), | |
| 609 | + | }); | |
| 610 | + | let (user_id, app_id) = test_ids(); | |
| 611 | + | client.restore_session(&fresh_token(), user_id, app_id); | |
| 612 | + | ||
| 613 | + | let err = client | |
| 614 | + | .rotate_key(DeviceId::new(Uuid::new_v4()), ROTATE_PW) | |
| 615 | + | .await | |
| 616 | + | .expect_err("a server that never converges must not be waited on forever"); | |
| 617 | + | ||
| 618 | + | // The straggler cap, not the re-encrypt cap: the two share a constant and a | |
| 619 | + | // variant, so the message is what tells them apart. | |
| 620 | + | match &err { | |
| 621 | + | SyncKitError::Internal(msg) => assert!( | |
| 622 | + | msg.contains("server kept reporting stragglers past the round cap"), | |
| 623 | + | "wrong give-up path: {msg}" | |
| 624 | + | ), | |
| 625 | + | other => panic!("expected the round-cap Internal error, got {other:?}"), | |
| 626 | + | } | |
| 627 | + | ||
| 628 | + | assert_eq!( | |
| 629 | + | completes.load(Ordering::Relaxed), | |
| 630 | + | MAX_ROTATION_ROUNDS, | |
| 631 | + | "the cap must fire on the round that reaches it: one completion attempt per round, no more and no fewer" | |
| 632 | + | ); | |
| 633 | + | // Every round but the last re-ran the re-encrypt loop; the capped round | |
| 634 | + | // returns before it does. | |
| 635 | + | assert_eq!( | |
| 636 | + | entries_pulls.load(Ordering::Relaxed), | |
| 637 | + | MAX_ROTATION_ROUNDS, | |
| 638 | + | "one initial re-encrypt pass plus one per straggler round short of the cap" | |
| 639 | + | ); | |
| 640 | + | } |