2026-08-27 11:57:16 -07:00
|
|
|
//! Settings export/import — see triple-c#35.
|
|
|
|
|
//!
|
|
|
|
|
//! `SettingsExportPayload` is the whole plaintext export before encryption
|
|
|
|
|
//! and after decryption (see `storage::settings_crypto`). It bundles
|
2026-08-27 12:16:43 -07:00
|
|
|
//! `AppSettings` — with one field carved out, see below — with the global
|
|
|
|
|
//! secrets that live in the OS keychain instead: the shared Claude Code
|
|
|
|
|
//! OAuth login and the model gateway's two keys. Per-project settings,
|
|
|
|
|
//! per-project secrets, and anything living in a project's Docker volumes
|
|
|
|
|
//! are deliberately out of scope: this exports the *host* environment, not
|
|
|
|
|
//! any one project's.
|
|
|
|
|
//!
|
|
|
|
|
//! **`AppSettings` is not entirely the non-secret shape it looks like.**
|
|
|
|
|
//! `WebTerminalSettings::access_token` is a live bearer credential for a
|
|
|
|
|
//! server that binds every interface, stored as a plain field on the
|
|
|
|
|
//! struct that is otherwise safe to treat as config. A review of this
|
|
|
|
|
//! feature caught it: exporting `AppSettings` wholesale would have carried
|
|
|
|
|
//! that token along as if it were as inert as a port number, and — worse —
|
|
|
|
|
//! importing it would apply `web_terminal.enabled` and the token together
|
|
|
|
|
//! with no more warning than any other setting, letting a crafted export
|
|
|
|
|
//! silently stand up a LAN-listening terminal server with an
|
|
|
|
|
//! attacker-known token on the next launch. `export_settings` /
|
|
|
|
|
//! `apply_settings_import` blank this field out of the `settings` they
|
|
|
|
|
//! read from and write to, and it travels only through
|
|
|
|
|
//! [`ExportedSecrets::web_terminal_access_token`] instead, with the same
|
|
|
|
|
//! "only overwrite what the import actually has" treatment as the other
|
|
|
|
|
//! three secrets.
|
2026-08-27 11:57:16 -07:00
|
|
|
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
|
2026-08-27 14:24:06 -07:00
|
|
|
use super::{AppSettings, ImageSource};
|
2026-08-27 11:57:16 -07:00
|
|
|
|
|
|
|
|
/// Bumped when the shape of [`SettingsExportPayload`] changes in a way that
|
|
|
|
|
/// isn't just an additive, `#[serde(default)]`-covered field — e.g. if a
|
|
|
|
|
/// field is ever removed or its meaning changes. `apply_settings_import`
|
|
|
|
|
/// checks this before touching anything.
|
|
|
|
|
pub const SETTINGS_EXPORT_FORMAT_VERSION: u32 = 1;
|
|
|
|
|
|
|
|
|
|
/// The global secrets bundled into an export. Deliberately a separate struct
|
|
|
|
|
/// from `AppSettings`: these live in the OS keychain, never in
|
|
|
|
|
/// `settings.json`, and — outside of this export/import flow — the values
|
|
|
|
|
/// themselves never cross into the frontend; see the doc comments on
|
|
|
|
|
/// `storage::secure::get_gateway_api_key` and
|
|
|
|
|
/// `commands::settings_export_commands` for why that boundary matters here
|
|
|
|
|
/// too.
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
|
|
|
pub struct ExportedSecrets {
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub claude_oauth_token: Option<String>,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub gateway_api_key: Option<String>,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub gateway_master_key: Option<String>,
|
2026-08-27 12:16:43 -07:00
|
|
|
/// See the module doc comment — this is `AppSettings::web_terminal
|
|
|
|
|
/// .access_token`, carved out because it is a live bearer credential,
|
|
|
|
|
/// not config, despite living on a struct that is otherwise safe to
|
|
|
|
|
/// export wholesale.
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub web_terminal_access_token: Option<String>,
|
2026-08-27 11:57:16 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl ExportedSecrets {
|
|
|
|
|
pub fn is_empty(&self) -> bool {
|
2026-08-27 14:24:06 -07:00
|
|
|
let blank = |s: &Option<String>| s.as_deref().is_none_or(|v| v.trim().is_empty());
|
|
|
|
|
blank(&self.claude_oauth_token)
|
|
|
|
|
&& blank(&self.gateway_api_key)
|
|
|
|
|
&& blank(&self.gateway_master_key)
|
|
|
|
|
&& blank(&self.web_terminal_access_token)
|
2026-08-27 11:57:16 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-27 14:24:06 -07:00
|
|
|
/// What `apply_settings_import` hands back: the settings that were actually
|
|
|
|
|
/// saved, plus a human-readable note for each keychain secret this import
|
|
|
|
|
/// carried but could not be restored. A keychain write failing partway
|
|
|
|
|
/// through must not read as unqualified success just because the settings
|
|
|
|
|
/// half of the import went through.
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
pub struct SettingsImportOutcome {
|
|
|
|
|
pub settings: AppSettings,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub secret_restore_warnings: Vec<String>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-27 11:57:16 -07:00
|
|
|
/// The full plaintext payload — this is what gets encrypted on export and
|
|
|
|
|
/// what decryption recovers on import. Never written to disk unencrypted;
|
|
|
|
|
/// see `storage::settings_crypto`.
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
pub struct SettingsExportPayload {
|
|
|
|
|
pub format_version: u32,
|
|
|
|
|
/// RFC3339. Purely informational — shown in the import preview so a user
|
|
|
|
|
/// picking between a few old export files has something to go on.
|
|
|
|
|
pub exported_at: String,
|
|
|
|
|
/// The exporting app's `CARGO_PKG_VERSION`. Also informational: every
|
|
|
|
|
/// field below already round-trips through `#[serde(default)]`-covered
|
|
|
|
|
/// `AppSettings`, so an older or newer export still deserializes; this is
|
|
|
|
|
/// for a human to notice "this is from a much older version" if an import
|
|
|
|
|
/// ever looks wrong, not something the code branches on.
|
|
|
|
|
pub app_version: String,
|
|
|
|
|
pub settings: AppSettings,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub secrets: ExportedSecrets,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// What `preview_settings_import` hands the frontend before anything is
|
|
|
|
|
/// applied — counts and presence flags only, **never** a secret value itself,
|
|
|
|
|
/// so this type is safe to return across the IPC boundary and render
|
|
|
|
|
/// directly. The confirmation UI is built from this.
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
pub struct SettingsImportPreview {
|
|
|
|
|
pub exported_at: String,
|
|
|
|
|
pub app_version: String,
|
|
|
|
|
pub custom_env_var_count: usize,
|
|
|
|
|
pub gateway_model_count: usize,
|
|
|
|
|
pub has_claude_code_settings: bool,
|
|
|
|
|
pub has_claude_oauth_token: bool,
|
|
|
|
|
pub has_gateway_api_key: bool,
|
|
|
|
|
pub has_gateway_master_key: bool,
|
2026-08-27 12:16:43 -07:00
|
|
|
pub has_web_terminal_access_token: bool,
|
|
|
|
|
/// Whether the imported settings turn the web terminal on. Named
|
|
|
|
|
/// separately from the token above: `enabled` and the token are two
|
|
|
|
|
/// different fields, either can be true without the other, and
|
|
|
|
|
/// "this import turns on a service that listens on your network" is
|
|
|
|
|
/// exactly the kind of change a wholesale settings replace must not
|
|
|
|
|
/// bury in a generic "settings replaced" line — see the module doc
|
|
|
|
|
/// comment on why this field exists at all.
|
|
|
|
|
pub enables_web_terminal: bool,
|
2026-08-27 13:13:48 -07:00
|
|
|
/// Non-blank custom base URLs the import would set, so a redirect of
|
|
|
|
|
/// model traffic to somewhere other than the usual provider is visible
|
|
|
|
|
/// at import time rather than discovered later. These are endpoints, not
|
|
|
|
|
/// secrets — safe to show verbatim, unlike everything above.
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub ollama_base_url: Option<String>,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub llamacpp_base_url: Option<String>,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub openai_compatible_base_url: Option<String>,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub gateway_api_base: Option<String>,
|
2026-08-27 14:24:06 -07:00
|
|
|
/// Whether the import sets a custom Docker image, and its name if so —
|
|
|
|
|
/// disclosed for the same reason as the base URLs above, and arguably
|
|
|
|
|
/// more sharply: this is the image *every* project container is created
|
|
|
|
|
/// from (`models::container_config::resolve_image_name`), so a crafted
|
|
|
|
|
/// export pointing it at an attacker-controlled image is a path to
|
|
|
|
|
/// running arbitrary code with whatever a project's containers are
|
|
|
|
|
/// allowed to reach (the Docker socket, an SSH key, project files) —
|
|
|
|
|
/// not merely a redirected API endpoint.
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub image_source: ImageSource,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub custom_image_name: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A cap on how much of a decrypted, not-yet-trusted string gets echoed back
|
|
|
|
|
/// into a preview a user reads and a UI renders without truncation of its
|
|
|
|
|
/// own. Applied to every field above that carries free-form text straight
|
|
|
|
|
/// from the import file rather than a count or a boolean — a base URL or an
|
|
|
|
|
/// image name a hostile export author controls has had no validation done
|
|
|
|
|
/// on it yet at preview time, and nothing stops it from being pathological
|
|
|
|
|
/// (embedded control characters, or long enough to blow out the confirmation
|
|
|
|
|
/// dialog and push the security warnings below it off screen).
|
|
|
|
|
const MAX_PREVIEW_STRING_LEN: usize = 100;
|
|
|
|
|
|
|
|
|
|
fn sanitize_for_preview(value: &str) -> String {
|
|
|
|
|
let cleaned: String = value.chars().filter(|c| !c.is_control()).collect();
|
|
|
|
|
let trimmed = cleaned.trim();
|
|
|
|
|
if trimmed.chars().count() > MAX_PREVIEW_STRING_LEN {
|
|
|
|
|
let truncated: String = trimmed.chars().take(MAX_PREVIEW_STRING_LEN).collect();
|
|
|
|
|
format!("{}…", truncated)
|
|
|
|
|
} else {
|
|
|
|
|
trimmed.to_string()
|
|
|
|
|
}
|
2026-08-27 11:57:16 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl SettingsImportPreview {
|
|
|
|
|
pub fn from_payload(payload: &SettingsExportPayload) -> Self {
|
2026-08-27 12:16:43 -07:00
|
|
|
let non_blank = |s: &Option<String>| s.as_deref().is_some_and(|v| !v.trim().is_empty());
|
2026-08-27 14:24:06 -07:00
|
|
|
let sanitized_non_blank = |s: &Option<String>| {
|
|
|
|
|
s.as_deref()
|
|
|
|
|
.map(sanitize_for_preview)
|
|
|
|
|
.filter(|v| !v.is_empty())
|
|
|
|
|
};
|
2026-08-27 11:57:16 -07:00
|
|
|
Self {
|
|
|
|
|
exported_at: payload.exported_at.clone(),
|
|
|
|
|
app_version: payload.app_version.clone(),
|
|
|
|
|
custom_env_var_count: payload.settings.global_custom_env_vars.len(),
|
|
|
|
|
gateway_model_count: payload.settings.gateway.models.len(),
|
|
|
|
|
has_claude_code_settings: payload.settings.global_claude_code_settings.is_some(),
|
2026-08-27 12:16:43 -07:00
|
|
|
has_claude_oauth_token: non_blank(&payload.secrets.claude_oauth_token),
|
|
|
|
|
has_gateway_api_key: non_blank(&payload.secrets.gateway_api_key),
|
|
|
|
|
has_gateway_master_key: non_blank(&payload.secrets.gateway_master_key),
|
|
|
|
|
has_web_terminal_access_token: non_blank(&payload.secrets.web_terminal_access_token),
|
|
|
|
|
enables_web_terminal: payload.settings.web_terminal.enabled,
|
2026-08-27 14:24:06 -07:00
|
|
|
ollama_base_url: sanitized_non_blank(&payload.settings.global_ollama.base_url),
|
|
|
|
|
llamacpp_base_url: sanitized_non_blank(&payload.settings.global_llamacpp.base_url),
|
|
|
|
|
openai_compatible_base_url: sanitized_non_blank(
|
2026-08-27 13:13:48 -07:00
|
|
|
&payload.settings.global_openai_compatible.base_url,
|
|
|
|
|
),
|
2026-08-27 14:24:06 -07:00
|
|
|
gateway_api_base: sanitized_non_blank(&payload.settings.gateway.api_base),
|
|
|
|
|
image_source: payload.settings.image_source.clone(),
|
|
|
|
|
custom_image_name: sanitized_non_blank(&payload.settings.custom_image_name),
|
2026-08-27 11:57:16 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
use crate::models::AppSettings;
|
|
|
|
|
|
|
|
|
|
fn payload_with(secrets: ExportedSecrets) -> SettingsExportPayload {
|
2026-08-27 14:24:06 -07:00
|
|
|
let settings = AppSettings {
|
|
|
|
|
global_custom_env_vars: vec![
|
|
|
|
|
crate::models::EnvVar {
|
|
|
|
|
key: "A".to_string(),
|
|
|
|
|
value: "1".to_string(),
|
|
|
|
|
},
|
|
|
|
|
crate::models::EnvVar {
|
|
|
|
|
key: "B".to_string(),
|
|
|
|
|
value: "2".to_string(),
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
..AppSettings::default()
|
|
|
|
|
};
|
2026-08-27 11:57:16 -07:00
|
|
|
SettingsExportPayload {
|
|
|
|
|
format_version: SETTINGS_EXPORT_FORMAT_VERSION,
|
|
|
|
|
exported_at: "2026-08-27T00:00:00Z".to_string(),
|
|
|
|
|
app_version: "0.4.14".to_string(),
|
|
|
|
|
settings,
|
|
|
|
|
secrets,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn the_preview_never_carries_a_secret_value() {
|
|
|
|
|
let payload = payload_with(ExportedSecrets {
|
|
|
|
|
claude_oauth_token: Some("sk-super-secret-token".to_string()),
|
|
|
|
|
gateway_api_key: Some("sk-another-secret".to_string()),
|
|
|
|
|
gateway_master_key: Some("sk-triple-c-yet-another".to_string()),
|
2026-08-27 12:16:43 -07:00
|
|
|
web_terminal_access_token: Some("wt-super-secret-token".to_string()),
|
2026-08-27 11:57:16 -07:00
|
|
|
});
|
|
|
|
|
let preview = SettingsImportPreview::from_payload(&payload);
|
|
|
|
|
let serialized = serde_json::to_string(&preview).unwrap();
|
|
|
|
|
|
|
|
|
|
assert!(!serialized.contains("sk-super-secret-token"));
|
|
|
|
|
assert!(!serialized.contains("sk-another-secret"));
|
|
|
|
|
assert!(!serialized.contains("sk-triple-c-yet-another"));
|
2026-08-27 12:16:43 -07:00
|
|
|
assert!(!serialized.contains("wt-super-secret-token"));
|
2026-08-27 11:57:16 -07:00
|
|
|
assert!(preview.has_claude_oauth_token);
|
|
|
|
|
assert!(preview.has_gateway_api_key);
|
|
|
|
|
assert!(preview.has_gateway_master_key);
|
2026-08-27 12:16:43 -07:00
|
|
|
assert!(preview.has_web_terminal_access_token);
|
2026-08-27 11:57:16 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_blank_secret_reads_as_absent_in_the_preview() {
|
|
|
|
|
// A keychain entry that exists but holds only whitespace must not
|
|
|
|
|
// read as "present" — same "blank counts as absent" rule the
|
|
|
|
|
// keychain layer itself applies when storing these.
|
|
|
|
|
let payload = payload_with(ExportedSecrets {
|
|
|
|
|
claude_oauth_token: Some(" ".to_string()),
|
|
|
|
|
gateway_api_key: None,
|
|
|
|
|
gateway_master_key: None,
|
2026-08-27 12:16:43 -07:00
|
|
|
web_terminal_access_token: Some(" ".to_string()),
|
2026-08-27 11:57:16 -07:00
|
|
|
});
|
|
|
|
|
let preview = SettingsImportPreview::from_payload(&payload);
|
|
|
|
|
assert!(!preview.has_claude_oauth_token);
|
|
|
|
|
assert!(!preview.has_gateway_api_key);
|
|
|
|
|
assert!(!preview.has_gateway_master_key);
|
2026-08-27 12:16:43 -07:00
|
|
|
assert!(!preview.has_web_terminal_access_token);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn enabling_the_web_terminal_is_surfaced_regardless_of_whether_a_token_came_with_it() {
|
|
|
|
|
// `enabled` and the token are independent fields — a crafted export
|
|
|
|
|
// could set one without the other, and both are worth a user's
|
|
|
|
|
// attention: this is the field that exists specifically so "this
|
|
|
|
|
// import turns on a service that listens on your network" cannot
|
|
|
|
|
// hide inside a generic "settings replaced" summary.
|
|
|
|
|
let mut payload = payload_with(ExportedSecrets::default());
|
|
|
|
|
payload.settings.web_terminal.enabled = true;
|
|
|
|
|
let preview = SettingsImportPreview::from_payload(&payload);
|
|
|
|
|
assert!(preview.enables_web_terminal);
|
|
|
|
|
assert!(!preview.has_web_terminal_access_token);
|
2026-08-27 11:57:16 -07:00
|
|
|
}
|
|
|
|
|
|
2026-08-27 13:13:48 -07:00
|
|
|
#[test]
|
|
|
|
|
fn custom_base_urls_are_surfaced_but_blank_ones_read_as_absent() {
|
|
|
|
|
let mut payload = payload_with(ExportedSecrets::default());
|
|
|
|
|
payload.settings.global_ollama.base_url = Some("http://attacker.example:11434".to_string());
|
|
|
|
|
payload.settings.global_llamacpp.base_url = Some(" ".to_string());
|
|
|
|
|
payload.settings.gateway.api_base = Some("https://gateway.example/v1".to_string());
|
|
|
|
|
|
|
|
|
|
let preview = SettingsImportPreview::from_payload(&payload);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
preview.ollama_base_url.as_deref(),
|
|
|
|
|
Some("http://attacker.example:11434")
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(preview.llamacpp_base_url, None);
|
|
|
|
|
assert_eq!(preview.openai_compatible_base_url, None);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
preview.gateway_api_base.as_deref(),
|
|
|
|
|
Some("https://gateway.example/v1")
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-27 11:57:16 -07:00
|
|
|
#[test]
|
|
|
|
|
fn counts_reflect_the_real_settings() {
|
|
|
|
|
let payload = payload_with(ExportedSecrets::default());
|
|
|
|
|
let preview = SettingsImportPreview::from_payload(&payload);
|
|
|
|
|
assert_eq!(preview.custom_env_var_count, 2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn an_empty_secrets_bundle_reports_itself_as_empty() {
|
|
|
|
|
assert!(ExportedSecrets::default().is_empty());
|
|
|
|
|
assert!(!ExportedSecrets {
|
|
|
|
|
claude_oauth_token: Some("x".to_string()),
|
|
|
|
|
..Default::default()
|
|
|
|
|
}
|
|
|
|
|
.is_empty());
|
|
|
|
|
}
|
2026-08-27 14:24:06 -07:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_secrets_bundle_holding_only_whitespace_still_reports_itself_as_empty() {
|
|
|
|
|
// Matches the "blank counts as absent" rule every other consumer of
|
|
|
|
|
// these fields applies (`has_claude_oauth_token` and friends above) —
|
|
|
|
|
// a keychain entry that exists but holds only whitespace carries
|
|
|
|
|
// nothing usable, so the export-time "nothing to export" log line
|
|
|
|
|
// must still fire for it.
|
|
|
|
|
assert!(ExportedSecrets {
|
|
|
|
|
claude_oauth_token: Some(" ".to_string()),
|
|
|
|
|
..Default::default()
|
|
|
|
|
}
|
|
|
|
|
.is_empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_custom_docker_image_is_surfaced() {
|
|
|
|
|
let mut payload = payload_with(ExportedSecrets::default());
|
|
|
|
|
payload.settings.image_source = crate::models::ImageSource::Custom;
|
|
|
|
|
payload.settings.custom_image_name = Some("ghcr.io/attacker/triple-c:latest".to_string());
|
|
|
|
|
|
|
|
|
|
let preview = SettingsImportPreview::from_payload(&payload);
|
|
|
|
|
assert_eq!(preview.image_source, crate::models::ImageSource::Custom);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
preview.custom_image_name.as_deref(),
|
|
|
|
|
Some("ghcr.io/attacker/triple-c:latest")
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn preview_strings_are_stripped_of_control_characters_and_capped_in_length() {
|
|
|
|
|
let mut payload = payload_with(ExportedSecrets::default());
|
|
|
|
|
payload.settings.global_ollama.base_url =
|
|
|
|
|
Some(format!("http://example.test/{}\u{0007}bell", "x".repeat(200)));
|
|
|
|
|
|
|
|
|
|
let preview = SettingsImportPreview::from_payload(&payload);
|
|
|
|
|
let shown = preview.ollama_base_url.expect("non-blank base url");
|
|
|
|
|
assert!(!shown.contains('\u{0007}'), "control character leaked into the preview");
|
|
|
|
|
// +1 for the trailing ellipsis appended when truncated.
|
|
|
|
|
assert!(
|
|
|
|
|
shown.chars().count() <= MAX_PREVIEW_STRING_LEN + 1,
|
|
|
|
|
"preview string was not capped: {} chars",
|
|
|
|
|
shown.chars().count()
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-08-27 11:57:16 -07:00
|
|
|
}
|