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