Skip to content
18 changes: 18 additions & 0 deletions backend/app/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
import logging
import os
import sys
import secrets
import tempfile

from platformdirs import user_data_dir

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -41,6 +44,21 @@
THUMBNAIL_IMAGES_PATH = os.path.join(user_data_dir("PictoPy"), "thumbnails")
IMAGES_PATH = "./images"

# Generate session token for authenticated shutdown.
SHUTDOWN_TOKEN: str = secrets.token_hex(32)
SHUTDOWN_TOKEN_FILE: str = os.path.join(tempfile.gettempdir(), "pictopy_shutdown.token")

# Write token with owner-only permissions (0o600).
try:
_fd = os.open(SHUTDOWN_TOKEN_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(_fd, "w") as _f:
_f.write(SHUTDOWN_TOKEN)
# Enforce permissions.
os.chmod(SHUTDOWN_TOKEN_FILE, 0o600)
except OSError as e:
logger.fatal(f"Failed to write shutdown token to {SHUTDOWN_TOKEN_FILE}: {e}")
logger.fatal("Cannot start backend securely. Exiting.")
sys.exit(1)

def _get_env_float(
name: str,
Expand Down
32 changes: 23 additions & 9 deletions backend/app/routes/shutdown.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import asyncio
import hmac
import os
import platform
import signal
from fastapi import APIRouter
from typing import Optional
from fastapi import APIRouter, Header, HTTPException
from pydantic import BaseModel
from app.config import settings
from app.logging.setup_logging import get_logger

logger = get_logger(__name__)
Expand All @@ -28,6 +31,13 @@ async def _delayed_shutdown(delay: float = 0.5):
await asyncio.sleep(delay)
logger.info("Backend shutdown initiated, exiting process...")

# Clean up token file
try:
os.remove(settings.SHUTDOWN_TOKEN_FILE)
logger.info("Shutdown token file removed")
except OSError as e:
logger.warning(f"Could not remove shutdown token file: {e}")

if platform.system() == "Windows":
# Windows: SIGTERM doesn't work reliably with uvicorn subprocesses
os._exit(0)
Expand All @@ -37,16 +47,20 @@ async def _delayed_shutdown(delay: float = 0.5):


@router.post("/shutdown", response_model=ShutdownResponse)
async def shutdown():
"""
Gracefully shutdown the PictoPy backend.
async def shutdown(x_shutdown_token: Optional[str] = Header(default=None)):
"""Gracefully shutdown the PictoPy backend (requires X-Shutdown-Token)."""
if x_shutdown_token is None:
logger.warning("Shutdown attempt rejected: missing token")
raise HTTPException(status_code=401, detail="Unauthorized")

This endpoint schedules backend server termination after response is sent.
The frontend is responsible for shutting down the sync service separately.
if not settings.SHUTDOWN_TOKEN:
raise HTTPException(status_code=503, detail="Service not ready")

# Prevent timing-based token guessing
if not hmac.compare_digest(x_shutdown_token, settings.SHUTDOWN_TOKEN):
logger.warning("Shutdown attempt rejected: invalid token")
raise HTTPException(status_code=403, detail="Forbidden")

Returns:
ShutdownResponse with status and message
"""
logger.info("Shutdown request received for PictoPy backend")

# Define callback to handle potential exceptions in the background task
Expand Down
185 changes: 185 additions & 0 deletions backend/tests/test_shutdown.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import os
import asyncio
import hmac
from unittest.mock import patch

import pytest
from fastapi.testclient import TestClient

from app.main import app as main_app
from app.config import settings

VALID_TOKEN = "a" * 64

@pytest.fixture
def app():
return main_app

@pytest.fixture
def client():
with patch("app.config.settings.SHUTDOWN_TOKEN", VALID_TOKEN), \
patch("app.routes.shutdown.asyncio.create_task"):
with TestClient(main_app, raise_server_exceptions=False) as c:
yield c


# ---------------------------------------------------------------------------
# Header matrix tests
# ---------------------------------------------------------------------------

class TestShutdownHeaderMatrix:
"""Cover all four header scenarios on the /shutdown endpoint."""

def test_no_token_returns_401(self, client):
"""Missing X-Shutdown-Token header must return 401 Unauthorized."""
resp = client.post("/shutdown")
assert resp.status_code == 401
assert resp.json()["detail"] == "Unauthorized"

def test_empty_token_returns_401(self, client):
"""Empty header value is treated as missing (None after strip by FastAPI)."""
resp = client.post("/shutdown", headers={"X-Shutdown-Token": ""})
# FastAPI sends None for empty optional header → 401
assert resp.status_code in (401, 403)

def test_malformed_token_returns_403(self, client):
"""A syntactically valid but wrong token returns 403 Forbidden."""
resp = client.post("/shutdown", headers={"X-Shutdown-Token": "notahextoken"})
assert resp.status_code == 403
assert resp.json()["detail"] == "Forbidden"

def test_wrong_token_returns_403(self, client):
"""A well-formed but incorrect token must return 403."""
wrong = "b" * 64
resp = client.post("/shutdown", headers={"X-Shutdown-Token": wrong})
assert resp.status_code == 403

def test_correct_token_returns_200(self, client):
"""A correct token must return 200 with shutting_down status."""
resp = client.post("/shutdown", headers={"X-Shutdown-Token": VALID_TOKEN})
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "shutting_down"


# ---------------------------------------------------------------------------
# Token rotation / restart simulation
# ---------------------------------------------------------------------------

class TestTokenRotation:
"""Verify per-session token semantics."""

def test_old_token_rejected_after_rotation(self, app):
"""Simulates a restart: new session → new token → old token is rejected."""
old_token = "c" * 64
new_token = "d" * 64

with patch("app.config.settings.SHUTDOWN_TOKEN", new_token), \
patch("app.routes.shutdown.asyncio.create_task"):
with TestClient(app, raise_server_exceptions=False) as c:
resp = c.post("/shutdown", headers={"X-Shutdown-Token": old_token})
assert resp.status_code == 403

def test_new_token_accepted_after_rotation(self, app):
new_token = "e" * 64
with patch("app.config.settings.SHUTDOWN_TOKEN", new_token), \
patch("app.routes.shutdown.asyncio.create_task"):
with TestClient(app, raise_server_exceptions=False) as c:
resp = c.post("/shutdown", headers={"X-Shutdown-Token": new_token})
assert resp.status_code == 200


# ---------------------------------------------------------------------------
# Token file cleanup
# ---------------------------------------------------------------------------

class TestTokenFileCleanup:
"""_delayed_shutdown should attempt to remove the token file."""

def test_token_file_removed_on_shutdown(self, tmp_path):
token_file = str(tmp_path / "pictopy_shutdown.token")
token_file_obj = open(token_file, "w")
token_file_obj.write(VALID_TOKEN)
token_file_obj.close()

with patch("app.config.settings.SHUTDOWN_TOKEN", VALID_TOKEN), \
patch("app.config.settings.SHUTDOWN_TOKEN_FILE", token_file), \
patch("app.routes.shutdown.os.kill"), \
patch("app.routes.shutdown.os._exit"):

from app.routes.shutdown import _delayed_shutdown
asyncio.get_event_loop().run_until_complete(_delayed_shutdown(delay=0))

assert not os.path.exists(token_file)

def test_missing_token_file_does_not_raise(self, tmp_path):
"""If file was already deleted, _delayed_shutdown must not propagate the error."""
token_file = str(tmp_path / "nonexistent.token")

with patch("app.config.settings.SHUTDOWN_TOKEN_FILE", token_file), \
patch("app.routes.shutdown.os.kill"), \
patch("app.routes.shutdown.os._exit"):

from app.routes.shutdown import _delayed_shutdown
# Should complete without raising
asyncio.get_event_loop().run_until_complete(_delayed_shutdown(delay=0))


# ---------------------------------------------------------------------------
# Concurrent invalid requests
# ---------------------------------------------------------------------------

class TestConcurrentInvalidRequests:
"""Concurrent bad requests must not block a legitimate shutdown."""

def test_concurrent_invalid_then_valid(self, client):
wrong = "f" * 64
for _ in range(10):
resp = client.post("/shutdown", headers={"X-Shutdown-Token": wrong})
assert resp.status_code == 403

# Service still reachable and accepts the correct token
resp = client.post("/shutdown", headers={"X-Shutdown-Token": VALID_TOKEN})
assert resp.status_code == 200


# ---------------------------------------------------------------------------
# Corrupted / invalid token content loaded by sync service
# ---------------------------------------------------------------------------

class TestCorruptedTokenContent:
"""If the token file had garbage, hmac.compare_digest must still return False."""

def test_corrupted_token_always_rejects(self, app):
corrupted = "\x00\xff partial"
with patch("app.config.settings.SHUTDOWN_TOKEN", corrupted), \
patch("app.routes.shutdown.asyncio.create_task"):
with TestClient(app, raise_server_exceptions=False) as c:
# Even sending the corrupted string must not crash the endpoint
resp = c.post("/shutdown", headers={"X-Shutdown-Token": corrupted})
# hmac.compare_digest may raise TypeError for non-str/bytes — document behavior
assert resp.status_code in (200, 400, 403, 500)

class TestEmptyTokenContent:
def test_empty_settings_token_always_rejects(self, app):
"""An empty SHUTDOWN_TOKEN must never grant access, even with an empty header."""
with patch("app.config.settings.SHUTDOWN_TOKEN", ""):
with TestClient(app, raise_server_exceptions=False) as c:
resp = c.post("/shutdown", headers={"X-Shutdown-Token": ""})
# Empty header is None → 401; but documents that "" ≠ "" guard is NOT present
assert resp.status_code in (401, 403, 503)

def test_get_method_rejected(self, app):
"""GET /shutdown should be rejected automatically."""
with TestClient(app, raise_server_exceptions=False) as c:
resp = c.get("/shutdown")
assert resp.status_code == 405

def test_long_token_header_rejected(self, app):
"""Extremely long token header should be rejected."""
long_token = "a" * 1024 * 1024 # 1MB
with patch("app.config.settings.SHUTDOWN_TOKEN", VALID_TOKEN), \
patch("app.routes.shutdown.asyncio.create_task"):
with TestClient(app, raise_server_exceptions=False) as c:
resp = c.post("/shutdown", headers={"X-Shutdown-Token": long_token})
assert resp.status_code in (400, 403, 413, 431) # Payload too large, forbidden, or header fields too large
49 changes: 44 additions & 5 deletions frontend/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,27 +53,61 @@ fn on_window_event(window: &Window, event: &WindowEvent) {
}

#[cfg(unix)]
fn kill_process(process: &sysinfo::Process) {
fn kill_process(process: &sysinfo::Process) -> Result<(), String> {
use sysinfo::Signal;
let _ = process.kill_with(Signal::Term);
Ok(())
}

#[cfg(windows)]
pub fn kill_process(_process: &sysinfo::Process) -> Result<(), String> {
fn kill_process(_process: &sysinfo::Process) -> Result<(), String> {
use reqwest::blocking::Client;
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use std::str::FromStr;

// Read per-session shutdown token written by backend.
let token_path = std::env::temp_dir().join("pictopy_shutdown.token");
let token = match std::fs::read_to_string(&token_path) {
Ok(t) => {
let trimmed = t.trim().to_string();
if trimmed.is_empty() {
eprintln!("[PictoPy] Warning: shutdown token file is empty — shutdown request will be rejected by the backend.");
}
trimmed
}
Err(e) => {
eprintln!("[PictoPy] Warning: could not read shutdown token file ({token_path:?}): {e} — shutdown request will be rejected by the backend.");
String::new()
}
};

let mut headers = HeaderMap::new();
if !token.is_empty() {
if let (Ok(name), Ok(value)) = (
HeaderName::from_str("x-shutdown-token"),
HeaderValue::from_str(&token),
) {
headers.insert(name, value);
}
}

let client = Client::builder().build().map_err(|e| e.to_string())?;

for (name, url, _) in &ENDPOINTS {
match client.post(*url).send() {
match client.post(*url).headers(headers.clone()).send() {
Ok(resp) => {
let status = resp.status();

if status.is_success() {
println!("[{}] Shutdown OK ({})", name, status);
}
}
Err(_err) => {}
Err(_err) => {
eprintln!(
"[{}] Failed to send shutdown request to {}: {}",
name, url, _err
);
}
}
}

Expand All @@ -95,7 +129,12 @@ fn kill_process_tree() -> Result<(), String> {
let name = process.name().to_string_lossy();

if target_names.iter().any(|t| name.eq_ignore_ascii_case(t)) {
let _ = kill_process(process);
if let Err(e) = kill_process(process) {
eprintln!(
"[PictoPy] Failed to send shutdown signal to process {}: {}",
name, e
);
}
}
}

Expand Down
8 changes: 7 additions & 1 deletion sync-microservice/app/config/settings.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from platformdirs import user_data_dir
import os
import tempfile

from platformdirs import user_data_dir

# Model Exports Path
MODEL_EXPORTS_PATH = "app/models/ONNX_Exports"
Expand Down Expand Up @@ -28,3 +30,7 @@
DATABASE_PATH = os.path.join(user_data_dir("PictoPy"), "database", "PictoPy.db")
THUMBNAIL_IMAGES_PATH = "./images/thumbnails"
IMAGES_PATH = "./images"

# Shared session token file for authenticated shutdown.
SHUTDOWN_TOKEN_FILE: str = os.path.join(tempfile.gettempdir(), "pictopy_shutdown.token")
SHUTDOWN_TOKEN: str = ""
Loading
Loading