From 9830f444ac0f5c4e2a555752b489c5a4f2dde00f Mon Sep 17 00:00:00 2001 From: sergeych Date: Sun, 16 Aug 2026 15:57:03 +0400 Subject: [PATCH] Add browser-hosted TrustLab security surface --- trustlab/README.md | 10 +- trustlab/package.json | 3 +- trustlab/plugins/demo-plugins.js | 90 ++++++++++ trustlab/test/ui.test.js | 50 ++++++ trustlab/ui/app.js | 159 +++++++++++++++++ trustlab/ui/dev-server.js | 54 ++++++ trustlab/ui/index.html | 78 +++++++++ trustlab/ui/model.js | 55 ++++++ trustlab/ui/styles.css | 286 +++++++++++++++++++++++++++++++ 9 files changed, 782 insertions(+), 3 deletions(-) create mode 100644 trustlab/plugins/demo-plugins.js create mode 100644 trustlab/test/ui.test.js create mode 100644 trustlab/ui/app.js create mode 100644 trustlab/ui/dev-server.js create mode 100644 trustlab/ui/index.html create mode 100644 trustlab/ui/model.js create mode 100644 trustlab/ui/styles.css diff --git a/trustlab/README.md b/trustlab/README.md index d48329c..0a17e77 100644 --- a/trustlab/README.md +++ b/trustlab/README.md @@ -17,14 +17,20 @@ Run: ```sh npm test npm run demo +npm run ui ``` +`npm run ui` serves the browser-hosted testbed at `http://127.0.0.1:4173`. +The page uses synthetic TLS records and the same portable TrustLab runner used +by the tests. It does not make real TLS decisions. + ## Current protocol boundary Plugins may return evidence, warnings, and a scoped Boolean trust verdict. They cannot access Node.js facilities through the protocol, mutate TLS facts or journal entries, or perform browser actions. -This first slice intentionally omits persistence, package signatures, -interactive UI, community identities, networking, and real X.509 parsing. +This first slice intentionally omits persistence, package signatures, community +identities, networking, and real X.509 parsing. The included interactive UI is a +security-surface prototype, not browser integration. diff --git a/trustlab/package.json b/trustlab/package.json index 73f2b27..407d783 100644 --- a/trustlab/package.json +++ b/trustlab/package.json @@ -6,7 +6,8 @@ "description": "Browser-neutral reference runner for Browsec trust plugins", "scripts": { "test": "node --test --test-isolation=none", - "demo": "node examples/demo.js" + "demo": "node examples/demo.js", + "ui": "node ui/dev-server.js" }, "engines": { "node": ">=22" diff --git a/trustlab/plugins/demo-plugins.js b/trustlab/plugins/demo-plugins.js new file mode 100644 index 0000000..e1ee104 --- /dev/null +++ b/trustlab/plugins/demo-plugins.js @@ -0,0 +1,90 @@ +export function createFirefoxValidationPlugin() { + return { + manifest: { + id: "org.browsec.firefox-validation", + name: "Firefox validation", + role: "advisor", + }, + collectEvidence({ facts }) { + if (facts.validation === "success") { + return { + entries: [ + { + kind: "vote", + code: "firefox-validation-succeeded", + message: "Firefox constructed a valid path to a configured trust anchor.", + }, + ], + }; + } + + return { + entries: facts.errors.map((error) => ({ + kind: "warning", + code: error, + message: explainValidationError(error), + })), + }; + }, + }; +} + +export function createVillageCommunityPlugin() { + return { + manifest: { + id: "community.village.observer", + name: "Village community observer", + role: "advisor", + }, + collectEvidence({ facts }) { + if (facts.hostname !== "library.village") return; + return { + entries: [ + { + kind: "evidence", + code: "community-key-continuity", + message: "This certificate has appeared in the synthetic community record for 184 days.", + data: { observers: 7, independentOperators: 3, ageDays: 184 }, + }, + { + kind: "vote", + code: "community-recommends-trust", + message: "The configured community recommends trusting this exact host certificate.", + }, + ], + }; + }, + }; +} + +export function createUserDecisionPlugin(decision) { + if (decision !== true && decision !== false) return undefined; + return { + manifest: { + id: "local.user.decision", + name: "Local user decision", + role: "decision-authority", + }, + decide({ facts, journal }) { + return { + trusted: decision, + scope: { hostname: facts.hostname, port: facts.port }, + reasonEntryIds: journal.entries + .filter((entry) => entry.kind === "evidence" || entry.kind === "warning") + .map((entry) => entry.id), + }; + }, + }; +} + +function explainValidationError(error) { + const explanations = { + "unknown-issuer": + "Firefox cannot construct a path from this certificate to a configured trust anchor.", + expired: "At least one certificate in the validation path is outside its validity period.", + "hostname-mismatch": + "The leaf certificate does not identify the requested hostname.", + }; + return explanations[error] ?? `Firefox reported certificate error: ${error}.`; +} + diff --git a/trustlab/test/ui.test.js b/trustlab/test/ui.test.js new file mode 100644 index 0000000..0f97477 --- /dev/null +++ b/trustlab/test/ui.test.js @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +import { unknownLocalAuthority } from "../fixtures/tls.js"; +import { + createFirefoxValidationPlugin, + createUserDecisionPlugin, + createVillageCommunityPlugin, +} from "../plugins/demo-plugins.js"; +import { TrustRunner } from "../src/index.js"; +import { chainRows, subjectName, verdictCopy } from "../ui/model.js"; + +test("UI model identifies the failed end of an unknown-authority chain", () => { + const rows = chainRows(unknownLocalAuthority); + assert.equal(rows.length, 2); + assert.equal(rows[0].name, "library.village"); + assert.equal(rows.at(-1).failed, true); + assert.match(rows.at(-1).edge, /Not anchored/); +}); + +test("certificate display names prefer the common name", () => { + assert.equal(subjectName("O=Village,CN=Library CA,C=GE"), "Library CA"); + assert.equal(subjectName("O=Nameless"), "O=Nameless"); +}); + +test("UI plugins produce attributed evidence and a local Boolean verdict", async () => { + const plugins = [ + createFirefoxValidationPlugin(), + createVillageCommunityPlugin(), + createUserDecisionPlugin(true), + ]; + const result = await new TrustRunner({ plugins }).evaluate(unknownLocalAuthority); + + assert.equal(result.verdict.trusted, true); + assert.ok(result.journal.entries.some((entry) => entry.code === "unknown-issuer")); + assert.ok( + result.journal.entries.some((entry) => entry.code === "community-key-continuity"), + ); + assert.equal(verdictCopy(result, true).title, "You trust this connection"); +}); + +test("security surface contains immutable-frame and simulation labels", async () => { + const html = await readFile(new URL("../ui/index.html", import.meta.url), "utf8"); + assert.match(html, /Browsec security decision/); + assert.match(html, /Browser-owned test surface/); + assert.match(html, /TRUSTLAB · SYNTHETIC/); + assert.match(html, /It cannot alter browser trust/); +}); + diff --git a/trustlab/ui/app.js b/trustlab/ui/app.js new file mode 100644 index 0000000..802902c --- /dev/null +++ b/trustlab/ui/app.js @@ -0,0 +1,159 @@ +import { unknownLocalAuthority, validPublicCertificate } from "../fixtures/tls.js"; +import { + createFirefoxValidationPlugin, + createUserDecisionPlugin, + createVillageCommunityPlugin, +} from "../plugins/demo-plugins.js"; +import { TrustRunner } from "../src/index.js"; +import { chainRows, verdictCopy } from "./model.js"; + +const scenarios = { + "unknown-local": { + label: "Unknown village authority", + facts: unknownLocalAuthority, + }, + "valid-public": { + label: "Valid conventional path", + facts: validPublicCertificate, + }, +}; + +const state = { + scenario: "unknown-local", + userDecision: undefined, + communityEnabled: true, +}; + +const elements = { + scenario: document.querySelector("#scenario"), + community: document.querySelector("#community-enabled"), + status: document.querySelector("#status"), + identity: document.querySelector("#identity"), + chain: document.querySelector("#chain"), + journal: document.querySelector("#journal"), + trust: document.querySelector("#trust"), + reject: document.querySelector("#reject"), + clear: document.querySelector("#clear-decision"), +}; + +for (const [value, scenario] of Object.entries(scenarios)) { + const option = document.createElement("option"); + option.value = value; + option.textContent = scenario.label; + elements.scenario.append(option); +} + +elements.scenario.addEventListener("change", () => { + state.scenario = elements.scenario.value; + state.userDecision = undefined; + render(); +}); +elements.community.addEventListener("change", () => { + state.communityEnabled = elements.community.checked; + render(); +}); +elements.trust.addEventListener("click", () => { + state.userDecision = true; + render(); +}); +elements.reject.addEventListener("click", () => { + state.userDecision = false; + render(); +}); +elements.clear.addEventListener("click", () => { + state.userDecision = undefined; + render(); +}); + +async function render() { + const facts = scenarios[state.scenario].facts; + const plugins = [createFirefoxValidationPlugin()]; + if (state.communityEnabled) plugins.push(createVillageCommunityPlugin()); + const userPlugin = createUserDecisionPlugin(state.userDecision); + if (userPlugin) plugins.push(userPlugin); + + const result = await new TrustRunner({ plugins }).evaluate(facts); + renderStatus(result); + renderIdentity(result); + renderChain(result); + renderJournal(result); + elements.clear.hidden = state.userDecision === undefined; +} + +function renderStatus(result) { + const copy = verdictCopy(result, state.userDecision !== undefined); + elements.status.dataset.state = result.verdict.trusted ? "trusted" : "not-trusted"; + elements.status.replaceChildren( + node("p", copy.eyebrow, "eyebrow"), + node("h1", copy.title), + node("p", copy.detail, "status-detail"), + ); +} + +function renderIdentity(result) { + elements.identity.replaceChildren( + definition("Requested host", result.facts.hostname), + definition("Port", String(result.facts.port)), + definition("Firefox result", result.facts.validation), + definition("TLS", result.facts.tls.version ?? "Unknown"), + ); +} + +function renderChain(result) { + elements.chain.replaceChildren( + ...chainRows(result.facts).map((certificate, index, all) => { + const item = document.createElement("li"); + item.className = certificate.failed ? "certificate failed" : "certificate"; + item.append( + node("span", certificate.role, "certificate-role"), + node("strong", certificate.name), + node("code", certificate.sha256 ?? "No fingerprint"), + node("span", certificate.edge, "edge-label"), + ); + item.setAttribute("aria-label", `${certificate.role}: ${certificate.name}. ${certificate.edge}`); + if (index < all.length - 1) item.dataset.linked = "true"; + return item; + }), + ); +} + +function renderJournal(result) { + if (result.journal.entries.length === 0) { + elements.journal.replaceChildren(node("p", "No plugin findings.", "empty")); + return; + } + + elements.journal.replaceChildren( + ...result.journal.entries.map((entry) => { + const article = document.createElement("article"); + article.className = `journal-entry kind-${entry.kind}`; + article.append( + node("span", entry.kind, "entry-kind"), + node("h3", entry.pluginName), + node("p", entry.message ?? formatCode(entry.code)), + node("code", entry.code ?? entry.id), + ); + return article; + }), + ); +} + +function definition(term, value) { + const wrapper = document.createElement("div"); + wrapper.append(node("dt", term), node("dd", value)); + return wrapper; +} + +function node(tag, text, className) { + const element = document.createElement(tag); + element.textContent = text; + if (className) element.className = className; + return element; +} + +function formatCode(value = "") { + return value.replaceAll("-", " "); +} + +render(); + diff --git a/trustlab/ui/dev-server.js b/trustlab/ui/dev-server.js new file mode 100644 index 0000000..ecc6143 --- /dev/null +++ b/trustlab/ui/dev-server.js @@ -0,0 +1,54 @@ +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; +import { extname, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +const trustlabRoot = resolve(fileURLToPath(new URL("..", import.meta.url))); +const host = "127.0.0.1"; +const port = Number.parseInt(process.env.TRUSTLAB_PORT ?? "4173", 10); +const contentTypes = { + ".css": "text/css; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", +}; + +const server = createServer(async (request, response) => { + try { + const url = new URL(request.url ?? "/", `http://${host}:${port}`); + const relativePath = url.pathname === "/" ? "ui/index.html" : url.pathname.slice(1); + const requestedPath = resolve(trustlabRoot, relativePath); + if (!requestedPath.startsWith(`${trustlabRoot}${sep}`)) { + respond(response, 403, "Forbidden"); + return; + } + + const body = await readFile(requestedPath); + response.writeHead(200, { + "Content-Type": contentTypes[extname(requestedPath)] ?? "application/octet-stream", + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + "Content-Security-Policy": "default-src 'self'; style-src 'self'; script-src 'self'; object-src 'none'; frame-ancestors 'none'", + }); + response.end(body); + } catch (error) { + if (error?.code === "ENOENT" || error?.code === "EISDIR") { + respond(response, 404, "Not found"); + return; + } + respond(response, 500, "Internal server error"); + } +}); + +server.listen(port, host, () => { + console.log(`TrustLab UI: http://${host}:${port}`); +}); + +function respond(response, status, message) { + response.writeHead(status, { + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "no-store", + }); + response.end(message); +} + diff --git a/trustlab/ui/index.html b/trustlab/ui/index.html new file mode 100644 index 0000000..4273230 --- /dev/null +++ b/trustlab/ui/index.html @@ -0,0 +1,78 @@ + + + + + + + Browsec TrustLab + + + +
+ +
+ Browsec security decision + Browser-owned test surface +
+
TRUSTLAB · SYNTHETIC
+
+ +
+ + +
+
+ +
+
+

