Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,19 @@ jobs:
pip install build
- name: Build sdist and wheel (hatchling)
run: python -m build
- name: Wheel contains the public stylesheet
run: |
python - <<'PY'
import glob
import zipfile

wheel = glob.glob("dist/*.whl")
if len(wheel) != 1:
raise SystemExit(f"expected one wheel, found {wheel}")
with zipfile.ZipFile(wheel[0]) as archive:
if "ledger/static/app.css" not in archive.namelist():
raise SystemExit("wheel is missing ledger/static/app.css")
PY
# Integrity: a tag that doesn't match the package's own declared version is
# almost always a cut-from-the-wrong-commit mistake, not an intentional
# release — fail loudly instead of publishing a mislabeled artifact.
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,9 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Fixed

- **Missing production stylesheet (2026-07-12).** Package `web/static/app.css`
inside wheel and container installs so `/static/app.css` no longer returns 404
when the server runs outside a source checkout.
- **Cloud-init secret tracing (SEV2, 2026-07-12).** Removed shell xtrace from
AWS first-boot provisioning after the initial synthetic demo deploy revealed
that expanded secret assignments reached the IAM-restricted EC2 console log.
Expand Down
5 changes: 3 additions & 2 deletions infra/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,9 @@ WORKDIR /app
# for this Dockerfile is the repository root (see docker-compose.yml `build`).
COPY pyproject.toml README.md LICENSE ./
COPY src ./src
# The browse server serves the bundled, framework-free web assets (web/static),
# resolved relative to the installed package's parent. Ship them in the image.
# Keep the source web tree in the build context for documentation and source-form
# use. Hatch also embeds app.css under ledger/static in the installed wheel, which
# is the path the production server uses.
COPY web ./web
RUN pip install . \
&& rm -rf /root/.cache
Expand Down
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ ledger = "ledger.cli:main"
[tool.hatch.build.targets.wheel]
packages = ["src/ledger"]

# The public server links this stylesheet at /static/app.css. Ship it inside the
# Python package so wheel installs and containers do not depend on the repository
# layout that happened to exist while the artifact was built.
[tool.hatch.build.targets.wheel.force-include]
"web/static/app.css" = "ledger/static/app.css"

# SEC-13/CQ-09/CQ-27: dev tooling lives in a PEP 735 dependency group, not a
# `[project.optional-dependencies]` extra. Extras ship in the built wheel's
# metadata and are installable by any consumer (`pip install ledger-archive[dev]`);
Expand Down
22 changes: 17 additions & 5 deletions src/ledger/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,23 @@
)
from ledger.tombstones import PRIMARY_LOCATION, TombstoneStore

# Where the bundled, framework-free web assets live, resolved relative to this
# module so the server works from any working directory (portability). The static
# root is the canonical boundary the traversal guard enforces.
_WEB_ROOT: Path = Path(__file__).resolve().parent.parent.parent / "web"
_STATIC_ROOT: Path = (_WEB_ROOT / "static").resolve()

def _resolve_static_root(module_root: Path) -> Path:
"""Locate static assets in an installed wheel or a source checkout.

Hatch places ``web/static`` inside ``ledger/static`` in built wheels. A
source checkout keeps the canonical editable files at repository-level
``web/static``. Prefer the packaged location so an installed application
never depends on its build context or current working directory.
"""
packaged = module_root / "static"
if packaged.is_dir():
return packaged.resolve()
return (module_root.parent.parent / "web" / "static").resolve()


# The canonical boundary enforced by the traversal-safe static allowlist.
_STATIC_ROOT: Path = _resolve_static_root(Path(__file__).resolve().parent)
_STAGED_UPLOAD_FILENAME = "payload.bin"


Expand Down
16 changes: 16 additions & 0 deletions tests/test_server_remediation.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

import pytest

from ledger import server
from ledger.access.grants import issue_grant_token
from ledger.config import Config
from ledger.consent import issue_claim_token
Expand Down Expand Up @@ -286,6 +287,21 @@ def test_static_is_cacheable(site: tuple[str, str, str]) -> None:
assert headers.get("etag")


def test_static_root_resolution_prefers_installed_package(tmp_path: Path) -> None:
"""Wheel installs use packaged CSS, independent of checkout/cwd layout."""
module_root = tmp_path / "site-packages" / "ledger"
packaged = module_root / "static"
source = tmp_path / "web" / "static"
source.mkdir(parents=True)
(source / "app.css").write_text("source", encoding="utf-8")

assert server._resolve_static_root(module_root) == source.resolve()

packaged.mkdir(parents=True)
(packaged / "app.css").write_text("installed", encoding="utf-8")
assert server._resolve_static_root(module_root) == packaged.resolve()


def test_static_rejects_traversal_and_unknown_names(site: tuple[str, str, str]) -> None:
"""Static serving is a name allowlist: a traversal or unknown name 404s.

Expand Down
Loading