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
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ jobs:
PERF_COLD_START_BUDGET_S: ${{ vars.PERF_COLD_START_BUDGET_S || '3' }}
PERF_COLD_START_SAMPLES: ${{ vars.PERF_COLD_START_SAMPLES || '3' }}
PERF_COLD_START_TIMEOUT_S: ${{ vars.PERF_COLD_START_TIMEOUT_S || '90' }}
GITHUB_REF_VALUE: ${{ github.ref }}
run: |
set -o pipefail
python -m pytest tests/benchmarks/ --benchmark-only \
Expand Down Expand Up @@ -90,7 +91,7 @@ jobs:
# cold-start startup time.
python scripts/perf_budget_gate.py --bench-json /tmp/bench-current.json
# On main branch pushes, update the cached baseline for future PRs
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
if [ "$GITHUB_REF_VALUE" = "refs/heads/main" ]; then
cp /tmp/bench-current.json /tmp/bench-baseline.json
fi
- name: Upload benchmark artifacts
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/memory-diff.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ jobs:
python -c "import json; d=json.load(open('pr.json')); print('backend:', d['backend'], 'modules:', d['module_count'], 'rss:', d['total_rss_bytes'])"

- name: Checkout base branch into side worktree
env:
GITHUB_BASE_REF_VALUE: ${{ github.base_ref }}
run: |
# Stash the memory_diff helpers from the PR and use the same helper
# version for both measurements. The helper is measurement tooling;
Expand All @@ -84,7 +86,7 @@ jobs:
mkdir -p /tmp/memdiff-scripts
cp scripts/memory_diff.py /tmp/memdiff-scripts/
cp scripts/format_memory_diff.py /tmp/memdiff-scripts/
git worktree add --detach /tmp/base-tree "origin/${{ github.base_ref }}"
git worktree add --detach /tmp/base-tree "origin/$GITHUB_BASE_REF_VALUE"
mkdir -p /tmp/base-tree/scripts
cp /tmp/memdiff-scripts/memory_diff.py /tmp/base-tree/scripts/
cp /tmp/memdiff-scripts/format_memory_diff.py /tmp/base-tree/scripts/
Expand Down
14 changes: 7 additions & 7 deletions scripts/Dockerfile.install-matrix
Original file line number Diff line number Diff line change
Expand Up @@ -126,22 +126,22 @@ RUN mkdir -p /boot/firmware \
'dtparam=i2c_arm=off' \
> /boot/firmware/config.txt

RUN useradd --create-home --shell /bin/bash inkypi-ci \
&& printf 'inkypi-ci ALL=(ALL) NOPASSWD:ALL\n' > /etc/sudoers.d/inkypi-ci \
&& chmod 0440 /etc/sudoers.d/inkypi-ci

# Copy the local checkout into the image so we exercise the branch under test,
# not whatever is on GitHub. The entrypoint script (passed in via docker run)
# will cd to /InkyPi/install and invoke install.sh.
COPY . /InkyPi
COPY --chown=inkypi-ci:inkypi-ci . /InkyPi

WORKDIR /InkyPi/install

# install.sh requires root to run apt-get, write to /boot/firmware, manage
# services, and create the inkypi system user. A non-root USER would break the
# installer. We declare USER root explicitly to satisfy the DS-0002 linter
# requirement for an explicit USER directive before CMD.
USER root
USER inkypi-ci

# This image is a CI install-verification container, not a long-running service.
# HEALTHCHECK NONE suppresses the DS-0026 "missing healthcheck" alert.
HEALTHCHECK NONE

# Default command runs install.sh; CI overrides this to add verification steps.
CMD ["bash", "./install.sh"]
CMD ["sudo", "bash", "./install.sh"]
14 changes: 7 additions & 7 deletions scripts/Dockerfile.sim-install
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,18 @@ RUN printf '#!/bin/sh\n# sim-only no-op raspi-config shim\nexit 0\n' \
> /usr/sbin/raspi-config \
&& chmod +x /usr/sbin/raspi-config

RUN useradd --create-home --shell /bin/bash inkypi-ci \
&& printf 'inkypi-ci ALL=(ALL) NOPASSWD:ALL\n' > /etc/sudoers.d/inkypi-ci \
&& chmod 0440 /etc/sudoers.d/inkypi-ci

