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}