Skip to main content

futu_core/localization/
remote.rs

1use std::collections::BTreeSet;
2use std::path::PathBuf;
3use std::time::Duration;
4
5use reqwest::Url;
6use serde::{Deserialize, Serialize};
7
8use super::{
9    LanguageId, LanguagePackFile, LanguagePackInstallOptions, LanguagePackKind,
10    LanguagePackManifest, MANIFEST_SCHEMA_VERSION, install_language_pack_files,
11    language_pack_sha256_hex, record_language_pack_update_error,
12};
13
14const MAX_REMOTE_PACK_BYTES: u64 = 5 * 1024 * 1024;
15
16#[derive(Debug, Clone)]
17pub struct LanguagePackRemoteUpdateOptions {
18    pub endpoint: String,
19    pub cache_root: PathBuf,
20    pub timeout_ms: u64,
21    pub lang_filter: Option<String>,
22}
23
24#[derive(Debug, Clone)]
25pub struct LanguagePackAutoUpdateOptions {
26    pub enabled: bool,
27    pub endpoint: Option<String>,
28    pub cache_root: PathBuf,
29    pub timeout_ms: u64,
30    pub lang_filter: Option<String>,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
34#[serde(rename_all = "snake_case")]
35pub enum LanguagePackAutoUpdateState {
36    DisabledByConfig,
37    EnabledNoEndpoint,
38    Updated,
39    DisabledAfterError,
40}
41
42impl LanguagePackAutoUpdateState {
43    pub const fn as_str(self) -> &'static str {
44        match self {
45            Self::DisabledByConfig => "disabled_by_config",
46            Self::EnabledNoEndpoint => "enabled_no_endpoint",
47            Self::Updated => "updated",
48            Self::DisabledAfterError => "disabled_after_error",
49        }
50    }
51}
52
53#[derive(Debug, Clone, Serialize)]
54pub struct LanguagePackAutoUpdateOutcome {
55    pub state: LanguagePackAutoUpdateState,
56    pub pack_version: Option<String>,
57    pub error: Option<String>,
58}
59
60#[derive(Debug, Deserialize)]
61struct RemoteManifest {
62    schema_version: u32,
63    pack_version: String,
64    #[serde(default = "default_remote_source")]
65    source: String,
66    #[serde(default)]
67    profile: Option<String>,
68    entries: Vec<RemoteEntry>,
69}
70
71#[derive(Debug, Deserialize)]
72struct RemoteEntry {
73    lang: String,
74    kind: LanguagePackKind,
75    path: String,
76    sha256: String,
77    #[serde(default)]
78    bytes: Option<u64>,
79}
80
81#[derive(Debug)]
82struct ValidatedRemoteEntry {
83    language: LanguageId,
84    kind: LanguagePackKind,
85    url: Url,
86    sha256: String,
87    bytes: Option<u64>,
88}
89
90pub async fn auto_update_language_pack(
91    options: LanguagePackAutoUpdateOptions,
92) -> LanguagePackAutoUpdateOutcome {
93    if !options.enabled {
94        return LanguagePackAutoUpdateOutcome {
95            state: LanguagePackAutoUpdateState::DisabledByConfig,
96            pack_version: None,
97            error: None,
98        };
99    }
100
101    let Some(endpoint) = options
102        .endpoint
103        .as_deref()
104        .map(str::trim)
105        .filter(|endpoint| !endpoint.is_empty())
106    else {
107        return LanguagePackAutoUpdateOutcome {
108            state: LanguagePackAutoUpdateState::EnabledNoEndpoint,
109            pack_version: None,
110            error: None,
111        };
112    };
113
114    match update_language_pack_from_endpoint(LanguagePackRemoteUpdateOptions {
115        endpoint: endpoint.to_string(),
116        cache_root: options.cache_root.clone(),
117        timeout_ms: options.timeout_ms,
118        lang_filter: options.lang_filter,
119    })
120    .await
121    {
122        Ok(manifest) => LanguagePackAutoUpdateOutcome {
123            state: LanguagePackAutoUpdateState::Updated,
124            pack_version: Some(manifest.pack_version),
125            error: None,
126        },
127        Err(error) => {
128            let error = format!("language-pack auto update failed: {error}");
129            let _ = record_language_pack_update_error(&options.cache_root, &error);
130            LanguagePackAutoUpdateOutcome {
131                state: LanguagePackAutoUpdateState::DisabledAfterError,
132                pack_version: None,
133                error: Some(error),
134            }
135        }
136    }
137}
138
139pub async fn update_language_pack_from_endpoint(
140    options: LanguagePackRemoteUpdateOptions,
141) -> Result<LanguagePackManifest, String> {
142    let manifest_url = language_pack_manifest_url(&options.endpoint)?;
143    ensure_allowed_language_pack_remote_scheme(&manifest_url)?;
144    let langs = parse_lang_filter(options.lang_filter.as_deref())?;
145    let client = crate::http_client::webpki_builder()
146        .redirect(reqwest::redirect::Policy::none())
147        .timeout(Duration::from_millis(options.timeout_ms))
148        .build()
149        .map_err(|error| format!("build language-pack HTTP client: {error}"))?;
150    let manifest = fetch_remote_manifest(&client, manifest_url.clone()).await?;
151    let entries = validate_remote_entries(&manifest_url, &manifest, langs.as_deref())?;
152    let files = fetch_remote_files(&client, entries).await?;
153    install_language_pack_files(
154        files,
155        LanguagePackInstallOptions {
156            cache_root: options.cache_root,
157            pack_version: manifest.pack_version,
158            source: manifest.source,
159            profile: manifest.profile,
160        },
161    )
162}
163
164pub fn language_pack_manifest_url(endpoint: &str) -> Result<Url, String> {
165    let mut url = Url::parse(endpoint)
166        .map_err(|error| format!("parse language-pack endpoint URL: {error}"))?;
167    if url.path().is_empty() || url.path().ends_with('/') {
168        url = url
169            .join("manifest.json")
170            .map_err(|error| format!("append manifest.json to endpoint URL: {error}"))?;
171    }
172    Ok(url)
173}
174
175pub fn ensure_allowed_language_pack_remote_scheme(url: &Url) -> Result<(), String> {
176    if url.scheme() == "https" {
177        return Ok(());
178    }
179    let host = url.host_str().unwrap_or_default();
180    if url.scheme() == "http" && matches!(host, "127.0.0.1" | "localhost" | "::1") {
181        return Ok(());
182    }
183    Err(
184        "language-pack update requires HTTPS endpoint; http is allowed only for localhost tests"
185            .to_string(),
186    )
187}
188
189async fn fetch_remote_manifest(
190    client: &reqwest::Client,
191    url: Url,
192) -> Result<RemoteManifest, String> {
193    let resp = client
194        .get(url.clone())
195        .send()
196        .await
197        .map_err(|error| format!("fetch language-pack manifest {url}: {error}"))?;
198    let status = resp.status();
199    if !status.is_success() {
200        return Err(format!("language-pack manifest HTTP status {status}"));
201    }
202    let bytes = resp
203        .bytes()
204        .await
205        .map_err(|error| format!("read language-pack manifest {url}: {error}"))?;
206    let manifest: RemoteManifest = serde_json::from_slice(&bytes)
207        .map_err(|error| format!("parse language-pack manifest JSON: {error}"))?;
208    if manifest.schema_version != MANIFEST_SCHEMA_VERSION {
209        return Err(format!(
210            "unsupported language-pack remote manifest schema_version {} (supported={MANIFEST_SCHEMA_VERSION})",
211            manifest.schema_version
212        ));
213    }
214    Ok(manifest)
215}
216
217fn canonical_url_matches(resolved: &Url, canonical: &Url) -> bool {
218    let resolved_segments = resolved
219        .path_segments()
220        .map(|segments| segments.collect::<Vec<_>>());
221    let canonical_segments = canonical
222        .path_segments()
223        .map(|segments| segments.collect::<Vec<_>>());
224    resolved.scheme() == canonical.scheme()
225        && resolved.host_str() == canonical.host_str()
226        && resolved.port_or_known_default() == canonical.port_or_known_default()
227        && resolved_segments == canonical_segments
228        && resolved.query().is_none()
229        && resolved.fragment().is_none()
230}
231
232fn validate_remote_entries(
233    manifest_url: &Url,
234    manifest: &RemoteManifest,
235    lang_filter: Option<&[LanguageId]>,
236) -> Result<Vec<ValidatedRemoteEntry>, String> {
237    let mut seen = BTreeSet::new();
238    let mut validated = Vec::new();
239
240    for entry in &manifest.entries {
241        let Some(language) = LanguageId::from_cache_name(&entry.lang) else {
242            continue;
243        };
244        if let Some(filter) = lang_filter
245            && !filter.contains(&language)
246        {
247            continue;
248        }
249
250        let canonical_path = format!("{}/{}", language.cache_name(), entry.kind.file_name());
251        if entry.path != canonical_path {
252            return Err(format!(
253                "non-canonical language-pack entry path `{}` for {} {}; expected `{canonical_path}`",
254                entry.path,
255                language.cache_name(),
256                entry.kind.as_str()
257            ));
258        }
259
260        if !seen.insert((language, entry.kind)) {
261            return Err(format!(
262                "duplicate language-pack entry {} {}",
263                language.cache_name(),
264                entry.kind.as_str()
265            ));
266        }
267
268        let canonical_url = manifest_url.join(&canonical_path).map_err(|error| {
269            format!("resolve canonical language-pack entry path {canonical_path}: {error}")
270        })?;
271        let resolved_url = manifest_url
272            .join(&entry.path)
273            .map_err(|error| format!("resolve language-pack entry path {}: {error}", entry.path))?;
274        if !canonical_url_matches(&resolved_url, &canonical_url) {
275            return Err(format!(
276                "language-pack entry URL violates canonical manifest directory boundary: {}",
277                entry.path
278            ));
279        }
280
281        validated.push(ValidatedRemoteEntry {
282            language,
283            kind: entry.kind,
284            url: resolved_url,
285            sha256: entry.sha256.clone(),
286            bytes: entry.bytes,
287        });
288    }
289
290    if validated.is_empty() {
291        return Err("language-pack manifest yielded no supported entries".to_string());
292    }
293    Ok(validated)
294}
295
296async fn fetch_remote_files(
297    client: &reqwest::Client,
298    entries: Vec<ValidatedRemoteEntry>,
299) -> Result<Vec<LanguagePackFile>, String> {
300    let mut files = Vec::with_capacity(entries.len());
301    for entry in entries {
302        let bytes = fetch_remote_file(client, entry.url, entry.bytes).await?;
303        let actual = language_pack_sha256_hex(&bytes);
304        if actual != entry.sha256 {
305            return Err(format!(
306                "language-pack checksum mismatch for {} {}: expected {}, got {}",
307                entry.language.cache_name(),
308                entry.kind.as_str(),
309                entry.sha256,
310                actual
311            ));
312        }
313        files.push(LanguagePackFile {
314            language: entry.language,
315            kind: entry.kind,
316            bytes,
317        });
318    }
319    Ok(files)
320}
321
322async fn fetch_remote_file(
323    client: &reqwest::Client,
324    url: Url,
325    expected_bytes: Option<u64>,
326) -> Result<Vec<u8>, String> {
327    let resp = client
328        .get(url.clone())
329        .send()
330        .await
331        .map_err(|error| format!("fetch language-pack file {url}: {error}"))?;
332    let status = resp.status();
333    if !status.is_success() {
334        return Err(format!("language-pack file {url} HTTP status {status}"));
335    }
336    if let Some(len) = resp.content_length()
337        && len > MAX_REMOTE_PACK_BYTES
338    {
339        return Err(format!("language-pack file {url} too large: {len} bytes"));
340    }
341    let bytes = resp
342        .bytes()
343        .await
344        .map_err(|error| format!("read language-pack file {url}: {error}"))?;
345    if bytes.len() as u64 > MAX_REMOTE_PACK_BYTES {
346        return Err(format!(
347            "language-pack file {url} too large after download: {} bytes",
348            bytes.len()
349        ));
350    }
351    if let Some(expected) = expected_bytes
352        && bytes.len() as u64 != expected
353    {
354        return Err(format!(
355            "language-pack file {url} byte size mismatch: expected {expected}, got {}",
356            bytes.len()
357        ));
358    }
359    Ok(bytes.to_vec())
360}
361
362fn parse_lang_filter(value: Option<&str>) -> Result<Option<Vec<LanguageId>>, String> {
363    let Some(value) = value else {
364        return Ok(None);
365    };
366    let mut langs = Vec::new();
367    for token in value
368        .split(',')
369        .map(str::trim)
370        .filter(|token| !token.is_empty())
371    {
372        let Some(language) = LanguageId::from_cache_name(token) else {
373            return Err(format!(
374                "unsupported language `{token}`; supported values: en, zh_cn"
375            ));
376        };
377        langs.push(language);
378    }
379    langs.sort();
380    langs.dedup();
381    Ok(Some(langs))
382}
383
384fn default_remote_source() -> String {
385    "remote".to_string()
386}