mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* test(server): admin/handlers/channels — test-audit 2026-08-19 fixes * test(server): api/constants — test-audit 2026-08-19 fixes * test(server): api/middleware — test-audit 2026-08-19 fixes * test(server): api/waf — test-audit 2026-08-19 fixes * test(server): auth/totp/encrypt — test-audit 2026-08-19 fixes * test(server): db/session/expiry/test — test-audit 2026-08-19 fixes * test(server): migrations/030/attachments/unlink/on/message/delete — test-audit 2026-08-19 fixes * test(server): updater/download — test-audit 2026-08-19 fixes * test(server): ws/handlers_command — test-audit 2026-08-19 fixes * test(server): ws/hub/broadcast — test-audit 2026-08-19 fixes * test(server): ws/hub/events — test-audit 2026-08-19 fixes * test(server): ws/livekit/webhook — test-audit 2026-08-19 fixes * test(server): ws/voice/controls — test-audit 2026-08-19 fixes * test(server): ws/voice/join — test-audit 2026-08-19 fixes * test(server): ws/voice/moderation — test-audit 2026-08-19 fixes * test(rust): src-tauri/src/commands.rs — test-audit 2026-08-19 fixes * test(rust): src-tauri/src/secret_store.rs — test-audit 2026-08-19 fixes * test(rust): src-tauri/src/update_commands.rs — test-audit 2026-08-19 fixes * test(client): src/components/ChannelSidebar.ts — test-audit 2026-08-19 fixes * test(client): src/lib/ws.ts — test-audit 2026-08-19 fixes * test(rust): src-tauri/src/credentials.rs — test-audit 2026-08-19 fixes * test(rust): src-tauri/src/tofu.rs — test-audit 2026-08-19 fixes * test(client): src/lib/hostValidation.ts — test-audit 2026-08-19 fixes * test(client): src/lib/rate-limiter.ts — test-audit 2026-08-19 fixes * test(client): src/pages/connect-page/LoginForm.ts — test-audit 2026-08-19 fixes * test(client): src/pages/main-page/SidebarArea.ts — test-audit 2026-08-19 fixes * test(client): src/stores/voice.store.ts — test-audit 2026-08-19 fixes * test(client): tests/browser/smoke.test.ts — test-audit 2026-08-19 fixes * test(client): tests/unit/media.test.ts — test-audit 2026-08-19 fixes * test(client): tests/unit/renderers.test.ts — test-audit 2026-08-19 fixes * test(client): src/components/UserProfilePopup.ts — test-audit 2026-08-19 fixes * test(client): src/lib/e2eeCrypto.ts — test-audit 2026-08-19 fixes * test(client): tests/unit/log-persistence.test.ts — test-audit 2026-08-19 fixes * test(client): keep tests/browser out of the jsdom suite and run it in CI * test(server): ws/hub_broadcast_test.go — bytes.Equal payload compare (gocritic) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(client): src/lib/credentials.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/dispatcher.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/permissions.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/rate-limiter.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/hostValidation.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/stores/messages.store.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/e2eeCrypto.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/ws.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/identity.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/lib/livekitE2EE.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/stores/auth.store.ts — test-audit 2026-08-19 round 2 (Stryker) * test(client): src/stores/voice.store.ts — test-audit 2026-08-19 round 2 (Stryker) * docs: test audit 2026-08-19 — findings, fixes, measured baselines Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(graph): refresh the knowledge graph after the 2026-08-19 test audit Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
486 lines
18 KiB
Rust
486 lines
18 KiB
Rust
use serde::Serialize;
|
|
use std::sync::Mutex;
|
|
use tauri::AppHandle;
|
|
|
|
use crate::secret_store::{self, Backend};
|
|
|
|
/// Data returned from `load_credential`.
|
|
#[derive(Serialize, Clone)]
|
|
pub struct CredentialData {
|
|
pub username: String,
|
|
pub token: String,
|
|
// Password is stored in the credential blob for re-authentication and is
|
|
// serialized back to the frontend over IPC so the login form can prefill
|
|
// it when the user ticked "Remember password".
|
|
pub password: Option<String>,
|
|
}
|
|
|
|
impl std::fmt::Debug for CredentialData {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("CredentialData")
|
|
.field("username", &self.username)
|
|
.field("token", &"[REDACTED]")
|
|
.field("password", &self.password.as_ref().map(|_| "[REDACTED]"))
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Account naming
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// Both secrets live in the same credential-store service
|
|
// (`secret_store::SERVICE`) and are told apart by their account name. Changing
|
|
// either function orphans every credential already stored under the old name,
|
|
// so they are pure and covered by tests.
|
|
|
|
/// Account holding the login credential for `host`.
|
|
fn login_account(host: &str) -> String {
|
|
host.to_string()
|
|
}
|
|
|
|
/// Account holding the voice-E2EE identity private key for `host`.
|
|
///
|
|
/// The `identity:` prefix keeps it distinct from the login credential for the
|
|
/// same host; a collision would make one secret overwrite the other.
|
|
fn identity_account(host: &str) -> String {
|
|
format!("identity:{host}")
|
|
}
|
|
|
|
fn require_non_empty(value: &str, field: &str) -> Result<(), String> {
|
|
if value.is_empty() {
|
|
return Err(format!("{field} must not be empty"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Cross-command serialization
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// B4-3 moved every command below to `#[tauri::command(async)]` so the
|
|
// blocking keyring/DPAPI I/O runs off Tauri's IPC main thread instead of
|
|
// freezing the UI on it. Before that, Tauri ran all (sync) commands one at a
|
|
// time on that thread, so two overlapping invocations were always fully
|
|
// serialized in arrival order. `async` dispatches each invocation onto the
|
|
// async runtime's thread pool instead, so two overlapping calls can now
|
|
// genuinely run concurrently and interleave their OS credential-store
|
|
// operations.
|
|
//
|
|
// That is reachable, not hypothetical: `identity.ts`'s legacy-key migration
|
|
// does a save-then-delete pair for two different accounts, and logging out
|
|
// fires a fire-and-forget `delete_credential` for a host whose connect-page
|
|
// auto-login can immediately issue `load_credential` for the very same host.
|
|
// Nothing upstream awaits the delete before the read can start.
|
|
//
|
|
// This mutex restores the "only one credential-store operation in flight at
|
|
// a time" property that made ordering safe pre-`async`, without giving back
|
|
// the perf win: it guards the whole command body (not just the raw OS call),
|
|
// so the fallback file's read-modify-write in `secret_store::set_with` is
|
|
// still atomic with respect to a concurrent read or delete for the same or a
|
|
// different account.
|
|
static CREDENTIAL_LOCK: Mutex<()> = Mutex::new(());
|
|
|
|
/// Run `f` with every other credential-store command excluded. Poisoning is
|
|
/// recovered from (the guarded value is `()`, so there is nothing to
|
|
/// distrust) rather than propagated, so a panic inside one command cannot
|
|
/// permanently wedge every credential operation for the rest of the process.
|
|
fn with_credential_lock<T>(f: impl FnOnce() -> T) -> T {
|
|
let _guard = CREDENTIAL_LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
f()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tauri commands
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Save a credential (username + token + optional password) to the system
|
|
/// credential store.
|
|
///
|
|
/// Credential key: service=`com.owncord.client`, account=`host`
|
|
/// Secret: JSON `{"username":"...","token":"...","password":"..."}`
|
|
///
|
|
/// On Windows the secret is protected by DPAPI via Windows Credential Manager.
|
|
/// On Linux it is stored in the Secret Service (GNOME Keyring / KWallet).
|
|
/// On macOS it is stored in the system Keychain. The write is read back before
|
|
/// this returns — see [`crate::secret_store`] for what happens when it does not
|
|
/// come back.
|
|
#[tauri::command(async)]
|
|
pub fn save_credential(
|
|
app: AppHandle,
|
|
host: String,
|
|
username: String,
|
|
token: String,
|
|
password: Option<String>,
|
|
) -> Result<(), String> {
|
|
with_credential_lock(|| {
|
|
require_non_empty(&host, "host")?;
|
|
require_non_empty(&token, "token")?;
|
|
require_non_empty(&username, "username")?;
|
|
|
|
let mut payload = serde_json::json!({
|
|
"username": username,
|
|
"token": token,
|
|
});
|
|
if let Some(ref pw) = password {
|
|
payload["password"] = serde_json::Value::String(pw.clone());
|
|
}
|
|
|
|
secret_store::set(&app, &login_account(&host), &payload.to_string())
|
|
.map_err(|e| format!("save_credential failed: {e}"))?;
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
/// Load a credential from the system credential store.
|
|
///
|
|
/// Returns `None` when no credential exists for the given host.
|
|
#[tauri::command(async)]
|
|
pub fn load_credential(app: AppHandle, host: String) -> Result<Option<CredentialData>, String> {
|
|
with_credential_lock(|| {
|
|
require_non_empty(&host, "host")?;
|
|
|
|
let Some(json_str) = secret_store::get(&app, &login_account(&host))
|
|
.map_err(|e| format!("load_credential failed: {e}"))?
|
|
else {
|
|
return Ok(None);
|
|
};
|
|
|
|
parse_credential_blob(&json_str).map(Some)
|
|
})
|
|
}
|
|
|
|
/// Parse the stored credential JSON blob.
|
|
///
|
|
/// Split out from the command so the blob contract is testable without a
|
|
/// credential store.
|
|
fn parse_credential_blob(json_str: &str) -> Result<CredentialData, String> {
|
|
let parsed: serde_json::Value = serde_json::from_str(json_str)
|
|
.map_err(|e| format!("credential blob is not valid JSON: {e}"))?;
|
|
|
|
let username = parsed
|
|
.get("username")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or("credential blob missing 'username' field")?
|
|
.to_string();
|
|
let token = parsed
|
|
.get("token")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or("credential blob missing 'token' field")?
|
|
.to_string();
|
|
let password = parsed
|
|
.get("password")
|
|
.and_then(|v| v.as_str())
|
|
.map(|s| s.to_string());
|
|
|
|
Ok(CredentialData {
|
|
username,
|
|
token,
|
|
password,
|
|
})
|
|
}
|
|
|
|
/// Delete a credential from the system credential store.
|
|
///
|
|
/// Deleting a non-existent credential is not treated as an error.
|
|
#[tauri::command(async)]
|
|
pub fn delete_credential(app: AppHandle, host: String) -> Result<(), String> {
|
|
with_credential_lock(|| {
|
|
require_non_empty(&host, "host")?;
|
|
secret_store::delete(&app, &login_account(&host))
|
|
.map_err(|e| format!("delete_credential failed: {e}"))
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Identity-key commands (F3: voice E2EE TOFU long-term identity keypair)
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// Mirrors save/load/delete_credential, but the secret is a single opaque
|
|
// key blob (base64 JWK private key) rather than a JSON credential struct,
|
|
// and it is stored under account `identity:{host}` to keep it distinct from
|
|
// the login credential entry (account `{host}`) in the same service.
|
|
|
|
/// Save the long-term identity private key for `host`.
|
|
///
|
|
/// The write is read back before this returns. A machine whose credential store
|
|
/// accepts writes without keeping them falls through to the encrypted fallback
|
|
/// file (DPAPI on Windows, sealed per-install key elsewhere); if that is also
|
|
/// unavailable this returns an error rather than reporting a success that would
|
|
/// leave peers rejecting the user's voice announce after a restart.
|
|
#[tauri::command(async)]
|
|
pub fn save_identity_key(app: AppHandle, host: String, key: String) -> Result<(), String> {
|
|
with_credential_lock(|| {
|
|
require_non_empty(&host, "host")?;
|
|
require_non_empty(&key, "key")?;
|
|
|
|
secret_store::set(&app, &identity_account(&host), &key)
|
|
.map_err(|e| format!("save_identity_key failed: {e}"))?;
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
/// Load the identity private key for `host`.
|
|
///
|
|
/// Returns `None` when no identity key exists for the given host.
|
|
#[tauri::command(async)]
|
|
pub fn load_identity_key(app: AppHandle, host: String) -> Result<Option<String>, String> {
|
|
with_credential_lock(|| {
|
|
require_non_empty(&host, "host")?;
|
|
secret_store::get(&app, &identity_account(&host))
|
|
.map_err(|e| format!("load_identity_key failed: {e}"))
|
|
})
|
|
}
|
|
|
|
/// Delete the identity private key for `host`.
|
|
///
|
|
/// Deleting a non-existent key is not treated as an error.
|
|
#[tauri::command(async)]
|
|
pub fn delete_identity_key(app: AppHandle, host: String) -> Result<(), String> {
|
|
with_credential_lock(|| {
|
|
require_non_empty(&host, "host")?;
|
|
secret_store::delete(&app, &identity_account(&host))
|
|
.map_err(|e| format!("delete_identity_key failed: {e}"))
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Diagnostics
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Result of [`probe_credential_store`].
|
|
#[derive(Serialize, Debug)]
|
|
pub struct CredentialStoreProbe {
|
|
/// Whether a write/read/delete cycle completed with the value intact.
|
|
pub ok: bool,
|
|
/// Which store served the probe, when it succeeded.
|
|
pub backend: Option<Backend>,
|
|
/// Failure detail, for the log and the support bundle.
|
|
pub error: Option<String>,
|
|
}
|
|
|
|
/// Write, read back and delete a throwaway secret to prove the credential store
|
|
/// works on this machine.
|
|
///
|
|
/// This is the check to run when a user reports peers rejecting their voice
|
|
/// announce: it distinguishes "the credential store is fine" from "writes are
|
|
/// accepted and dropped" without touching any real credential. The probe
|
|
/// account is removed again whatever the outcome.
|
|
#[tauri::command(async)]
|
|
pub fn probe_credential_store(app: AppHandle) -> CredentialStoreProbe {
|
|
with_credential_lock(|| {
|
|
// Underscores are not legal in DNS hostnames, so this cannot collide
|
|
// with a real `{host}` or `identity:{host}` account.
|
|
const PROBE_ACCOUNT: &str = "__diagnostic_probe__";
|
|
const PROBE_SECRET: &str = "owncord-credential-store-probe";
|
|
|
|
let result = secret_store::set(&app, PROBE_ACCOUNT, PROBE_SECRET).and_then(|backend| {
|
|
match secret_store::get(&app, PROBE_ACCOUNT)? {
|
|
Some(ref got) if got == PROBE_SECRET => Ok(backend),
|
|
Some(_) => Err("read back a different value than was written".into()),
|
|
None => Err("the store reported a successful write but returned no entry".into()),
|
|
}
|
|
});
|
|
|
|
// Always clean up, including when the probe failed part-way through.
|
|
if let Err(e) = secret_store::delete(&app, PROBE_ACCOUNT) {
|
|
log::warn!("failed to remove credential store probe entry: {e}");
|
|
}
|
|
|
|
match result {
|
|
Ok(backend) => {
|
|
log::info!("credential store probe succeeded (backend: {backend:?})");
|
|
CredentialStoreProbe {
|
|
ok: true,
|
|
backend: Some(backend),
|
|
error: None,
|
|
}
|
|
}
|
|
Err(e) => {
|
|
log::error!("credential store probe failed: {e}");
|
|
CredentialStoreProbe {
|
|
ok: false,
|
|
backend: None,
|
|
error: Some(e),
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn require_non_empty_rejects_empty_and_names_the_field() {
|
|
let err = require_non_empty("", "host").unwrap_err();
|
|
assert_eq!(err, "host must not be empty");
|
|
assert_eq!(
|
|
require_non_empty("", "token").unwrap_err(),
|
|
"token must not be empty"
|
|
);
|
|
assert_eq!(
|
|
require_non_empty("", "username").unwrap_err(),
|
|
"username must not be empty"
|
|
);
|
|
assert_eq!(
|
|
require_non_empty("", "key").unwrap_err(),
|
|
"key must not be empty"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn require_non_empty_accepts_a_value() {
|
|
assert!(require_non_empty("chat.example.com", "host").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn login_and_identity_accounts_never_collide() {
|
|
// Both secrets share one credential-store service, so a collision would
|
|
// silently overwrite one with the other.
|
|
let host = "chat.example.com";
|
|
assert_eq!(login_account(host), "chat.example.com");
|
|
assert_eq!(identity_account(host), "identity:chat.example.com");
|
|
assert_ne!(login_account(host), identity_account(host));
|
|
}
|
|
|
|
#[test]
|
|
fn account_names_keep_the_port_that_distinguishes_hosts() {
|
|
// Two servers on one machine differ only by port; dropping it would
|
|
// make them share an identity key.
|
|
assert_ne!(login_account("localhost:8443"), login_account("localhost:9443"));
|
|
assert_eq!(identity_account("localhost:8443"), "identity:localhost:8443");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_credential_blob_reads_all_fields() {
|
|
let data =
|
|
parse_credential_blob(r#"{"username":"alice","token":"tok","password":"pw"}"#).unwrap();
|
|
assert_eq!(data.username, "alice");
|
|
assert_eq!(data.token, "tok");
|
|
assert_eq!(data.password.as_deref(), Some("pw"));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_credential_blob_allows_missing_password() {
|
|
let data = parse_credential_blob(r#"{"username":"alice","token":"tok"}"#).unwrap();
|
|
assert_eq!(data.password, None);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_credential_blob_rejects_malformed_input() {
|
|
assert!(parse_credential_blob("not json").unwrap_err().contains("not valid JSON"));
|
|
assert!(parse_credential_blob(r#"{"token":"tok"}"#)
|
|
.unwrap_err()
|
|
.contains("missing 'username'"));
|
|
assert!(parse_credential_blob(r#"{"username":"alice"}"#)
|
|
.unwrap_err()
|
|
.contains("missing 'token'"));
|
|
}
|
|
|
|
#[test]
|
|
fn credential_data_debug_redacts_sensitive_fields() {
|
|
let data = CredentialData {
|
|
username: "alice".into(),
|
|
token: "secret-token".into(),
|
|
password: Some("hunter2".into()),
|
|
};
|
|
let debug = format!("{data:?}");
|
|
assert!(debug.contains("alice"));
|
|
assert!(!debug.contains("secret-token"));
|
|
assert!(!debug.contains("hunter2"));
|
|
assert!(debug.contains("[REDACTED]"));
|
|
}
|
|
|
|
#[test]
|
|
fn credential_data_serializes_password_for_prefill() {
|
|
let data = CredentialData {
|
|
username: "alice".into(),
|
|
token: "tok".into(),
|
|
password: Some("pw".into()),
|
|
};
|
|
let json = serde_json::to_string(&data).unwrap();
|
|
assert!(json.contains("password"));
|
|
assert!(json.contains("pw"));
|
|
}
|
|
|
|
/// B4-3 follow-up: all 7 commands moved to `#[tauri::command(async)]`,
|
|
/// which runs each invocation on the async runtime's thread pool instead
|
|
/// of Tauri's single IPC main thread. Two overlapping invocations (e.g.
|
|
/// `identity.ts`'s save-then-delete legacy-key migration, or a logout's
|
|
/// `delete_credential` racing a connect-page auto-login's
|
|
/// `load_credential` for the same host) can now genuinely run
|
|
/// concurrently. `with_credential_lock` must serialize them: this proves
|
|
/// no two holders of the lock ever run their critical section at the
|
|
/// same time, regardless of which OS thread the runtime schedules them
|
|
/// on.
|
|
#[test]
|
|
fn credential_lock_serializes_overlapping_commands() {
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
use std::sync::Arc;
|
|
use std::thread;
|
|
use std::time::Duration;
|
|
|
|
let concurrent = Arc::new(AtomicUsize::new(0));
|
|
let max_concurrent = Arc::new(AtomicUsize::new(0));
|
|
|
|
let handles: Vec<_> = (0..8)
|
|
.map(|_| {
|
|
let concurrent = Arc::clone(&concurrent);
|
|
let max_concurrent = Arc::clone(&max_concurrent);
|
|
thread::spawn(move || {
|
|
with_credential_lock(|| {
|
|
let now = concurrent.fetch_add(1, Ordering::SeqCst) + 1;
|
|
max_concurrent.fetch_max(now, Ordering::SeqCst);
|
|
thread::sleep(Duration::from_millis(5));
|
|
concurrent.fetch_sub(1, Ordering::SeqCst);
|
|
});
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
for h in handles {
|
|
h.join().unwrap();
|
|
}
|
|
|
|
assert_eq!(
|
|
max_concurrent.load(Ordering::SeqCst),
|
|
1,
|
|
"two credential-store commands ran their critical section concurrently"
|
|
);
|
|
}
|
|
|
|
/// `with_credential_lock`'s doc comment promises that poisoning is
|
|
/// recovered from rather than propagated, so a panic inside one
|
|
/// credential command cannot permanently wedge every later credential
|
|
/// operation for the rest of the process. Prove it: panic while holding
|
|
/// the lock on a spawned thread (which poisons `CREDENTIAL_LOCK`), then
|
|
/// confirm a later `with_credential_lock` call still runs its closure
|
|
/// instead of panicking on the poisoned mutex.
|
|
#[test]
|
|
fn with_credential_lock_recovers_from_a_poisoned_guard() {
|
|
use std::thread;
|
|
|
|
let poisoning = thread::spawn(|| {
|
|
with_credential_lock(|| {
|
|
panic!("boom");
|
|
});
|
|
});
|
|
assert!(
|
|
poisoning.join().is_err(),
|
|
"expected the spawned thread to panic while holding the lock"
|
|
);
|
|
|
|
assert_eq!(
|
|
with_credential_lock(|| 42),
|
|
42,
|
|
"with_credential_lock must recover from a poisoned mutex, not propagate it"
|
|
);
|
|
}
|
|
}
|