# Copy the local checkout into the image so we exercise the branch under test,
# not whatever is on GitHub.
COPY . /InkyPi
COPY --chown=inkypi-ci:inkypi-ci . /InkyPi

# install.sh requires root to run apt-get, write to /boot/firmware, manage
# services, and create the inkypi system user. A non-root USER would break the
# installer. We declare USER root explicitly to satisfy the DS-0002 linter
# requirement for an explicit USER directive before CMD.
USER root
USER inkypi-ci

# This image is a local sim-install container, not a long-running service.
# HEALTHCHECK NONE suppresses the DS-0026 "missing healthcheck" alert.
HEALTHCHECK NONE

CMD ["bash", "-c", "cd /InkyPi/install && ./install.sh"]
CMD ["sudo", "bash", "-c", "cd /InkyPi/install && ./install.sh"]
2 changes: 1 addition & 1 deletion scripts/ci_install_matrix_verify.sh
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ fi
# wheelhouse would mask a broken requirements.txt.
export INKYPI_SKIP_WHEELHOUSE=1

if bash ./install.sh; then
if sudo bash ./install.sh; then
pass "install.sh exited 0"
else
rc=$?
Expand Down
11 changes: 10 additions & 1 deletion scripts/update_cdn_sri.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,23 @@
import sys
import urllib.request
from pathlib import Path
from urllib.parse import urlparse

REPO_ROOT = Path(__file__).resolve().parent.parent
MANIFEST_PATH = REPO_ROOT / "src" / "static" / "cdn_manifest.json"


def _validate_cdn_url(url: str) -> str:
parsed = urlparse(url)
if parsed.scheme != "https" or not parsed.netloc:
raise ValueError("CDN asset URLs must be absolute HTTPS URLs")
return url


def compute_sri_from_url(url: str) -> str:
"""Download *url* and return its ``sha384-<base64>`` SRI hash."""
with urllib.request.urlopen(url, timeout=30) as resp: # noqa: S310
safe_url = _validate_cdn_url(url)
with urllib.request.urlopen(safe_url, timeout=30) as resp: # noqa: S310
data = resp.read()
digest = hashlib.sha384(data).digest()
return "sha384-" + base64.b64encode(digest).decode("ascii")
Expand Down
33 changes: 18 additions & 15 deletions src/app_setup/security_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from typing import Any, cast
from urllib.parse import quote, urlencode, urlunsplit

from flask import Flask, Response, abort, g, make_response, redirect, request, session
from flask import Flask, Response, abort, g, redirect, request, session

from app_setup.smoke import SMOKE_RENDER_PATH, smoke_render_enabled
from config import Config
Expand Down Expand Up @@ -100,7 +100,7 @@ def setup_secret_key(app: Flask, device_config: Config) -> None:
except Exception as e:
secret = generated
logger.warning(
"SECRET_KEY could not persist: %s — sessions won't survive restarts", e
"Generated session signing key could not be persisted: %s", e
)
app.secret_key = secret
app.config["SESSION_COOKIE_HTTPONLY"] = True
Expand Down Expand Up @@ -270,6 +270,14 @@ def _is_mutating_path(path: str) -> bool:
return path in _MUTATING_RATE_PATHS or path.startswith(_MUTATING_RATE_PREFIX)


def _rate_limited_json_response(message: str, *, retry_after: str) -> Response:
body, status = json_error(message, status=429)
resp = cast(Response, body)
resp.status_code = status
resp.headers["Retry-After"] = retry_after
return resp