Connection

+

Identity under examination

+
+
+
+ +
+
+

Validation path

+

Where trust succeeds or breaks

+
+
    +
    + +
    +
    +

    Append-only journal

    +

    Attributed findings

    +
    +
    +
    +
    +
    + + + + + + + diff --git a/trustlab/ui/model.js b/trustlab/ui/model.js new file mode 100644 index 0000000..7296a08 --- /dev/null +++ b/trustlab/ui/model.js @@ -0,0 +1,55 @@ +export function subjectName(subject = "") { + const commonName = /(?:^|,)CN=([^,]+)/.exec(subject)?.[1]; + return commonName ?? (subject || "Unnamed certificate"); +} + +export function verdictCopy(result, hasUserDecision) { + if (hasUserDecision) { + return result.verdict.trusted + ? { + eyebrow: "Local decision", + title: "You trust this connection", + detail: "The decision is limited to the displayed host and port in this simulation.", + } + : { + eyebrow: "Local decision", + title: "You do not trust this connection", + detail: "The connection remains blocked by your explicit decision.", + }; + } + + if (result.facts.validation === "success") { + return { + eyebrow: "Firefox validation", + title: "The conventional certificate path is valid", + detail: "Trust plugins may still add evidence, warnings, or a stricter local verdict.", + }; + } + + return { + eyebrow: "Decision required", + title: "Firefox could not verify this identity", + detail: "Review the broken path and attributed plugin findings before deciding.", + }; +} + +export function chainRows(facts) { + const chain = facts.constructedChain.length + ? facts.constructedChain + : facts.presentedChain; + const failureAtEnd = facts.validation === "failure"; + + return chain.map((certificate, index) => ({ + ...certificate, + name: subjectName(certificate.subject), + role: + index === 0 ? "Leaf certificate" : index === chain.length - 1 ? "Root candidate" : "Intermediate CA", + edge: + index === chain.length - 1 + ? failureAtEnd + ? "Not anchored in current Firefox trust" + : "Trusted by current Firefox policy" + : "Signature links to next issuer", + failed: index === chain.length - 1 && failureAtEnd, + })); +} diff --git a/trustlab/ui/styles.css b/trustlab/ui/styles.css new file mode 100644 index 0000000..da9e344 --- /dev/null +++ b/trustlab/ui/styles.css @@ -0,0 +1,286 @@ +:root { + color: #eef4f7; + background: #091014; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-synthesis: none; + --ink-muted: #9baeb8; + --panel: #111c22; + --panel-raised: #17252d; + --line: #2c3c45; + --cyan: #75d9e9; + --cyan-dark: #123d48; + --amber: #f0b85b; + --red: #ff8178; + --green: #7bddad; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + min-width: 320px; + min-height: 100vh; + background: + radial-gradient(circle at 75% -20%, #173843 0, transparent 42rem), + #091014; +} + +button, select { font: inherit; } + +.browser-frame { + position: sticky; + z-index: 10; + top: 0; + display: flex; + align-items: center; + gap: 0.85rem; + min-height: 4.4rem; + padding: 0.75rem 1.25rem; + border-bottom: 3px solid var(--cyan); + background: #071319f2; + box-shadow: 0 0.5rem 2rem #0008; + backdrop-filter: blur(12px); +} + +.browser-frame strong, .browser-frame span { display: block; } +.browser-frame span { color: var(--ink-muted); font-size: 0.78rem; } + +.brand-mark { + display: grid; + width: 2.45rem; + height: 2.45rem; + place-items: center; + border: 1px solid var(--cyan); + border-radius: 50%; + color: #071319; + background: var(--cyan); + font-weight: 900; +} + +.frame-seal { + margin-left: auto; + padding: 0.38rem 0.6rem; + border: 1px solid #3d6975; + border-radius: 0.25rem; + color: var(--cyan); + font: 700 0.66rem/1.1 ui-monospace, monospace; + letter-spacing: 0.12em; +} + +main { + display: grid; + grid-template-columns: minmax(14rem, 19rem) minmax(0, 60rem); + gap: clamp(1rem, 4vw, 4rem); + max-width: 88rem; + margin: 0 auto; + padding: clamp(1.5rem, 4vw, 4rem) clamp(1rem, 3vw, 3rem) 8rem; +} + +.controls { + align-self: start; + padding: 1.2rem; + border: 1px solid var(--line); + border-radius: 0.5rem; + background: #0c171c; +} + +.controls label:not(.toggle) { + display: block; + margin: 0.8rem 0 0.4rem; + color: var(--ink-muted); + font-size: 0.78rem; +} + +select { + width: 100%; + padding: 0.7rem; + border: 1px solid #3b515c; + border-radius: 0.3rem; + color: inherit; + background: #142229; +} + +.toggle { + display: flex; + gap: 0.65rem; + align-items: center; + margin-top: 1.2rem; + color: #cfdae0; + font-size: 0.85rem; +} + +.toggle input { accent-color: var(--cyan); } + +.simulation-note { + margin: 1.5rem 0 0; + padding-top: 1rem; + border-top: 1px solid var(--line); + color: var(--ink-muted); + font-size: 0.75rem; + line-height: 1.55; +} + +.workspace { min-width: 0; } + +.status-card, .panel { + margin-bottom: 1rem; + border: 1px solid var(--line); + border-radius: 0.5rem; + background: linear-gradient(145deg, #142129, #0f191e); + box-shadow: 0 1.1rem 2.5rem #0003; +} + +.status-card { + position: relative; + overflow: hidden; + padding: clamp(1.5rem, 4vw, 2.6rem); +} + +.status-card::before { + position: absolute; + inset: 0 auto 0 0; + width: 0.35rem; + background: var(--amber); + content: ""; +} + +.status-card[data-state="trusted"]::before { background: var(--green); } +.status-card[data-state="not-trusted"]::before { background: var(--red); } + +.status-card h1 { + max-width: 22ch; + margin: 0.3rem 0 0.65rem; + font-family: Georgia, "Times New Roman", serif; + font-size: clamp(1.8rem, 4vw, 3.2rem); + font-weight: 500; + line-height: 1.04; +} + +.status-detail { max-width: 62ch; color: var(--ink-muted); line-height: 1.55; } + +.panel { padding: clamp(1.1rem, 3vw, 2rem); } +.section-heading h2 { margin: 0.2rem 0 1.3rem; font-size: 1.15rem; } +.eyebrow { + margin: 0; + color: var(--cyan); + font: 700 0.68rem/1.3 ui-monospace, monospace; + letter-spacing: 0.13em; + text-transform: uppercase; +} + +.identity-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin: 0; + border-top: 1px solid var(--line); +} + +.identity-grid div { padding: 1rem 1rem 0 0; } +.identity-grid dt { color: var(--ink-muted); font-size: 0.72rem; } +.identity-grid dd { margin: 0.25rem 0 0; overflow-wrap: anywhere; } + +.chain { margin: 0; padding: 0; list-style: none; } +.certificate { + position: relative; + display: grid; + grid-template-columns: minmax(9rem, 0.7fr) minmax(12rem, 1fr) minmax(10rem, 1fr); + gap: 0.35rem 1rem; + padding: 1rem; + border: 1px solid #31434d; + border-radius: 0.35rem; + background: var(--panel-raised); +} + +.certificate + .certificate { margin-top: 1.8rem; } +.certificate + .certificate::before { + position: absolute; + top: -1.85rem; + left: 2rem; + width: 1px; + height: 1.8rem; + background: #54707c; + content: ""; +} + +.certificate + .certificate::after { + position: absolute; + top: -0.4rem; + left: 1.78rem; + color: #78919b; + content: "▾"; +} + +.certificate.failed { border-color: #a45250; background: #2b1c1d; } +.certificate-role { grid-column: 1; color: var(--ink-muted); font-size: 0.72rem; } +.certificate strong { grid-column: 1; } +.certificate code { grid-column: 2; color: #afc1ca; overflow-wrap: anywhere; } +.edge-label { grid-column: 3; grid-row: 1 / span 2; align-self: center; color: var(--green); font-size: 0.78rem; } +.failed .edge-label { color: var(--red); } + +.journal { display: grid; gap: 0.65rem; } +.journal-entry { + display: grid; + grid-template-columns: 5rem 1fr auto; + gap: 0.15rem 0.9rem; + align-items: baseline; + padding: 0.9rem 1rem; + border-left: 3px solid #54707c; + background: #0c161b; +} + +.journal-entry.kind-warning { border-color: var(--amber); } +.journal-entry.kind-resolution { border-color: var(--cyan); } +.journal-entry h3, .journal-entry p { margin: 0; } +.journal-entry h3 { font-size: 0.86rem; } +.journal-entry p { grid-column: 2; color: var(--ink-muted); font-size: 0.8rem; } +.journal-entry code { grid-column: 3; grid-row: 1; color: #78919b; font-size: 0.68rem; } +.entry-kind { color: var(--cyan); font: 700 0.62rem ui-monospace, monospace; text-transform: uppercase; } +.empty { color: var(--ink-muted); } + +.decision-bar { + position: fixed; + z-index: 20; + right: 0; + bottom: 0; + left: 0; + display: flex; + gap: 0.7rem; + align-items: center; + min-height: 5rem; + padding: 0.85rem clamp(1rem, 3vw, 3rem); + border-top: 1px solid #38525e; + background: #081116f5; + box-shadow: 0 -1rem 3rem #0008; + backdrop-filter: blur(14px); +} + +.decision-bar div { margin-right: auto; } +.decision-bar strong, .decision-bar span { display: block; } +.decision-bar span { margin-top: 0.2rem; color: var(--ink-muted); font-size: 0.72rem; } +.button { + padding: 0.72rem 1rem; + border: 1px solid transparent; + border-radius: 0.3rem; + color: #eef4f7; + background: #26363e; + cursor: pointer; +} +.button:hover { filter: brightness(1.13); } +.button:focus-visible { outline: 3px solid white; outline-offset: 2px; } +.button.trust { color: #071319; background: var(--green); font-weight: 800; } +.button.reject { border-color: #985653; background: #321e1e; } +.button.quiet { border-color: #3d515b; background: transparent; } + +@media (max-width: 760px) { + main { grid-template-columns: 1fr; } + .controls { position: static; } + .identity-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .certificate { grid-template-columns: 1fr; } + .certificate code, .edge-label { grid-column: 1; grid-row: auto; } + .journal-entry { grid-template-columns: 4.5rem 1fr; } + .journal-entry code { display: none; } + .decision-bar { flex-wrap: wrap; } + .decision-bar div { flex-basis: 100%; } + .button { flex: 1; } +} +