Skip to main content

hibp_sync_client/
sync.rs

1use std::io;
2use std::path::{Path, PathBuf};
3use std::time::Duration;
4
5use chrono::{DateTime, SecondsFormat, Utc};
6use futures_util::StreamExt;
7use serde::{Deserialize, Serialize};
8use tokio::fs;
9
10use crate::client::Client;
11use crate::error::Error;
12use crate::wire::decode_segment_stream;
13
14pub struct Config {
15    pub server_url: http::Uri,
16    pub data_dir: PathBuf,
17    pub segments: u8,
18}
19
20pub enum Outcome {
21    UpToDate,
22    DeltaSync { changed_count: usize },
23    FullSync { file_count: usize },
24}
25
26#[derive(Serialize, Deserialize, Default)]
27struct LocalState {
28    last_updated: Option<DateTime<Utc>>,
29}
30
31#[derive(Debug, Serialize, Deserialize)]
32struct Plan {
33    server_last_updated: DateTime<Utc>,
34    since: Option<String>,
35    segments: u8,
36}
37
38#[tracing::instrument(skip_all)]
39pub async fn sync(config: &Config) -> Result<Outcome, Error> {
40    if config.segments == 0 {
41        return Err(Error::InvalidConfig("segments must be >= 1"));
42    }
43
44    let staging = config.data_dir.join(".staging");
45    let complete_marker = staging.join(".complete");
46    let plan_path = staging.join(".sync-plan.json");
47
48    if staging.exists() {
49        if complete_marker.exists() {
50            tracing::info!("staging/.complete exists - finishing interrupted commit");
51            return finish_commit(&staging, &config.data_dir).await;
52        } else if plan_path.exists() {
53            tracing::info!("resuming interrupted download");
54            let plan: Plan = serde_json::from_slice(&fs::read(&plan_path).await?)?;
55
56            let client = Client::new(&config.server_url)?;
57            let status = client.status().await?;
58
59            if status.last_updated != Some(plan.server_last_updated) {
60                tracing::warn!(
61                    "server state changed since last attempt; discarding staging and starting fresh"
62                );
63                clear_staging(&staging).await?;
64            } else {
65                fetch_missing_segments(&config.server_url, &staging, &plan).await?;
66                fs::write(&complete_marker, b"").await?;
67                return finish_commit(&staging, &config.data_dir).await;
68            }
69        } else {
70            tracing::warn!("staging exists without .sync-plan.json; discarding");
71            clear_staging(&staging).await?;
72        }
73    }
74
75    let state_path = config.data_dir.join("sync-state.json");
76    let local: LocalState = match fs::read(&state_path).await {
77        Ok(bytes) => serde_json::from_slice(&bytes)?,
78        Err(e) if e.kind() == io::ErrorKind::NotFound => LocalState::default(),
79        Err(e) => return Err(e.into()),
80    };
81    let client = Client::new(&config.server_url)?;
82    let server_last_updated = match client.status().await?.last_updated {
83        Some(t) => t,
84        None => return Ok(Outcome::UpToDate),
85    };
86
87    if Some(server_last_updated) <= local.last_updated {
88        return Ok(Outcome::UpToDate);
89    }
90
91    // Use Z-suffix format (e.g. "2026-01-01T00:00:00Z") so the value is URL-safe
92    // without encoding when used as a query parameter.
93    let since_opt: Option<String> = if local.last_updated.is_none() {
94        None
95    } else {
96        let changed = client.changed().await?;
97        if changed.prev_last_updated == local.last_updated {
98            local.last_updated.map(|t| t.to_rfc3339_opts(SecondsFormat::Secs, true))
99        } else {
100            tracing::warn!(
101                "server prev_last_updated does not match local last_updated; falling back to full sync"
102            );
103            None
104        }
105    };
106
107    fs::create_dir_all(&staging).await?;
108
109    let plan = Plan { server_last_updated, since: since_opt, segments: config.segments };
110    fs::write(&plan_path, serde_json::to_vec_pretty(&plan)?).await?;
111
112    fetch_missing_segments(&config.server_url, &staging, &plan).await?;
113    fs::write(&complete_marker, b"").await?;
114
115    finish_commit(&staging, &config.data_dir).await
116}
117
118#[tracing::instrument(skip(server_url, staging), fields(segments = plan.segments, since = plan.since.as_deref()))]
119async fn fetch_missing_segments(
120    server_url: &http::Uri,
121    staging: &Path,
122    plan: &Plan,
123) -> Result<(), Error> {
124    let segments = plan.segments;
125    let client = Client::new(server_url)?;
126
127    for seg in 0..segments {
128        if staging.join(format!(".seg.{}.done", seg)).exists() {
129            continue;
130        }
131        fetch_segment_with_retry(&client, seg, segments, plan.since.as_deref(), staging).await?;
132    }
133
134    Ok(())
135}
136
137#[tracing::instrument(skip(client, staging))]
138async fn fetch_segment_with_retry(
139    client: &Client,
140    segment: u8,
141    of: u8,
142    since: Option<&str>,
143    staging: &Path,
144) -> Result<(), Error> {
145    const MAX_RETRIES: u32 = 5;
146    let mut delay = Duration::from_millis(500);
147    let mut last_result = Ok(());
148
149    for attempt in 0..MAX_RETRIES {
150        if attempt > 0 {
151            tokio::time::sleep(delay).await;
152            delay *= 2;
153        }
154        match client.segment_stream(segment, of, since).await {
155            Ok(decoder) => {
156                let mut stream = Box::pin(decode_segment_stream(decoder));
157                while let Some(entry_res) = stream.next().await {
158                    let entry = entry_res?;
159                    let prefix_str = std::str::from_utf8(&entry.prefix)
160                        .map_err(|e| Error::Decode(format!("invalid prefix bytes: {e}")))?;
161                    fs::write(staging.join(format!("{}.bin", prefix_str)), &entry.content).await?;
162                }
163                fs::write(staging.join(format!(".seg.{}.done", segment)), b"").await?;
164                return Ok(());
165            }
166            Err(e) => last_result = Err(e),
167        }
168    }
169
170    last_result
171}
172
173#[tracing::instrument(skip_all)]
174async fn finish_commit(staging: &Path, data_dir: &Path) -> Result<Outcome, Error> {
175    let plan: Plan = serde_json::from_slice(&fs::read(staging.join(".sync-plan.json")).await?)?;
176
177    let mut entries = fs::read_dir(staging).await?;
178    let mut file_count = 0usize;
179    while let Some(entry) = entries.next_entry().await? {
180        let src = entry.path();
181        if src.extension().is_some_and(|e| e == "bin") {
182            fs::rename(&src, data_dir.join(src.file_name().unwrap())).await?;
183            file_count += 1;
184        }
185    }
186
187    let state_path = data_dir.join("sync-state.json");
188    let new_state = LocalState { last_updated: Some(plan.server_last_updated) };
189    let tmp = state_path.with_extension("json.tmp");
190    fs::write(&tmp, serde_json::to_vec_pretty(&new_state)?).await?;
191    fs::rename(&tmp, &state_path).await?;
192
193    clear_staging(staging).await?;
194
195    Ok(match plan.since {
196        Some(_) => Outcome::DeltaSync { changed_count: file_count },
197        None => Outcome::FullSync { file_count },
198    })
199}
200
201async fn clear_staging(staging: &Path) -> Result<(), Error> {
202    fs::remove_dir_all(staging).await?;
203    Ok(())
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[tokio::test]
211    async fn sync_rejects_zero_segments() {
212        let tmp = tempfile::tempdir().unwrap();
213        let cfg = Config {
214            server_url: "http://127.0.0.1:8765".parse().unwrap(),
215            data_dir: tmp.path().to_path_buf(),
216            segments: 0,
217        };
218
219        match sync(&cfg).await {
220            Err(Error::InvalidConfig(_)) => {}
221            Err(e) => panic!("expected InvalidConfig, got {e}"),
222            Ok(_) => panic!("expected error for zero segments"),
223        }
224    }
225}