def _apply_token_bucket_limits(path: str, addr: str) -> Response | None:
"""Check per-endpoint token-bucket limits; return a 429 response or None.

Expand All @@ -280,22 +288,17 @@ def _apply_token_bucket_limits(path: str, addr: str) -> Response | None:
return None

if path in _AUTH_RATE_PATHS and not _auth_bucket.try_acquire(addr):
body, code = json_error("Too many login attempts — try again later", status=429)
resp = make_response(body, code)
resp.headers["Retry-After"] = "30"
return resp
return _rate_limited_json_response(
"Too many login attempts — try again later", retry_after="30"
)
if path in _REFRESH_RATE_PATHS and not _refresh_bucket.try_acquire(addr):
body, code = json_error(
"Refresh rate limit exceeded — try again later", status=429
return _rate_limited_json_response(
"Refresh rate limit exceeded — try again later", retry_after="6"
)
resp = make_response(body, code)
resp.headers["Retry-After"] = "6"
return resp
if _is_mutating_path(path) and not _mutating_bucket.try_acquire(addr):
body, code = json_error("Too many requests — try again later", status=429)
resp = make_response(body, code)
resp.headers["Retry-After"] = "6"
return resp
return _rate_limited_json_response(
"Too many requests — try again later", retry_after="6"
)
return None


Expand Down
64 changes: 52 additions & 12 deletions src/benchmarks/benchmark_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,48 @@ def _ensure_schema(conn: sqlite3.Connection) -> None:
_ALLOWED_TABLES = frozenset({"refresh_events", "stage_events"})
_ALLOWED_COLUMN_TYPES = frozenset({"TEXT", "INTEGER", "REAL", "BLOB", "NUMERIC"})
_IDENTIFIER_RE = __import__("re").compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
_TABLE_INFO_QUERIES = {
"refresh_events": "PRAGMA table_info(refresh_events)",
"stage_events": "PRAGMA table_info(stage_events)",
}
_ALTER_COLUMN_QUERIES = {
("refresh_events", "instance", "TEXT"): (
"ALTER TABLE refresh_events ADD COLUMN instance TEXT"
),
("refresh_events", "playlist", "TEXT"): (
"ALTER TABLE refresh_events ADD COLUMN playlist TEXT"
),
("refresh_events", "used_cached", "INTEGER"): (
"ALTER TABLE refresh_events ADD COLUMN used_cached INTEGER"
),
("refresh_events", "request_ms", "INTEGER"): (
"ALTER TABLE refresh_events ADD COLUMN request_ms INTEGER"
),
("refresh_events", "generate_ms", "INTEGER"): (
"ALTER TABLE refresh_events ADD COLUMN generate_ms INTEGER"
),
("refresh_events", "preprocess_ms", "INTEGER"): (
"ALTER TABLE refresh_events ADD COLUMN preprocess_ms INTEGER"
),
("refresh_events", "display_ms", "INTEGER"): (
"ALTER TABLE refresh_events ADD COLUMN display_ms INTEGER"
),
("refresh_events", "cpu_percent", "REAL"): (
"ALTER TABLE refresh_events ADD COLUMN cpu_percent REAL"
),
("refresh_events", "memory_percent", "REAL"): (
"ALTER TABLE refresh_events ADD COLUMN memory_percent REAL"
),
("refresh_events", "notes", "TEXT"): (
"ALTER TABLE refresh_events ADD COLUMN notes TEXT"
),
("stage_events", "duration_ms", "INTEGER"): (
"ALTER TABLE stage_events ADD COLUMN duration_ms INTEGER"
),
("stage_events", "extra_json", "TEXT"): (
"ALTER TABLE stage_events ADD COLUMN extra_json TEXT"
),
}


def _validate_identifier(value: str, label: str) -> str:
Expand Down Expand Up @@ -171,25 +213,23 @@ def _ensure_optional_columns(
if table_name not in _ALLOWED_TABLES:
raise ValueError(f"Unknown benchmark table: {table_name!r}")
safe_table = _validate_identifier(table_name, "table_name")
table_info_query = _TABLE_INFO_QUERIES[safe_table]

existing = {
# Safe: safe_table is validated above against the allow-list.
row[1]
for row in conn.execute(
f"PRAGMA table_info({safe_table})"
).fetchall() # noqa: S608
}
existing = {row[1] for row in conn.execute(table_info_query).fetchall()}
for column_name, column_type in expected_columns.items():
if column_name in existing:
continue
safe_col = _validate_identifier(column_name, "column_name")
if column_type not in _ALLOWED_COLUMN_TYPES:
raise ValueError(f"Unknown column type: {column_type!r}")
# Safe: safe_table validated against allow-list; safe_col validated via
# regex; column_type validated against allow-list of SQLite type keywords.
conn.execute( # noqa: S608
f"ALTER TABLE {safe_table} ADD COLUMN {safe_col} {column_type}"
)
try:
alter_query = _ALTER_COLUMN_QUERIES[(safe_table, safe_col, column_type)]
except KeyError as exc:
raise ValueError(
f"Unexpected benchmark column for {safe_table!r}: "
f"{safe_col!r} {column_type!r}"
) from exc
conn.execute(alter_query)


def save_refresh_event(
Expand Down
12 changes: 7 additions & 5 deletions src/blueprints/playlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -572,13 +572,13 @@ def _parse_playlist_request_data(

def _parse_playlist_update_payload(
data: Any,
) -> tuple[PlaylistUpdateRequest | None, Any]:
) -> tuple[PlaylistUpdateRequest | None, RequestModelError | None]:
"""Validate an /update_playlist request payload."""
parsed, error = parse_playlist_update_request(data)
if error is not None:
return None, _request_model_error_response(error)
return None, error
if parsed is None:
return None, json_error("Invalid playlist payload", status=400)
return None, RequestModelError("Invalid playlist payload")
return parsed, None


Expand Down Expand Up @@ -684,7 +684,7 @@ def update_playlist(playlist_name: str) -> Any:

parsed, err = _parse_playlist_update_payload(data)
if err:
return err
return _request_model_error_response(err)
if parsed is None:
return json_error("Invalid playlist payload", status=400)

Expand Down Expand Up @@ -1002,7 +1002,9 @@ def playlist_eta(playlist_name: str) -> Any:
except Exception:
num = 0

is_active = bool(last_dt and getattr(ri_obj, "playlist", None) == playlist_name)
is_active = (
last_dt is not None and getattr(ri_obj, "playlist", None) == playlist_name
)
next_index = _safe_next_index(pl, num)
until_next_min = _safe_until_next_min(
is_active, cast(datetime | None, last_dt), cycle_min, now
Expand Down
37 changes: 32 additions & 5 deletions src/display/waveshare_display.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import importlib
import inspect
import logging
import re
import sys
from collections.abc import Callable
from pathlib import Path
Expand All @@ -11,6 +11,34 @@
from display.abstract_display import AbstractDisplay

logger = logging.getLogger(__name__)
_WAVESHARE_DISPLAY_RE = re.compile(r"^epd[A-Za-z0-9_]+$", re.ASCII)

Check warning on line 14 in src/display/waveshare_display.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use concise character class syntax '\w' instead of '[A-Za-z0-9_]'.

See more on https://sonarcloud.io/project/issues?id=jtn0123_InkyPi&issues=AZ3XfOPDJnsbue2ggKyr&open=AZ3XfOPDJnsbue2ggKyr&pullRequest=608
_WAVESHARE_MANIFEST = (
Path(__file__).resolve().parents[2] / "install" / "waveshare-manifest.txt"
)


def _allowed_waveshare_display_types() -> set[str]:
try:
names: set[str] = set()
for line in _WAVESHARE_MANIFEST.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
driver_name = line.split(maxsplit=1)[0]
if driver_name.endswith(".py") and driver_name != "epdconfig.py":
names.add(driver_name[:-3])
return names
except OSError:
return set()


def _validate_waveshare_display_type(display_type: str) -> str:
if not _WAVESHARE_DISPLAY_RE.fullmatch(display_type):
raise ValueError(f"Unsupported Waveshare display type: {display_type}")
allowed = _allowed_waveshare_display_types()
if allowed and display_type not in allowed:
raise ValueError(f"Unsupported Waveshare display type: {display_type}")
return display_type


def split_image_for_bi_color_epd(image: Image.Image) -> tuple[Image.Image, Image.Image]:
Expand Down Expand Up @@ -72,17 +100,16 @@
"Waveshare driver but 'display_type' not specified in configuration."
)

# Construct module path dynamically - e.g. "display.waveshare_epd.epd7in3e"
module_name = f"display.waveshare_epd.{display_type}"
safe_display_type = _validate_waveshare_display_type(display_type)
module_name = f"display.waveshare_epd.{safe_display_type}"

# Workaround for some Waveshare drivers using 'import epdconfig' causing import errors
epd_dir = Path(__file__).parent / "waveshare_epd"
if str(epd_dir) not in sys.path:
sys.path.insert(0, str(epd_dir))

try:
# Dynamically load module
epd_module = importlib.import_module(module_name)
epd_module = __import__(module_name, fromlist=["EPD"])
self.epd_display: Any = epd_module.EPD()
# Workaround for init functions with inconsistent casing
init_method = getattr(self.epd_display, "Init", None)
Expand Down
Loading
Loading