458 lines
20 KiB
Python
458 lines
20 KiB
Python
"""Pinned patch and live-process security tests for the WebUI HUX BFF."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from contextlib import contextmanager
|
|
from email.message import Message
|
|
import hashlib
|
|
import hmac
|
|
import http.client
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
import importlib.util
|
|
import io
|
|
from pathlib import Path
|
|
import shutil
|
|
import sys
|
|
import threading
|
|
import time
|
|
from types import ModuleType, SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
|
|
ROOT = Path(__file__).parents[2]
|
|
PATCHER = ROOT / "dockerfiles/hermes-webui-hux-bff-patch.py"
|
|
FIXTURE = ROOT / "testing/fixtures/hermes-webui-0.52.181"
|
|
FIXTURE_ROUTES_SHA = "099f961f023342a3284719de024ad7c2e767e7c84f0fbfd6d7240d792ebf0903"
|
|
TEST_CONTEXT_KEY = b"k" * 32
|
|
TEST_SUBJECT = "usr_" + hmac.new(TEST_CONTEXT_KEY, b"hux.subject.id.v1\0slot-3",
|
|
hashlib.sha256).hexdigest()
|
|
|
|
|
|
def load_patcher(name: str = "hermes_hux_bff_patch"):
|
|
spec = importlib.util.spec_from_file_location(name, PATCHER)
|
|
assert spec and spec.loader
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
def fixture_tree(tmp_path: Path) -> Path:
|
|
target = tmp_path / "webui"
|
|
shutil.copytree(FIXTURE, target)
|
|
routes = target / "api/routes.py"
|
|
source = routes.read_text()
|
|
pinned_post = ' """Pinned POST route anchors for the Atlas voice patch."""'
|
|
upstream_post = ' """Handle all POST routes. Returns True if handled, False for 404."""'
|
|
assert source.count(pinned_post) == 1
|
|
source = source.replace(pinned_post, upstream_post, 1)
|
|
for method in ("PUT", "PATCH", "DELETE"):
|
|
source += (f"\n\ndef handle_{method.lower()}(handler, parsed) -> bool:\n"
|
|
f' """Handle all {method} routes. Returns True if handled, False for 404."""\n'
|
|
" return False\n")
|
|
routes.write_text(source)
|
|
return target
|
|
|
|
|
|
class UpstreamHandler(BaseHTTPRequestHandler):
|
|
protocol_version = "HTTP/1.1"
|
|
status = 200
|
|
response_headers = {"Content-Type": "application/json"}
|
|
response_body = b'{"ok":true}'
|
|
delay = 0.0
|
|
requests: list[dict] = []
|
|
|
|
def _run(self):
|
|
length = int(self.headers.get("Content-Length") or 0)
|
|
body = self.rfile.read(length) if length else b""
|
|
type(self).requests.append({"method": self.command, "path": self.path,
|
|
"headers": dict(self.headers.items()), "body": body})
|
|
if type(self).delay:
|
|
time.sleep(type(self).delay)
|
|
self.send_response(type(self).status)
|
|
headers = dict(type(self).response_headers)
|
|
if "Content-Length" not in headers:
|
|
headers["Content-Length"] = str(len(type(self).response_body))
|
|
for name, value in headers.items():
|
|
self.send_header(name, value)
|
|
self.end_headers()
|
|
self.wfile.write(type(self).response_body)
|
|
|
|
do_GET = do_POST = do_PUT = do_PATCH = do_DELETE = _run
|
|
|
|
def log_message(self, *_args):
|
|
return
|
|
|
|
|
|
class ReusableServer(ThreadingHTTPServer):
|
|
allow_reuse_address = True
|
|
daemon_threads = True
|
|
|
|
|
|
@contextmanager
|
|
def upstream(**values):
|
|
previous = {name: getattr(UpstreamHandler, name) for name in
|
|
("status", "response_headers", "response_body", "delay")}
|
|
UpstreamHandler.requests = []
|
|
for name, value in values.items():
|
|
setattr(UpstreamHandler, name, value)
|
|
server = ReusableServer(("127.0.0.1", 8790), UpstreamHandler)
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
try:
|
|
yield UpstreamHandler
|
|
finally:
|
|
server.shutdown()
|
|
server.server_close()
|
|
thread.join(timeout=2)
|
|
for name, value in previous.items():
|
|
setattr(UpstreamHandler, name, value)
|
|
|
|
|
|
def auth_modules(monkeypatch, *, info=None, valid_cookie="valid.cookie", valid_csrf="csrf-value"):
|
|
package = ModuleType("api")
|
|
package.__path__ = []
|
|
auth = ModuleType("api.auth")
|
|
auth.CSRF_HEADER_NAME = "X-Hermes-CSRF-Token"
|
|
auth.parse_cookie = lambda handler: valid_cookie if "session=valid" in handler.headers.get("Cookie", "") else None
|
|
auth.verify_session = lambda cookie: cookie == valid_cookie
|
|
auth.ensure_trusted_auth_session = lambda _handler: info or {
|
|
"auth_type": "trusted", "username": "slot-3"}
|
|
auth.verify_csrf_token = lambda cookie, token: cookie == valid_cookie and token == valid_csrf
|
|
package.auth = auth
|
|
monkeypatch.setitem(sys.modules, "api", package)
|
|
monkeypatch.setitem(sys.modules, "api.auth", auth)
|
|
|
|
|
|
def relay_key(module, tmp_path: Path, value="relay_key_0123456789abcdef0123456789abcdef") -> Path:
|
|
directory = tmp_path / "webui-only"
|
|
directory.mkdir(mode=0o700)
|
|
directory.chmod(0o700)
|
|
key = directory / "relay-key"
|
|
key.write_text(value)
|
|
key.chmod(0o400)
|
|
module.RELAY_KEY_FILE = key
|
|
context_key = directory / "context-key"
|
|
context_key.write_bytes(TEST_CONTEXT_KEY)
|
|
context_key.chmod(0o600)
|
|
module.CONTEXT_KEY_FILE = context_key
|
|
return key
|
|
|
|
|
|
@contextmanager
|
|
def bff_server(module):
|
|
class Handler(BaseHTTPRequestHandler):
|
|
protocol_version = "HTTP/1.1"
|
|
|
|
def _run(self):
|
|
from urllib.parse import urlsplit
|
|
module.proxy_hux(self, urlsplit(self.path), self.command)
|
|
|
|
do_GET = do_POST = do_PUT = do_PATCH = do_DELETE = _run
|
|
|
|
def log_message(self, *_args):
|
|
return
|
|
|
|
server = ReusableServer(("127.0.0.1", 0), Handler)
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
try:
|
|
yield server.server_address[1]
|
|
finally:
|
|
server.shutdown()
|
|
server.server_close()
|
|
thread.join(timeout=2)
|
|
|
|
|
|
def request(port: int, method="GET", path="/hux/v1/capabilities", body=None, headers=None):
|
|
connection = http.client.HTTPConnection("127.0.0.1", port, timeout=3)
|
|
base = {"Cookie": "session=valid", "X-Hermes-Tenant-Identity": "slot-3",
|
|
"X-Hux-Subject": TEST_SUBJECT}
|
|
base.update(headers or {})
|
|
base = {name: value for name, value in base.items() if value is not None}
|
|
connection.request(method, path, body=body, headers=base)
|
|
response = connection.getresponse()
|
|
payload = response.read()
|
|
result = response.status, dict(response.getheaders()), payload
|
|
connection.close()
|
|
return result
|
|
|
|
|
|
def test_pinned_fixture_patch_is_atomic_and_installs_all_methods(tmp_path: Path):
|
|
module = load_patcher("hux_bff_fixture")
|
|
assert hashlib.sha256((FIXTURE / "api/routes.py").read_bytes()).hexdigest() == FIXTURE_ROUTES_SHA
|
|
assert module.PINNED_UPSTREAM_COMMIT == "7a94e34a6d639576576baa9131acf6765f6d2b98"
|
|
target = fixture_tree(tmp_path)
|
|
module.apply(target)
|
|
routes = (target / "api/routes.py").read_text()
|
|
for method in ("GET", "POST", "PUT", "PATCH", "DELETE"):
|
|
assert routes.count(f'return proxy_hux(handler, parsed, "{method}")') == 1
|
|
installed = target / "api/hux_bff.py"
|
|
assert installed.read_text() == PATCHER.read_text()
|
|
with pytest.raises(SystemExit, match="already installed"):
|
|
module.apply(target)
|
|
|
|
|
|
def test_pinned_drift_fails_before_writing_any_file(tmp_path: Path):
|
|
module = load_patcher("hux_bff_drift")
|
|
target = fixture_tree(tmp_path)
|
|
routes = target / "api/routes.py"
|
|
routes.write_text(routes.read_text().replace("Handle all PATCH routes", "Changed PATCH routes"))
|
|
before = routes.read_bytes()
|
|
with pytest.raises(SystemExit, match="patch context changed"):
|
|
module.apply(target)
|
|
assert routes.read_bytes() == before
|
|
assert not (target / "api/hux_bff.py").exists()
|
|
|
|
|
|
def test_target_body_and_header_validators_fail_closed():
|
|
module = load_patcher("hux_bff_validators")
|
|
assert module._header_values(SimpleNamespace(), "Missing") == []
|
|
assert module._header_values(SimpleNamespace(headers={}), "Missing") == []
|
|
assert module._header_values(SimpleNamespace(headers={"Present": 7}), "Present") == ["7"]
|
|
assert module._safe_target(SimpleNamespace(path="/hux/v1/memory", query="status=active&limit=20")) == \
|
|
"/hux/v1/memory?status=active&limit=20"
|
|
assert module._safe_target(SimpleNamespace(path="/hux/v1/search", query="q=kidney%20stone")) == \
|
|
"/hux/v1/search?q=kidney+stone"
|
|
bad_targets = [SimpleNamespace(path="/api/memory", query=""),
|
|
SimpleNamespace(path="/hux/v1/../memory", query=""),
|
|
SimpleNamespace(path="/hux/v1/memory", query="x=1&x=2"),
|
|
SimpleNamespace(path="/hux/v1/memory", query="=empty"),
|
|
SimpleNamespace(path="/hux/v1/memory", query="x=%2Fetc"),
|
|
SimpleNamespace(path="/hux/v1/memory", query="not-a-pair"),
|
|
SimpleNamespace(path="/hux/v1/memory", query="x=" + "a" * 4097)]
|
|
for target in bad_targets:
|
|
with pytest.raises(module.BffError) as error:
|
|
module._safe_target(target)
|
|
assert error.value.status == 400
|
|
|
|
def fake(headers, body=b""):
|
|
return SimpleNamespace(headers=headers, rfile=io.BytesIO(body), path="/hux/v1/memory")
|
|
|
|
valid = Message()
|
|
valid["Content-Length"] = "2"
|
|
valid["Content-Type"] = "application/json"
|
|
assert module._read_request(fake(valid, b"{}"), "POST") == b"{}"
|
|
|
|
class ShortReader(io.BytesIO):
|
|
def read(self, size=-1):
|
|
return super().read(min(size, 1))
|
|
|
|
assert module._read_request(SimpleNamespace(headers=valid, rfile=ShortReader(b"{}")), "POST") == b"{}"
|
|
with pytest.raises(module.BffError, match="Incomplete"):
|
|
module._read_request(fake(valid, b"{"), "POST")
|
|
empty = Message()
|
|
assert module._read_request(fake(empty), "GET") is None
|
|
cases = []
|
|
for name, value in (("Transfer-Encoding", "chunked"), ("Content-Length", "nope")):
|
|
headers = Message()
|
|
headers[name] = value
|
|
cases.append((headers, "POST", 400))
|
|
duplicate = Message()
|
|
duplicate["Content-Length"] = "1"
|
|
duplicate["Content-Length"] = "1"
|
|
cases.append((duplicate, "POST", 400))
|
|
get_body = Message()
|
|
get_body["Content-Length"] = "1"
|
|
get_body["Content-Type"] = "application/json"
|
|
cases.append((get_body, "GET", 400))
|
|
huge = Message()
|
|
huge["Content-Length"] = str(module.MAX_REQUEST_BYTES + 1)
|
|
cases.append((huge, "POST", 413))
|
|
wrong_type = Message()
|
|
wrong_type["Content-Length"] = "1"
|
|
wrong_type["Content-Type"] = "text/plain"
|
|
cases.append((wrong_type, "POST", 415))
|
|
for headers, method, status in cases:
|
|
with pytest.raises(module.BffError) as error:
|
|
module._read_request(fake(headers, b"x"), method)
|
|
assert error.value.status == status
|
|
assert module._request_limit("/hux/v1/artifacts", "POST") == module.MAX_ARTIFACT_REQUEST_BYTES
|
|
assert module._request_limit("/hux/v1/artifacts/art_1234/versions", "POST") == \
|
|
module.MAX_ARTIFACT_REQUEST_BYTES
|
|
assert module._request_limit("/hux/v1/memory", "POST") == module.MAX_REQUEST_BYTES
|
|
assert module._response_limit("/hux/v1/artifacts/art_1234/versions/1") == \
|
|
module.MAX_ARTIFACT_RESPONSE_BYTES
|
|
assert module._response_limit("/hux/v1/artifacts/art_1234/versions/2/diff?from=1") == \
|
|
module.MAX_ARTIFACT_RESPONSE_BYTES
|
|
|
|
|
|
def test_relay_key_requires_private_owned_regular_file(tmp_path: Path):
|
|
module = load_patcher("hux_bff_key")
|
|
key = relay_key(module, tmp_path)
|
|
assert module._secure_relay_key().startswith("relay_key_")
|
|
key.chmod(0o440)
|
|
with pytest.raises(module.BffError, match="unavailable"):
|
|
module._secure_relay_key()
|
|
key.chmod(0o400)
|
|
key.parent.chmod(0o750)
|
|
with pytest.raises(module.BffError):
|
|
module._secure_relay_key()
|
|
key.parent.chmod(0o700)
|
|
key.chmod(0o600)
|
|
key.write_bytes(b"\xff" * 40)
|
|
key.chmod(0o400)
|
|
with pytest.raises(module.BffError):
|
|
module._secure_relay_key()
|
|
key.chmod(0o600)
|
|
key.write_text("short")
|
|
key.chmod(0o400)
|
|
with pytest.raises(module.BffError):
|
|
module._secure_relay_key()
|
|
module.RELAY_KEY_FILE = tmp_path / "missing" / "key"
|
|
with pytest.raises(module.BffError):
|
|
module._secure_relay_key()
|
|
|
|
|
|
def test_subject_is_server_derived_from_exact_context_key(tmp_path: Path):
|
|
module = load_patcher("hux_bff_subject")
|
|
relay_key(module, tmp_path)
|
|
assert module._derived_subject("slot-3") == TEST_SUBJECT
|
|
assert module._derived_subject("slot-4") != TEST_SUBJECT
|
|
module.CONTEXT_KEY_FILE.chmod(0o440)
|
|
with pytest.raises(module.BffError, match="identity"):
|
|
module._derived_subject("slot-3")
|
|
module.CONTEXT_KEY_FILE = tmp_path / "missing"
|
|
with pytest.raises(module.BffError, match="identity"):
|
|
module._derived_subject("slot-3")
|
|
def test_live_get_injects_only_server_identity_and_safe_headers(monkeypatch, tmp_path: Path):
|
|
module = load_patcher("hux_bff_live_get")
|
|
auth_modules(monkeypatch)
|
|
relay_key(module, tmp_path)
|
|
upstream_headers = {"Content-Type": "application/vnd.hermes.hux+json; version=1",
|
|
"ETag": '"3"', "HUX-Replayed": "true", "HUX-Audit-Stale": "false",
|
|
"Content-Disposition": "attachment", "Set-Cookie": "upstream=bad"}
|
|
with upstream(response_headers=upstream_headers), bff_server(module) as port:
|
|
status, headers, body = request(port, path="/hux/v1/memory?limit=2", headers={
|
|
"Authorization": "Bearer oauth", "X-Hux-Relay-Key": "browser-forgery",
|
|
"X-Hux-Trust": "worker",
|
|
"X-Hux-Subject": None, "If-Match": "3", "Idempotency-Key": "browser:key:0001",
|
|
"Cookie": "session=valid; oauth=secret"})
|
|
assert status == 200 and body == b'{"ok":true}'
|
|
assert headers["Cache-Control"] == "no-store"
|
|
assert headers["X-Content-Type-Options"] == "nosniff"
|
|
assert headers["ETag"] == '"3"' and headers["HUX-Replayed"] == "true"
|
|
assert headers["HUX-Audit-Stale"] == "false" and headers["Content-Disposition"] == "attachment"
|
|
assert "Set-Cookie" not in headers and "Access-Control-Allow-Origin" not in headers
|
|
captured = UpstreamHandler.requests[0]
|
|
assert captured["headers"]["X-Hux-Subject"] == TEST_SUBJECT
|
|
assert captured["headers"]["X-Hux-Trust"] == "relay"
|
|
assert captured["headers"]["X-Hux-Surface"] == "chat"
|
|
assert captured["headers"]["X-Hermes-Tenant-Identity"] == "slot-3"
|
|
assert captured["headers"]["X-Hux-Relay-Key"].startswith("relay_key_")
|
|
lowered = {name.lower() for name in captured["headers"]}
|
|
assert not ({"authorization", "cookie", "x-hermes-csrf-token"} & lowered)
|
|
|
|
|
|
@pytest.mark.parametrize("headers,info", [
|
|
({"Cookie": "bad", "X-Hermes-Tenant-Identity": "slot-3"}, None),
|
|
({"Cookie": "session=valid", "X-Hermes-Tenant-Identity": "slot-3"},
|
|
{"auth_type": "local", "username": "slot-3"}),
|
|
({"Cookie": "session=valid", "X-Hermes-Tenant-Identity": "slot-4"},
|
|
{"auth_type": "trusted", "username": "slot-3"}),
|
|
({"Cookie": "session=valid", "X-Hermes-Tenant-Identity": "tenant"},
|
|
{"auth_type": "trusted", "username": "tenant"}),
|
|
({"Cookie": "session=valid", "X-Hermes-Tenant-Identity": "slot-3",
|
|
"X-Hux-Subject": "usr_too_short"},
|
|
{"auth_type": "trusted", "username": "slot-3"}),
|
|
])
|
|
def test_live_get_requires_exact_trusted_session(monkeypatch, tmp_path: Path, headers, info):
|
|
module = load_patcher("hux_bff_auth_" + hashlib.sha1(repr(headers).encode()).hexdigest())
|
|
auth_modules(monkeypatch, info=info)
|
|
relay_key(module, tmp_path)
|
|
with upstream(), bff_server(module) as port:
|
|
status, response_headers, _body = request(port, headers=headers)
|
|
assert status == 401
|
|
assert response_headers["Cache-Control"] == "no-store"
|
|
assert not UpstreamHandler.requests
|
|
|
|
|
|
def test_live_mutation_requires_csrf_and_preserves_valid_concurrency(monkeypatch, tmp_path: Path):
|
|
module = load_patcher("hux_bff_mutation")
|
|
auth_modules(monkeypatch)
|
|
relay_key(module, tmp_path)
|
|
payload = b'{"kind":"fact","identity":{"subject":"browser"}}'
|
|
with upstream(), bff_server(module) as port:
|
|
status, _, _ = request(port, method="POST", path="/hux/v1/memory", body=payload,
|
|
headers={"Content-Type": "application/json"})
|
|
assert status == 403 and not UpstreamHandler.requests
|
|
status, _, _ = request(port, method="POST", path="/hux/v1/memory", body=payload, headers={
|
|
"Content-Type": "application/json", "X-Hermes-CSRF-Token": "csrf-value",
|
|
"If-Match": "7", "Idempotency-Key": "memory:key:0001"})
|
|
assert status == 200
|
|
captured = UpstreamHandler.requests[0]
|
|
assert captured["method"] == "POST" and captured["body"] == payload
|
|
assert captured["headers"]["If-Match"] == "7"
|
|
assert captured["headers"]["Idempotency-Key"] == "memory:key:0001"
|
|
|
|
|
|
def test_live_invalid_concurrency_headers_and_methods_never_reach_hux(monkeypatch, tmp_path: Path):
|
|
module = load_patcher("hux_bff_invalid_headers")
|
|
auth_modules(monkeypatch)
|
|
relay_key(module, tmp_path)
|
|
with upstream(), bff_server(module) as port:
|
|
for name, value in (("If-Match", '"bad"'), ("Idempotency-Key", "short"),
|
|
("Last-Event-ID", "1 OR 1")):
|
|
status, _, _ = request(port, headers={name: value})
|
|
assert status == 400
|
|
status, _, _ = request(port, method="POST", body=b"{}", headers={
|
|
"Content-Type": "application/json", "X-Hermes-CSRF-Token": "csrf-value",
|
|
"Last-Event-ID": "2"})
|
|
assert status == 400
|
|
handler = SimpleNamespace(headers=Message(), path="/hux/v1/memory",
|
|
rfile=io.BytesIO(), wfile=io.BytesIO())
|
|
handler.send_response = lambda status: setattr(handler, "status", status)
|
|
handler.send_header = lambda *_args: None
|
|
handler.end_headers = lambda: None
|
|
assert module.proxy_hux(handler, SimpleNamespace(path="/hux/v1/memory", query=""), "TRACE")
|
|
assert handler.status == 405
|
|
assert not UpstreamHandler.requests
|
|
|
|
|
|
def test_live_response_bounds_redirects_content_types_and_outage(monkeypatch, tmp_path: Path):
|
|
module = load_patcher("hux_bff_response_guards")
|
|
auth_modules(monkeypatch)
|
|
relay_key(module, tmp_path)
|
|
with upstream(status=302, response_headers={"Content-Type": "application/json", "Location": "https://evil.test"}), \
|
|
bff_server(module) as port:
|
|
status, headers, _ = request(port)
|
|
assert status == 502 and "Location" not in headers
|
|
with upstream(response_headers={"Content-Type": "text/html"}), bff_server(module) as port:
|
|
assert request(port)[0] == 502
|
|
module.MAX_RESPONSE_BYTES = 8
|
|
with upstream(response_body=b"x" * 9), bff_server(module) as port:
|
|
assert request(port)[0] == 502
|
|
with bff_server(module) as port:
|
|
assert request(port)[0] == 502
|
|
|
|
|
|
def test_live_sse_is_bounded_uncached_and_preserves_numeric_cursor(monkeypatch, tmp_path: Path):
|
|
module = load_patcher("hux_bff_sse")
|
|
auth_modules(monkeypatch)
|
|
relay_key(module, tmp_path)
|
|
event = b"id: 2\nevent: run.completed\ndata: {}\n\n"
|
|
with upstream(response_headers={"Content-Type": "text/event-stream"}, response_body=event), \
|
|
bff_server(module) as port:
|
|
status, headers, body = request(port, path="/hux/v1/conversations/conv_alpha/events/stream",
|
|
headers={"Last-Event-ID": "1"})
|
|
assert status == 200 and body == event
|
|
assert headers["Cache-Control"] == "no-store" and headers["Connection"] == "close"
|
|
assert UpstreamHandler.requests[0]["headers"]["Last-Event-ID"] == "1"
|
|
module.MAX_STREAM_BYTES = 5
|
|
with upstream(response_headers={"Content-Type": "text/event-stream"}, response_body=event), \
|
|
bff_server(module) as port:
|
|
assert request(port, path="/hux/v1/conversations/conv_alpha/events/stream")[2] == b""
|
|
|
|
|
|
def test_patch_is_small_isolated_and_wired_once():
|
|
source = PATCHER.read_text()
|
|
assert len(source.splitlines()) < 500
|
|
assert "127.0.0.1" in source and "UPSTREAM_PORT = 8790" in source
|
|
assert "HTTPConnection(UPSTREAM_HOST, UPSTREAM_PORT" in source
|
|
assert "requests." not in source and "urlopen" not in source
|
|
assert "Authorization" not in source and '"Cookie"' not in source
|
|
assert "X-Hux-Relay-Key" in source and "0o400" in source and "O_NOFOLLOW" in source
|
|
dockerfile = (ROOT / "dockerfiles/Dockerfile.hermes-webui").read_text()
|
|
assert dockerfile.count(f"COPY dockerfiles/{PATCHER.name} /tmp/{PATCHER.name}") == 1
|
|
assert dockerfile.count(f"python /tmp/{PATCHER.name}") == 1
|