Skip to main content

zeroclaw_runtime/rpc/
locales.rs

1//! Locale RPC methods: serve the in-memory locale registry and fetch
2//! translated FTL catalogues from upstream.
3//!
4//! `locales/list` returns the build's embedded `locales.toml` registry — no
5//! file read, no network. `locales/fetch` downloads catalogue bytes from the
6//! upstream repository (URL built entirely from constants plus the validated
7//! locale/catalog) and returns them so the client writes into its own config
8//! dir. The locale is validated against the embedded registry and the catalog
9//! against the fixed set, so neither can drive a request to an arbitrary host
10//! or path.
11//!
12//! Every emission runs inside an attribution span (`channel = "rpc"`, the
13//! caller's `tui_id` as `session_key`) so locale-fetch events are attributed to
14//! the originating TUI session, never orphaned.
15
16use ::zeroclaw_log::Instrument as _;
17use zeroclaw_api::jsonrpc::error_codes::*;
18use zeroclaw_api::jsonrpc::{
19    FetchedCatalog, JsonRpcError, LocaleOption, LocalesFetchRequest, LocalesFetchResponse,
20    LocalesListResponse,
21};
22
23fn rpc_err(code: i32, msg: impl Into<String>) -> JsonRpcError {
24    JsonRpcError {
25        code,
26        message: msg.into(),
27        data: None,
28    }
29}
30
31/// Attribution span keyed to the calling TUI session.
32fn locale_span(tui_id: Option<&str>) -> ::zeroclaw_log::Span {
33    ::zeroclaw_log::info_span!(
34        target: "zeroclaw_log_internal_scope",
35        "zeroclaw_scope",
36        session_key = %tui_id.unwrap_or("rpc"),
37        channel = "rpc",
38    )
39}
40
41/// Handle `locales/list` — the embedded locale registry. No network.
42pub fn handle_locales_list(tui_id: Option<&str>) -> Result<serde_json::Value, JsonRpcError> {
43    let span = locale_span(tui_id);
44    let _guard = span.enter();
45    let locales: Vec<LocaleOption> = crate::i18n::available_locales()
46        .iter()
47        .map(|o| LocaleOption {
48            code: o.code.clone(),
49            label: o.label.clone(),
50        })
51        .collect();
52    ::zeroclaw_log::record!(
53        DEBUG,
54        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
55            .with_attrs(::serde_json::json!({ "count": locales.len() })),
56        "locales/list served from embedded registry"
57    );
58    serde_json::to_value(LocalesListResponse { locales })
59        .map_err(|e| rpc_err(INTERNAL_ERROR, e.to_string()))
60}
61
62/// Handle `locales/fetch` — download FTL catalogue bytes from upstream.
63pub async fn handle_locales_fetch(
64    params: &serde_json::Value,
65    tui_id: Option<&str>,
66) -> Result<serde_json::Value, JsonRpcError> {
67    let span = locale_span(tui_id);
68    async move {
69        let req: LocalesFetchRequest = serde_json::from_value(params.clone())
70            .map_err(|e| rpc_err(INVALID_PARAMS, e.to_string()))?;
71
72        // Validate locale against the embedded registry + a syntactic allowlist.
73        let locale = match validate_locale(&req.locale) {
74            Ok(l) => l,
75            Err(e) => {
76                ::zeroclaw_log::record!(
77                    WARN,
78                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
79                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
80                        .with_attrs(::serde_json::json!({ "locale": req.locale })),
81                    "locales/fetch rejected: locale not in registry or invalid shape"
82                );
83                return Err(e);
84            }
85        };
86
87        // Select catalogues by name from the fixed table (never a caller path).
88        let selected: Vec<&(&str, &str, &str)> = if req.catalog.is_empty() {
89            zeroclaw_config::schema::FTL_CATALOGS.iter().collect()
90        } else {
91            let mut out = Vec::new();
92            for name in &req.catalog {
93                match zeroclaw_config::schema::FTL_CATALOGS
94                    .iter()
95                    .find(|(n, _, _)| n == name)
96                {
97                    Some(entry) => out.push(entry),
98                    None => {
99                        ::zeroclaw_log::record!(
100                            WARN,
101                            ::zeroclaw_log::Event::new(
102                                module_path!(),
103                                ::zeroclaw_log::Action::Reject
104                            )
105                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
106                            .with_attrs(::serde_json::json!({ "catalog": name })),
107                            "locales/fetch rejected: unknown catalog"
108                        );
109                        return Err(rpc_err(INVALID_PARAMS, format!("unknown catalog '{name}'")));
110                    }
111                }
112            }
113            out
114        };
115
116        ::zeroclaw_log::record!(
117            INFO,
118            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
119                ::serde_json::json!({
120                    "locale": locale,
121                    "catalogs": selected.iter().map(|(n, _, _)| *n).collect::<Vec<_>>(),
122                })
123            ),
124            "locales/fetch started"
125        );
126
127        let version = env!("CARGO_PKG_VERSION");
128        let refs = [format!("v{version}"), "master".to_string()];
129        let client = reqwest::Client::new();
130
131        let mut catalogs = Vec::new();
132        let mut skipped = Vec::new();
133        for (name, path_tmpl, out_name) in selected {
134            let repo_path = path_tmpl.replace("{locale}", &locale);
135            let mut content: Option<String> = None;
136            for git_ref in &refs {
137                let url = format!(
138                    "https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/{git_ref}/{repo_path}"
139                );
140                let resp = match client.get(&url).send().await {
141                    Ok(r) => r,
142                    Err(e) => {
143                        ::zeroclaw_log::record!(
144                            WARN,
145                            ::zeroclaw_log::Event::new(
146                                module_path!(),
147                                ::zeroclaw_log::Action::Note
148                            )
149                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
150                            .with_attrs(::serde_json::json!({ "catalog": name, "url": url })),
151                            "locales/fetch network error"
152                        );
153                        return Err(rpc_err(INTERNAL_ERROR, e.to_string()));
154                    }
155                };
156                if resp.status().is_success() {
157                    content = Some(
158                        resp.text()
159                            .await
160                            .map_err(|e| rpc_err(INTERNAL_ERROR, e.to_string()))?,
161                    );
162                    break;
163                }
164            }
165            match content {
166                Some(c) => catalogs.push(FetchedCatalog {
167                    name: (*name).to_string(),
168                    filename: (*out_name).to_string(),
169                    content: c,
170                }),
171                None => {
172                    ::zeroclaw_log::record!(
173                        DEBUG,
174                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
175                            .with_attrs(::serde_json::json!({ "catalog": name, "locale": locale })),
176                        "locales/fetch: catalogue not on upstream, skipped"
177                    );
178                    skipped.push((*name).to_string());
179                }
180            }
181        }
182
183        ::zeroclaw_log::record!(
184            INFO,
185            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
186                ::serde_json::json!({
187                    "locale": locale,
188                    "fetched": catalogs.len(),
189                    "skipped": skipped,
190                })
191            ),
192            "locales/fetch completed"
193        );
194
195        serde_json::to_value(LocalesFetchResponse {
196            locale,
197            catalogs,
198            skipped,
199        })
200        .map_err(|e| rpc_err(INTERNAL_ERROR, e.to_string()))
201    }
202    .instrument(span)
203    .await
204}
205
206/// Validate `locale` against the embedded registry and a strict syntactic
207/// allowlist (no slashes/dots), defeating path traversal and host injection.
208fn validate_locale(locale: &str) -> Result<String, JsonRpcError> {
209    let ok_shape = !locale.is_empty()
210        && locale.len() <= 16
211        && locale
212            .chars()
213            .all(|c| c.is_ascii_alphanumeric() || c == '-');
214    if !ok_shape {
215        return Err(rpc_err(
216            INVALID_PARAMS,
217            format!("invalid locale '{locale}'"),
218        ));
219    }
220    if !crate::i18n::available_locales()
221        .iter()
222        .any(|o| o.code == locale)
223    {
224        return Err(rpc_err(
225            INVALID_PARAMS,
226            format!("locale '{locale}' not in registry"),
227        ));
228    }
229    Ok(locale.to_string())
230}