mirror of
https://github.com/block/buzz.git
synced 2026-11-14 05:12:27 +00:00
**Category:** new-feature **User Impact:** Users can create, download, and verify a password-protected backup of their private identity from desktop Settings. **Problem:** Buzz does not currently give signed-in users a Settings-based path to protect or validate their private identity independently of onboarding. **Solution:** Add a focused backup menu to the private-key row, keep encryption and verification local in Rust, and preserve completed encrypted backups briefly so native saves can be retried without repeating encryption. <details> <summary>File changes</summary> **desktop/src/features/settings/** Adds the background backup lifecycle, create and test dialogs, private-key menu integration, password handling, and focused unit coverage. **desktop/src/features/onboarding/ui/NsecMaskedDisplay.tsx** Extends the masked private-key display with reusable overflow-menu actions used by Settings. **desktop/src/app/App.tsx** Mounts the backup provider at app scope so encryption and save work survive closing Settings or the modal. **desktop/src/shared/api/tauriIdentity.ts** Adds typed desktop bindings for local backup creation, save, selection, and verification. **desktop/src-tauri/src/key_backup.rs and desktop/src-tauri/src/commands/identity.rs** Implements local NIP-49 encryption, password generation, file handling, and public-identity-only verification results. **desktop/src-tauri/src/egress_guard.rs and guarded call sites** Blocks encrypted secret material from relay, websocket, snapshot, sharing, and huddle egress paths. **desktop/src-tauri tests and fixtures** Covers encryption, verification, file behavior, and fail-closed no-egress protections. **desktop/src/testing/e2eBridge.ts, desktop/tests/, and desktop/playwright.config.ts** Expands the mock native bridge and browser coverage across create, retry, expiry, and current/different-identity verification states. **desktop/src-tauri/Cargo.toml, Cargo.lock, and assets** Adds the local cryptography/password-generation dependencies and embedded short-word list. </details> ## Reproduction steps 1. Run the desktop app and open **Settings → Profile → Identity**. 2. Open the private-key overflow menu and choose **Create backup**. 3. Enter or generate a valid password, submit, and confirm progress continues if the dialog or Settings is closed. 4. Save the resulting `.ncryptsec` file; cancel and retry to confirm the temporary download remains available. 5. Choose **Test backup**, select the file, enter a wrong password, then retry with the correct password. 6. Confirm success identifies whether the backup matches the current identity and displays only the public `npub`. ## Screenshots | Settings identity | Private-key menu | Create backup | |---|---|---| | <img width="1280" height="720" alt="image" src="https://github.com/user-attachments/assets/981e391b-6829-4081-95ca-ca75a369de71" /> | <img width="1280" height="720" alt="image" src="https://github.com/user-attachments/assets/7972c68e-7635-47d8-b0ad-9639390d3e6c" /> | <img width="1280" height="720" alt="image" src="https://github.com/user-attachments/assets/4709c8f7-cf02-46f1-bec9-b3f98fe56fb2" /> | | Encrypting | Download available | Test success | |---|---|---| | <img width="1280" height="720" alt="image" src="https://github.com/user-attachments/assets/1ac3e934-2b4b-4135-bae6-126c715c8c59" /> | <img width="1280" height="720" alt="image" src="https://github.com/user-attachments/assets/cb6f07ee-a16f-44a5-b9a0-6b9fe0e4d40d" /> | <img width="1280" height="720" alt="image" src="https://github.com/user-attachments/assets/ea58b1b1-966c-46aa-8d59-92c9f06a25bd" /> | Visual review and additional states: [Buzz thread](buzz://message?channel=50ca7ef1-201e-4159-9499-40de3964b7c3&id=87eceb5f0f82fd50c32e560de3d35be48e293760f6620718aafdcef289d475fe) --------- Signed-off-by: Taylor Ho <taylorkmho@gmail.com> Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
447 lines
17 KiB
Rust
447 lines
17 KiB
Rust
use super::*;
|
|
|
|
/// NIP-49 spec vector — a real ncryptsec blob for injection payloads.
|
|
const NCRYPTSEC: &str = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p";
|
|
|
|
fn assert_guard_error(err: &str) {
|
|
assert!(
|
|
err.contains("key-backup material"),
|
|
"expected the egress-guard error, got: {err}"
|
|
);
|
|
}
|
|
|
|
// ── Guard unit behavior ───────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn rejects_ncryptsec_anywhere_in_text() {
|
|
assert_guard_error(&assert_no_key_backup(NCRYPTSEC, "test").unwrap_err());
|
|
assert_guard_error(
|
|
&assert_no_key_backup(
|
|
&format!("{{\"content\":\"my backup: {NCRYPTSEC}\"}}"),
|
|
"test",
|
|
)
|
|
.unwrap_err(),
|
|
);
|
|
}
|
|
|
|
/// Bech32 permits an all-uppercase encoding of the same payload — an
|
|
/// uppercased valid backup must not bypass the guard (text and bytes).
|
|
/// Mixed case is invalid bech32 (cannot decode) and is deliberately not
|
|
/// blocked.
|
|
#[test]
|
|
fn rejects_uppercase_ncryptsec() {
|
|
let upper = NCRYPTSEC.to_ascii_uppercase();
|
|
assert_guard_error(&assert_no_key_backup(&upper, "test").unwrap_err());
|
|
assert_guard_error(&assert_no_key_backup_bytes(upper.as_bytes(), "test").unwrap_err());
|
|
// Mixed case cannot decode; not blocked.
|
|
assert!(assert_no_key_backup("nCrYpTsEc1qgg9947r", "test").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn passes_clean_payloads_including_raw_nsec() {
|
|
assert!(assert_no_key_backup("hello world", "test").is_ok());
|
|
assert!(assert_no_key_backup("", "test").is_ok());
|
|
// Scope is ncryptsec1 ONLY: raw nsec intentionally transits the encrypted
|
|
// pairing session and must NOT be blocked (plan D4 / pairing.rs).
|
|
let nsec = nostr::ToBech32::to_bech32(nostr::Keys::generate().secret_key()).unwrap();
|
|
assert!(assert_no_key_backup(&nsec, "test").is_ok());
|
|
// Near-miss prefixes are not blocked.
|
|
assert!(assert_no_key_backup("ncryptsec", "test").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn byte_variant_matches_text_variant() {
|
|
assert_guard_error(&assert_no_key_backup_bytes(NCRYPTSEC.as_bytes(), "test").unwrap_err());
|
|
assert!(assert_no_key_backup_bytes(b"clean body", "test").is_ok());
|
|
// Invalid UTF-8 around an intact ncryptsec substring must still trip the
|
|
// guard (from_utf8_lossy preserves the ASCII run).
|
|
let mut body = vec![0xff, 0xfe];
|
|
body.extend_from_slice(NCRYPTSEC.as_bytes());
|
|
body.push(0xff);
|
|
assert_guard_error(&assert_no_key_backup_bytes(&body, "test").unwrap_err());
|
|
}
|
|
|
|
#[test]
|
|
fn error_names_the_boundary_context() {
|
|
let err = assert_no_key_backup(NCRYPTSEC, "huddle STT publish").unwrap_err();
|
|
assert!(err.contains("huddle STT publish"), "{err}");
|
|
}
|
|
|
|
// ── Runtime injection per boundary ────────────────────────────────────────────
|
|
//
|
|
// Each test drives the real production function with an ncryptsec-bearing
|
|
// payload and asserts the guard aborts the operation before any network I/O
|
|
// (no listener exists at the target address; a distinctive guard error — not
|
|
// a connection error — proves the abort happened first).
|
|
//
|
|
// Boundaries 6 and 7 (`submit_engram_event` twins) are module-private inside
|
|
// `commands`; their injection tests live next to them:
|
|
// - commands/team_snapshot/tests.rs::egress_guard_boundary
|
|
// - commands/personas/snapshot/import.rs::egress_guard_tests
|
|
|
|
/// Boundary 1: `relay/submit.rs` `submit_event_at_with_keys` (the funnel for
|
|
/// all `submit_event*` variants).
|
|
#[tokio::test]
|
|
async fn boundary_submit_event_at_with_keys_blocks_ncryptsec() {
|
|
let state = crate::app_state::build_app_state();
|
|
let keys = nostr::Keys::generate();
|
|
let builder = nostr::EventBuilder::new(nostr::Kind::Custom(9), NCRYPTSEC);
|
|
let err = crate::relay::submit_event_at_with_keys(
|
|
builder,
|
|
&state,
|
|
"http://127.0.0.1:9", // discard port — must never be reached
|
|
&keys,
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert_guard_error(&err);
|
|
}
|
|
|
|
/// Boundary 2: `relay.rs` `sync_managed_agent_profile` (agent kind:0 profile).
|
|
#[tokio::test]
|
|
async fn boundary_sync_managed_agent_profile_blocks_ncryptsec() {
|
|
let state = crate::app_state::build_app_state();
|
|
let keys = nostr::Keys::generate();
|
|
let err = crate::relay::sync_managed_agent_profile(
|
|
&state,
|
|
"ws://127.0.0.1:9",
|
|
&keys,
|
|
&format!("agent {NCRYPTSEC}"),
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert_guard_error(&err);
|
|
}
|
|
|
|
/// Boundary 3: `relay/submit.rs` `submit_signed_event_at_with_keys` — the
|
|
/// pre-signed entry into the boundary-1 funnel (main's submit refactor
|
|
/// replaced `relay.rs` `submit_signed_event` with this scoped form).
|
|
#[tokio::test]
|
|
async fn boundary_submit_signed_event_at_with_keys_blocks_ncryptsec() {
|
|
let state = crate::app_state::build_app_state();
|
|
let keys = nostr::Keys::generate();
|
|
let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), NCRYPTSEC)
|
|
.sign_with_keys(&keys)
|
|
.unwrap();
|
|
let err = crate::relay::submit_signed_event_at_with_keys(
|
|
&event,
|
|
&state,
|
|
"http://127.0.0.1:9", // discard port — must never be reached
|
|
&keys,
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert_guard_error(&err);
|
|
}
|
|
|
|
/// Boundary 4: `relay.rs` `submit_signed_event_with_keys`.
|
|
#[tokio::test]
|
|
async fn boundary_submit_signed_event_with_keys_blocks_ncryptsec() {
|
|
let state = crate::app_state::build_app_state();
|
|
*state.relay_url_override.lock().unwrap() = Some("ws://127.0.0.1:9".to_string());
|
|
let keys = nostr::Keys::generate();
|
|
let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), NCRYPTSEC)
|
|
.sign_with_keys(&keys)
|
|
.unwrap();
|
|
let err = crate::relay::submit_signed_event_with_keys(&event, &state, &keys, None)
|
|
.await
|
|
.unwrap_err();
|
|
assert_guard_error(&err);
|
|
}
|
|
|
|
/// Boundary 5: huddle STT publisher (`huddle/pipeline.rs`).
|
|
#[test]
|
|
fn boundary_huddle_stt_blocks_ncryptsec() {
|
|
let keys = nostr::Keys::generate();
|
|
let channel = uuid::Uuid::new_v4();
|
|
let builder =
|
|
crate::events::build_message(channel, NCRYPTSEC, None, &[], &[], &[], &[]).unwrap();
|
|
let err = crate::huddle::pipeline::sign_and_guard_stt_body(builder, &keys).unwrap_err();
|
|
assert_guard_error(&err);
|
|
|
|
// Clean transcripts pass through the same seam.
|
|
let builder =
|
|
crate::events::build_message(channel, "hello huddle", None, &[], &[], &[], &[]).unwrap();
|
|
assert!(crate::huddle::pipeline::sign_and_guard_stt_body(builder, &keys).is_ok());
|
|
}
|
|
|
|
/// Boundary 8: native websocket send loop — the single choke point for all
|
|
/// webview-originated relay websocket frames.
|
|
#[tokio::test]
|
|
async fn boundary_native_websocket_blocks_ncryptsec() {
|
|
let manager = crate::native_websocket::WebSocketManager::default();
|
|
// Text frame: guard fires before the connection lookup, so no connection
|
|
// is needed — and the error must be the guard's, not "not found".
|
|
let err = crate::native_websocket::send_message(
|
|
&manager,
|
|
1,
|
|
crate::native_websocket::WebSocketMessage::Text(format!(
|
|
"[\"EVENT\",{{\"content\":\"{NCRYPTSEC}\"}}]"
|
|
)),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert_guard_error(&err);
|
|
|
|
// Binary frame variant.
|
|
let err = crate::native_websocket::send_message(
|
|
&manager,
|
|
1,
|
|
crate::native_websocket::WebSocketMessage::Binary(NCRYPTSEC.as_bytes().to_vec()),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert_guard_error(&err);
|
|
|
|
// Clean frames fall through to normal handling ("connection not found"
|
|
// here — the guard did not reject them).
|
|
let err = crate::native_websocket::send_message(
|
|
&manager,
|
|
1,
|
|
crate::native_websocket::WebSocketMessage::Text("[\"REQ\",\"sub\",{}]".to_string()),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(err.contains("not found"), "{err}");
|
|
}
|
|
|
|
// ── Structural tripwires ──────────────────────────────────────────────────────
|
|
|
|
fn src_rust_files() -> Vec<std::path::PathBuf> {
|
|
fn walk(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
|
|
for entry in std::fs::read_dir(dir).unwrap() {
|
|
let path = entry.unwrap().path();
|
|
if path.is_dir() {
|
|
walk(&path, out);
|
|
} else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
|
|
out.push(path);
|
|
}
|
|
}
|
|
}
|
|
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
|
let mut out = Vec::new();
|
|
walk(&root, &mut out);
|
|
out
|
|
}
|
|
|
|
/// Site-granular `/events` inventory: `(file suffix, expected non-comment
|
|
/// `/events` occurrences, expected guard call sites — full-path calls into
|
|
/// the egress-guard module)`.
|
|
///
|
|
/// Every entry pairs the URL-construction count with the guard-call count for
|
|
/// that file, so BOTH of these fail the scan (not just a brand-new file):
|
|
/// - adding an unguarded ninth `/events` site inside an already-listed file
|
|
/// (count goes up without a matching table update), and
|
|
/// - removing/refactoring away a guard call while its egress site remains.
|
|
///
|
|
/// Updating a row here is the deliberate act that must accompany wiring the
|
|
/// guard + adding an injection test for the new site.
|
|
const EVENTS_INVENTORY: &[(&str, usize, usize)] = &[
|
|
// Production egress boundaries (see egress_guard.rs table):
|
|
("src/relay.rs", 2, 2), // boundaries 2, 4
|
|
("src/relay/submit.rs", 1, 1), // boundaries 1 + 3 (shared funnel)
|
|
("src/huddle/pipeline.rs", 1, 1), // boundary 5
|
|
("src/commands/team_snapshot.rs", 1, 1), // boundary 6
|
|
("src/commands/personas/snapshot/import.rs", 2, 1), // boundary 7 + its in-file injection-test fixture URL
|
|
("src/native_websocket.rs", 0, 2), // boundary 8 (WS frames; no events URL)
|
|
// Test-only fixtures — no production egress, no guard:
|
|
("src/relay_admission.rs", 1, 0),
|
|
("src/archive/mod_tests.rs", 1, 0),
|
|
("src/managed_agents/persona_events/tests.rs", 1, 0),
|
|
("src/commands/team_snapshot/tests.rs", 1, 0),
|
|
// Mock-relay route in its in-file tests; production publish goes through
|
|
// the guarded boundary-1 funnel (`submit_signed_event_at_with_keys`).
|
|
("src/commands/personas/sharing.rs", 1, 0),
|
|
];
|
|
|
|
// Needles are assembled at runtime so this scan file itself contains no
|
|
// contiguous match and needs no self-referential inventory row.
|
|
fn events_needle() -> String {
|
|
["/ev", "ents"].concat()
|
|
}
|
|
fn guard_needle() -> String {
|
|
["egress_guard::", "assert_no_key_backup"].concat()
|
|
}
|
|
|
|
/// Pure scan core over `(relative path, content)` pairs. Returns violations;
|
|
/// empty means every file matches its inventory row exactly (files absent
|
|
/// from the table are expected to have zero `/events` sites and zero guard
|
|
/// calls).
|
|
fn events_inventory_violations(files: &[(String, String)]) -> Vec<String> {
|
|
let events = events_needle();
|
|
let guard = guard_needle();
|
|
let mut violations = Vec::new();
|
|
|
|
for (rel, content) in files {
|
|
let expected = EVENTS_INVENTORY
|
|
.iter()
|
|
.find(|(suffix, _, _)| rel.ends_with(suffix))
|
|
.map(|&(_, e, g)| (e, g))
|
|
.unwrap_or((0, 0));
|
|
|
|
let mut event_sites = Vec::new();
|
|
for (i, line) in content.lines().enumerate() {
|
|
if line.trim_start().starts_with("//") {
|
|
continue; // doc/comment mentions
|
|
}
|
|
if line.contains(&events) {
|
|
event_sites.push(format!(" {rel}:{}: {}", i + 1, line.trim()));
|
|
}
|
|
}
|
|
let guard_count = content.matches(&guard).count();
|
|
|
|
if (event_sites.len(), guard_count) != expected {
|
|
violations.push(format!(
|
|
"{rel}: found {} events-URL site(s) + {} guard call(s), inventory \
|
|
expects {} + {}. Sites found:\n{}",
|
|
event_sites.len(),
|
|
guard_count,
|
|
expected.0,
|
|
expected.1,
|
|
if event_sites.is_empty() {
|
|
" (none)".to_string()
|
|
} else {
|
|
event_sites.join("\n")
|
|
},
|
|
));
|
|
}
|
|
}
|
|
violations
|
|
}
|
|
|
|
fn read_src_files() -> Vec<(String, String)> {
|
|
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
|
src_rust_files()
|
|
.into_iter()
|
|
.map(|path| {
|
|
let rel = path
|
|
.strip_prefix(root)
|
|
.unwrap()
|
|
.to_string_lossy()
|
|
.replace('\\', "/");
|
|
let content = std::fs::read_to_string(&path).unwrap();
|
|
(rel, content)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Inventory completeness: every `/events` URL-construction site in
|
|
/// `desktop/src-tauri/src` must match the site-granular inventory above. A
|
|
/// future ninth submission path — in a NEW file or an ALREADY-LISTED one —
|
|
/// fails this test until its guard is wired, its injection test exists, and
|
|
/// its inventory row is updated.
|
|
#[test]
|
|
fn events_url_inventory_is_fully_guarded() {
|
|
let violations = events_inventory_violations(&read_src_files());
|
|
assert!(
|
|
violations.is_empty(),
|
|
"events-URL egress inventory drift — wire crate::egress_guard, add an \
|
|
injection test, then update EVENTS_INVENTORY:\n{}",
|
|
violations.join("\n")
|
|
);
|
|
}
|
|
|
|
/// Mutation-style proof of the tripwire's guarantee: an unguarded ninth
|
|
/// `/events` site added to an already-inventoried file (relay.rs) is caught.
|
|
#[test]
|
|
fn inventory_scan_catches_new_site_in_allowlisted_file() {
|
|
let mut files = read_src_files();
|
|
let relay = files
|
|
.iter_mut()
|
|
.find(|(rel, _)| rel.ends_with("src/relay.rs"))
|
|
.expect("relay.rs must be in the scan set");
|
|
relay.1.push_str(&format!(
|
|
"\nfn sneaky_ninth_site(base: &str) -> String {{ format!(\"{{base}}{}\") }}\n",
|
|
events_needle()
|
|
));
|
|
let violations = events_inventory_violations(&files);
|
|
assert!(
|
|
violations.iter().any(|v| v.contains("src/relay.rs")),
|
|
"an unguarded ninth events-URL site in relay.rs must trip the scan: {violations:?}"
|
|
);
|
|
}
|
|
|
|
/// The pairing also fires in reverse: a guard call deleted while its egress
|
|
/// site remains is caught.
|
|
#[test]
|
|
fn inventory_scan_catches_removed_guard_call() {
|
|
let mut files = read_src_files();
|
|
let relay = files
|
|
.iter_mut()
|
|
.find(|(rel, _)| rel.ends_with("src/relay.rs"))
|
|
.expect("relay.rs must be in the scan set");
|
|
relay.1 = relay.1.replacen(&guard_needle(), "removed_guard", 1);
|
|
let violations = events_inventory_violations(&files);
|
|
assert!(
|
|
violations.iter().any(|v| v.contains("src/relay.rs")),
|
|
"a removed guard call in relay.rs must trip the scan: {violations:?}"
|
|
);
|
|
}
|
|
|
|
/// A brand-new file with an `/events` site (no inventory row) is caught.
|
|
#[test]
|
|
fn inventory_scan_catches_new_unlisted_file() {
|
|
let mut files = read_src_files();
|
|
files.push((
|
|
"src/brand_new_egress.rs".to_string(),
|
|
format!("let url = format!(\"{{}}{}\", base);", events_needle()),
|
|
));
|
|
let violations = events_inventory_violations(&files);
|
|
assert!(
|
|
violations
|
|
.iter()
|
|
.any(|v| v.contains("src/brand_new_egress.rs")),
|
|
"{violations:?}"
|
|
);
|
|
}
|
|
|
|
/// Source allowlist: NIP-49 material handling is confined to the identity /
|
|
/// backup / import / guard files. Anything else touching ncryptsec or the
|
|
/// nip49 codec is structural drift.
|
|
#[test]
|
|
fn ncryptsec_handling_is_confined_to_allowlisted_files() {
|
|
let allowlist: &[&str] = &[
|
|
"src/key_backup.rs",
|
|
"src/key_backup_tests.rs",
|
|
"src/egress_guard.rs",
|
|
"src/egress_guard_tests.rs",
|
|
"src/commands/identity.rs",
|
|
"src/commands/identity_key_backup_tests.rs",
|
|
"src/lib.rs", // module registration + invoke handler
|
|
// boundary wiring (guard call sites name the module, not the codec):
|
|
"src/relay.rs",
|
|
"src/relay/submit.rs",
|
|
"src/huddle/pipeline.rs",
|
|
"src/commands/team_snapshot.rs",
|
|
"src/commands/team_snapshot/tests.rs",
|
|
"src/commands/personas/snapshot/import.rs",
|
|
"src/native_websocket.rs",
|
|
];
|
|
|
|
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
|
let mut violations = Vec::new();
|
|
for path in src_rust_files() {
|
|
let rel = path
|
|
.strip_prefix(root)
|
|
.unwrap()
|
|
.to_string_lossy()
|
|
.replace('\\', "/");
|
|
if allowlist.iter().any(|a| rel.ends_with(a)) {
|
|
continue;
|
|
}
|
|
let content = std::fs::read_to_string(&path).unwrap();
|
|
for needle in ["ncryptsec", "EncryptedSecretKey", "nip49"] {
|
|
if content.contains(needle) {
|
|
violations.push(format!("{rel}: contains {needle:?}"));
|
|
}
|
|
}
|
|
}
|
|
assert!(
|
|
violations.is_empty(),
|
|
"NIP-49 material outside allowlisted files:\n{}",
|
|
violations.join("\n")
|
|
);
|
|
}
|