Skip to main content

max / makenotwork

synckit: honor retry contract + overflow-guard pricing, non_exhaustive API (ultra-fuzz Run #1 API) - subscription: route all six methods through retry_request_json so a transient 5xx retries like every other call (H1); saturating pricing math so hostile server numbers can't panic/wrap (M1/M2); rename from_str -> from_wire (lenient, infallible) and log unknown interval tags. - error: add InvalidArgument (used for negative blob size_bytes, was Internal); mark SyncKitError #[non_exhaustive]. - mark response-only types (Device, SyncStatus, SubscriptionStatus, AppPricing, AccountInfo, PriceQuote, CheckoutResponse) #[non_exhaustive] so a future field isn't a synchronized breaking bump across consumers. - add subscription.rs tests (were none). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-19 22:34 UTC
Commit: 98c57764672a6f93245cee3007e8f604393cbb00
Parent: 5ce8d33
5 files changed, +150 insertions, -68 deletions
@@ -21,10 +21,11 @@ src/
21 21 crypto.rs Encryption engine. Key derivation (Argon2id), key wrapping
22 22 (XChaCha20-Poly1305), per-entry encrypt/decrypt, blob
23 23 encrypt/decrypt, ZeroizeOnDrop guard.
24 - error.rs SyncKitError enum (thiserror). 12 variants: Http, Server,
25 - Json, NoMasterKey, DecryptionFailed, InvalidEnvelope, Crypto,
26 - Base64, NotAuthenticated, TokenExpired, Internal, and
27 - Keychain (feature-gated behind `keychain`).
24 + error.rs SyncKitError enum (thiserror, #[non_exhaustive]): Http, Server,
25 + Json, NoMasterKey, DecryptionFailed, IntegrityFailed,
26 + InvalidEnvelope, Crypto, Base64, NotAuthenticated, TokenExpired,
27 + InvalidArgument, Internal, and Keychain (feature-gated behind
28 + `keychain`).
28 29 keystore.rs OS keychain integration (macOS Keychain, Linux secret-service,
29 30 Windows Credential Manager) via the `keyring` crate.
30 31 Feature-gated behind `keychain` (default on). No-op stubs
@@ -27,7 +27,7 @@ impl SyncKitClient {
27 27 size_bytes: i64,
28 28 ) -> Result<BlobUploadUrlResponse> {
29 29 if size_bytes < 0 {
30 - return Err(crate::error::SyncKitError::Internal(
30 + return Err(SyncKitError::InvalidArgument(
31 31 "size_bytes must be non-negative".into(),
32 32 ));
33 33 }
@@ -80,7 +80,7 @@ impl SyncKitClient {
80 80 #[instrument(skip(self))]
81 81 pub async fn blob_confirm(&self, hash: &str, size_bytes: i64) -> Result<()> {
82 82 if size_bytes < 0 {
83 - return Err(crate::error::SyncKitError::Internal(
83 + return Err(SyncKitError::InvalidArgument(
84 84 "size_bytes must be non-negative".into(),
85 85 ));
86 86 }
@@ -7,6 +7,7 @@
7 7 //! to get the live number, then call [`SyncKitClient::create_subscription_checkout`]
8 8 //! once the user clicks subscribe.
9 9
10 + use bytes::Bytes;
10 11 use serde::{Deserialize, Serialize};
11 12
12 13 use crate::error::Result;
@@ -15,6 +16,7 @@ use super::helpers::check_response;
15 16
16 17 /// Subscription status as returned by the server.
17 18 #[derive(Debug, Clone, Default, Serialize, Deserialize)]
19 + #[non_exhaustive]
18 20 pub struct SubscriptionStatus {
19 21 /// Whether the user has an active sync subscription for this app.
20 22 pub active: bool,
@@ -40,6 +42,7 @@ pub struct SubscriptionStatus {
40 42 /// locally as a slider moves; the server enforces the same formula at
41 43 /// checkout so client-computed prices are advisory only.
42 44 #[derive(Debug, Clone, Serialize, Deserialize)]
45 + #[non_exhaustive]
43 46 pub struct AppPricing {
44 47 pub app_name: String,
45 48 /// Minimum charge in cents (applies to both monthly and annual).
@@ -57,21 +60,27 @@ impl AppPricing {
57 60 /// Compute the price in cents for a given cap and interval. Mirrors the
58 61 /// server-side formula so client-side previews match what the user will
59 62 /// actually be charged.
63 + ///
64 + /// All arithmetic saturates: the inputs are server-supplied `i64` values, and
65 + /// a hostile or buggy server returning huge rates must not panic the client
66 + /// (debug overflow) or wrap to a negative price (release). The server is
67 + /// authoritative at checkout, so this is an advisory preview regardless.
60 68 pub fn quote_cents(&self, cap_bytes: i64, interval: BillingInterval) -> i64 {
61 69 let cap_bytes = cap_bytes.clamp(self.min_cap_bytes, self.max_cap_bytes);
62 70 let gib = cap_bytes_to_gib_ceil(cap_bytes);
63 - let storage_monthly = (gib * self.per_gb_tenths_of_cent_per_month + 9) / 10;
71 + let storage_monthly =
72 + gib.saturating_mul(self.per_gb_tenths_of_cent_per_month).saturating_add(9) / 10;
64 73 let monthly = storage_monthly.max(self.min_charge_cents);
65 74 match interval {
66 75 BillingInterval::Monthly => monthly,
67 - BillingInterval::Annual => monthly * self.annual_multiplier,
76 + BillingInterval::Annual => monthly.saturating_mul(self.annual_multiplier),
68 77 }
69 78 }
70 79 }
71 80
72 81 fn cap_bytes_to_gib_ceil(cap_bytes: i64) -> i64 {
73 82 const GIB: i64 = 1024 * 1024 * 1024;
74 - (cap_bytes + GIB - 1) / GIB
83 + cap_bytes.saturating_add(GIB - 1) / GIB
75 84 }
76 85
77 86 /// Billing interval for SyncKit subscriptions.
@@ -89,18 +98,26 @@ impl BillingInterval {
89 98 }
90 99 }
91 100
92 - /// Parse from the wire string. Falls back to `Monthly` for unknown
93 - /// values rather than erroring — defensive against forward-compat tags.
94 - pub fn from_str(s: &str) -> Self {
101 + /// Parse from the server wire string. Falls back to `Monthly` for unknown
102 + /// values rather than erroring — defensive against a future interval tag.
103 + /// Named `from_wire` (not `from_str`) because it is infallible and lenient,
104 + /// unlike `std::str::FromStr`.
105 + pub fn from_wire(s: &str) -> Self {
95 106 match s {
96 107 "annual" => Self::Annual,
97 - _ => Self::Monthly,
108 + other => {
109 + if !other.is_empty() && other != "monthly" {
110 + tracing::debug!(tag = %other, "unknown billing interval tag; defaulting to monthly");
111 + }
112 + Self::Monthly
113 + }
98 114 }
99 115 }
100 116 }
101 117
102 118 /// Response from creating a checkout session.
103 119 #[derive(Debug, Deserialize)]
120 + #[non_exhaustive]
104 121 pub struct CheckoutResponse {
105 122 /// URL to redirect the user to for Stripe Checkout.
106 123 pub checkout_url: String,
@@ -108,6 +125,7 @@ pub struct CheckoutResponse {
108 125
109 126 /// Account identifiers for the authenticated MNW user.
110 127 #[derive(Debug, Clone, Serialize, Deserialize)]
128 + #[non_exhaustive]
111 129 pub struct AccountInfo {
112 130 /// The authenticated user's email address.
113 131 pub email: String,
@@ -116,6 +134,7 @@ pub struct AccountInfo {
116 134 }
117 135
118 136 #[derive(Debug, Serialize, Deserialize)]
137 + #[non_exhaustive]
119 138 pub struct PriceQuote {
120 139 pub cap_bytes: i64,
121 140 pub interval: String,
@@ -149,13 +168,18 @@ impl SyncKitClient {
149 168 /// app's API key is sent in the body. Safe to call before login so the
150 169 /// UI can show pricing on the marketing/onboarding view.
151 170 pub async fn get_app_pricing(&self) -> Result<AppPricing> {
152 - let resp = self.http
153 - .post(&self.endpoints.app_pricing)
154 - .json(&AppPricingRequest { api_key: &self.config.api_key })
155 - .send()
156 - .await?;
157 - let resp = check_response(resp).await?;
158 - Ok(resp.json().await?)
171 + let body = Bytes::from(serde_json::to_vec(&AppPricingRequest {
172 + api_key: &self.config.api_key,
173 + })?);
174 + self.retry_request_json(|| {
175 + let req = self
176 + .http
177 + .post(&self.endpoints.app_pricing)
178 + .header("content-type", "application/json")
179 + .body(body.clone());
180 + async move { check_response(req.send().await?).await }
181 + })
182 + .await
159 183 }
160 184
161 185 /// Server-side price quote for a (cap, interval). Use this to confirm
@@ -163,14 +187,20 @@ impl SyncKitClient {
163 187 /// authoritative.
164 188 pub async fn quote_price(&self, cap_bytes: i64, interval: BillingInterval) -> Result<PriceQuote> {
165 189 let token = self.require_token()?;
166 - let resp = self.http
167 - .post(&self.endpoints.subscription_quote)
168 - .bearer_auth(token.as_str())
169 - .json(&QuoteRequest { cap_bytes, interval: interval.as_str() })
170 - .send()
171 - .await?;
172 - let resp = check_response(resp).await?;
173 - Ok(resp.json().await?)
190 + let body = Bytes::from(serde_json::to_vec(&QuoteRequest {
191 + cap_bytes,
192 + interval: interval.as_str(),
193 + })?);
194 + self.retry_request_json(|| {
195 + let req = self
196 + .http
197 + .post(&self.endpoints.subscription_quote)
198 + .bearer_auth(token.as_str())
199 + .header("content-type", "application/json")
200 + .body(body.clone());
201 + async move { check_response(req.send().await?).await }
202 + })
203 + .await
174 204 }
175 205
176 206 /// Fetch the authenticated user's email and username.
@@ -178,16 +208,11 @@ impl SyncKitClient {
178 208 /// Used by apps to display "logged in as ..." in their cloud-sync UI.
179 209 pub async fn get_account_info(&self) -> Result<AccountInfo> {
180 210 let token = self.require_token()?;
181 -
182 - let resp = self.http
183 - .get(&self.endpoints.account)
184 - .bearer_auth(token.as_str())
185 - .send()
186 - .await?;
187 -
188 - let resp = check_response(resp).await?;
189 - let info: AccountInfo = resp.json().await?;
190 - Ok(info)
211 + self.retry_request_json(|| {
212 + let req = self.http.get(&self.endpoints.account).bearer_auth(token.as_str());
213 + async move { check_response(req.send().await?).await }
214 + })
215 + .await
191 216 }
192 217
193 218 /// Check the subscription status for this authenticated user + app.
@@ -196,16 +221,11 @@ impl SyncKitClient {
196 221 /// or `active: false` if the user needs to subscribe.
197 222 pub async fn get_subscription_status(&self) -> Result<SubscriptionStatus> {
198 223 let token = self.require_token()?;
199 -
200 - let resp = self.http
201 - .get(&self.endpoints.subscription)
202 - .bearer_auth(token.as_str())
203 - .send()
204 - .await?;
205 -
206 - let resp = check_response(resp).await?;
207 - let status: SubscriptionStatus = resp.json().await?;
208 - Ok(status)
224 + self.retry_request_json(|| {
225 + let req = self.http.get(&self.endpoints.subscription).bearer_auth(token.as_str());
226 + async move { check_response(req.send().await?).await }
227 + })
228 + .await
209 229 }
210 230
211 231 /// Create a Stripe Checkout session for subscribing to cloud sync at the
@@ -217,17 +237,20 @@ impl SyncKitClient {
217 237 interval: BillingInterval,
218 238 ) -> Result<CheckoutResponse> {
219 239 let token = self.require_token()?;
220 -
221 - let resp = self.http
222 - .post(&self.endpoints.subscription_checkout)
223 - .bearer_auth(token.as_str())
224 - .json(&CheckoutRequest { cap_bytes, interval: interval.as_str() })
225 - .send()
226 - .await?;
227 -
228 - let resp = check_response(resp).await?;
229 - let checkout: CheckoutResponse = resp.json().await?;
230 - Ok(checkout)
240 + let body = Bytes::from(serde_json::to_vec(&CheckoutRequest {
241 + cap_bytes,
242 + interval: interval.as_str(),
243 + })?);
244 + self.retry_request_json(|| {
245 + let req = self
246 + .http
247 + .post(&self.endpoints.subscription_checkout)
248 + .bearer_auth(token.as_str())
249 + .header("content-type", "application/json")
250 + .body(body.clone());
251 + async move { check_response(req.send().await?).await }
252 + })
253 + .await
231 254 }
232 255
233 256 /// Queue a storage-cap change that takes effect at the next billing
@@ -235,16 +258,62 @@ impl SyncKitClient {
235 258 /// `pending_storage_limit_bytes`.
236 259 pub async fn queue_storage_cap_change(&self, cap_bytes: i64) -> Result<SubscriptionStatus> {
237 260 let token = self.require_token()?;
261 + let body = Bytes::from(serde_json::to_vec(&CapChangeRequest { cap_bytes })?);
262 + self.retry_request_json(|| {
263 + let req = self
264 + .http
265 + .post(&self.endpoints.subscription_storage_cap)
266 + .bearer_auth(token.as_str())
267 + .header("content-type", "application/json")
268 + .body(body.clone());
269 + async move { check_response(req.send().await?).await }
270 + })
271 + .await
272 + }
273 + }
274 +
275 + #[cfg(test)]
276 + mod tests {
277 + use super::*;
278 +
279 + fn pricing(min_charge: i64, per_gb_tenths: i64, annual_mult: i64) -> AppPricing {
280 + AppPricing {
281 + app_name: "test".into(),
282 + min_charge_cents: min_charge,
283 + per_gb_tenths_of_cent_per_month: per_gb_tenths,
284 + annual_multiplier: annual_mult,
285 + min_cap_bytes: 0,
286 + max_cap_bytes: i64::MAX,
287 + }
288 + }
289 +
290 + #[test]
291 + fn quote_cents_applies_floor_and_annual_multiplier() {
292 + // 10 GiB at 50 tenths-of-a-cent/GiB/mo = 500 tenths => 50 cents, above the
293 + // 16-cent floor; annual = 10x.
294 + let p = pricing(16, 50, 10);
295 + let ten_gib = 10 * 1024 * 1024 * 1024;
296 + assert_eq!(p.quote_cents(ten_gib, BillingInterval::Monthly), 50);
297 + assert_eq!(p.quote_cents(ten_gib, BillingInterval::Annual), 500);
298 + // Tiny cap falls back to the minimum charge.
299 + assert_eq!(p.quote_cents(1, BillingInterval::Monthly), 16);
300 + }
238 301
239 - let resp = self.http
240 - .post(&self.endpoints.subscription_storage_cap)
241 - .bearer_auth(token.as_str())
242 - .json(&CapChangeRequest { cap_bytes })
243 - .send()
244 - .await?;
302 + #[test]
303 + fn quote_cents_saturates_on_hostile_server_numbers() {
304 + // A server returning an absurd per-GiB rate and multiplier must not panic
305 + // (debug) or wrap negative (release) — it saturates.
306 + let p = pricing(0, i64::MAX, i64::MAX);
307 + let big = p.quote_cents(i64::MAX, BillingInterval::Annual);
308 + assert!(big >= 0, "price must never wrap negative, got {big}");
309 + assert_eq!(big, i64::MAX);
310 + }
245 311
246 - let resp = check_response(resp).await?;
247 - let status: SubscriptionStatus = resp.json().await?;
248 - Ok(status)
312 + #[test]
313 + fn billing_interval_from_wire_defaults_to_monthly() {
314 + assert_eq!(BillingInterval::from_wire("annual"), BillingInterval::Annual);
315 + assert_eq!(BillingInterval::from_wire("monthly"), BillingInterval::Monthly);
316 + assert_eq!(BillingInterval::from_wire("weekly"), BillingInterval::Monthly);
317 + assert_eq!(BillingInterval::from_wire(""), BillingInterval::Monthly);
249 318 }
250 319 }
@@ -3,7 +3,12 @@
3 3 use thiserror::Error;
4 4
5 5 /// All errors that can occur in the SyncKit client.
6 + ///
7 + /// `#[non_exhaustive]`: new variants may be added in a minor release, so callers
8 + /// must include a wildcard arm when matching. This keeps a new error kind from
9 + /// being a breaking change across the consuming apps.
6 10 #[derive(Debug, Error)]
11 + #[non_exhaustive]
7 12 pub enum SyncKitError {
8 13 /// Network-level failure: connection refused, timeout, DNS resolution, TLS handshake.
9 14 #[error("HTTP request failed: {0}")]
@@ -63,6 +68,11 @@ pub enum SyncKitError {
63 68 #[error("Token expired — re-authenticate to continue syncing")]
64 69 TokenExpired,
65 70
71 + /// A caller passed an invalid argument (e.g. a negative size). Distinct from
72 + /// [`Self::Internal`], which signals an unexpected internal state.
73 + #[error("Invalid argument: {0}")]
74 + InvalidArgument(String),
75 +
66 76 /// Internal error that should not occur in normal operation.
67 77 #[error("Internal error: {0}")]
68 78 Internal(String),
@@ -165,6 +165,7 @@ pub(crate) struct RegisterDeviceRequest {
165 165 }
166 166
167 167 #[derive(Debug, Clone, Deserialize, Serialize)]
168 + #[non_exhaustive]
168 169 pub struct Device {
169 170 /// Server-assigned device UUID.
170 171 pub id: Uuid,
@@ -437,6 +438,7 @@ pub(crate) struct OAuthTokenResponse {
437 438 // ── Status ──
438 439
439 440 #[derive(Debug, Deserialize)]
441 + #[non_exhaustive]
440 442 pub struct SyncStatus {
441 443 /// Number of changelog entries on the server for this app/user.
442 444 pub total_changes: i64,