Ctrl K
ComunidadGuías…

tests/test_gamesave_store.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Per-caller gamesave scoping — the pure path/identity logic.

Regression suite for the 2026-09-01 leak: the slots were three GLOBAL files on
the guest tier, so any Turnstile-solving visitor read the owner's save into
their browser and pushed their own progress back over it. Every test here is a
property that had to hold to prevent that.

The model: the owner's save is permanent at the original paths; each guest's
save is tied to their nf_save cookie.
"""
import os
import sys
import time

sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "api"))

from pathlib import Path  # noqa: E402

from gamesave_store import (  # noqa: E402
    GUEST_DIR_NAME,
    VALID_SLOTS,
    guest_id_or_new,
    is_valid_guest_id,
    is_valid_slot,
    new_guest_id,
    slot_path,
    sweep_stale_guest_saves,
)

DATA = Path("/data")


# ── the leak itself ──────────────────────────────────────────────────────────


def test_guest_never_shares_a_path_with_the_owner():
    """THE bug. A guest's slot must not resolve to the owner's file."""
    for slot in VALID_SLOTS:
        owner = slot_path(DATA, slot, None)
        guest = slot_path(DATA, slot, "guest-abcdefghijklmnop")
        assert owner != guest


def test_two_guests_never_share_a_path():
    for slot in VALID_SLOTS:
        a = slot_path(DATA, slot, "aaaaaaaaaaaaaaaaaaaa")
        b = slot_path(DATA, slot, "bbbbbbbbbbbbbbbbbbbb")
        assert a != b


def test_same_guest_is_stable_across_calls():
    """A returning guest must land on the same file, or their save 'vanishes'."""
    gid = "stable-id-0123456789"
    assert slot_path(DATA, "save1", gid) == slot_path(DATA, "save1", gid)


def test_one_guest_keeps_three_distinct_slots():
    gid = "one-guest-0123456789"
    paths = {slot_path(DATA, s, gid) for s in VALID_SLOTS}
    assert len(paths) == len(VALID_SLOTS)


# ── owner paths are unmigrated and permanent ─────────────────────────────────


def test_owner_paths_are_unchanged():
    """The owner's existing saves stay exactly where they already are — the fix
    must not orphan a save that predates it."""
    assert slot_path(DATA, "save1", None) == DATA / "gamesave_save1.json"
    assert slot_path(DATA, "save2", None) == DATA / "gamesave_save2.json"
    assert slot_path(DATA, "save3", None) == DATA / "gamesave_save3.json"


def test_guest_saves_live_under_the_guest_dir():
    p = slot_path(DATA, "save1", "somebody-0123456789")
    assert p.parent == DATA / GUEST_DIR_NAME


# ── traversal: the guest id never reaches the filesystem verbatim ────────────


def test_hostile_guest_ids_cannot_escape_the_guest_dir():
    for hostile in [
        "../../etc/passwd",
        "..",
        "../gamesave_save1",
        "/etc/shadow",
        "a/../../b",
        "\\..\\..\\win",
        "x" * 5000,
        "nul\x00byte",
    ]:
        p = slot_path(DATA, "save1", hostile)
        assert p.parent == DATA / GUEST_DIR_NAME
        assert ".." not in p.parts


def test_guest_path_component_is_always_hex():
    """Structural guarantee: whatever the cookie holds, the filename is
    [0-9a-f]{32}_<slot>.json."""
    for gid in ["../../x", "hello", "x" * 500, "üñïçø∂é", ""]:
        name = slot_path(DATA, "save1", gid).name
        digest, _, rest = name.partition("_")
        assert len(digest) == 32
        assert all(c in "0123456789abcdef" for c in digest)
        assert rest == "save1.json"


def test_hostile_guest_id_cannot_reach_an_owner_file():
    """Even a cookie crafted to look like an owner filename stays contained."""
    for gid in ["gamesave_save1.json", "../gamesave_save1", "gamesave_save1"]:
        assert slot_path(DATA, "save1", gid) != slot_path(DATA, "save1", None)


# ── slot allowlist ───────────────────────────────────────────────────────────


def test_is_valid_slot():
    assert all(is_valid_slot(s) for s in VALID_SLOTS)
    for bad in ["save4", "../rd", "", "SAVE1", "save1.json", "profile"]:
        assert not is_valid_slot(bad)


# ── guest id minting / validation ────────────────────────────────────────────


def test_new_guest_id_is_valid_and_unguessable():
    a, b = new_guest_id(), new_guest_id()
    assert a != b
    assert is_valid_guest_id(a) and is_valid_guest_id(b)


def test_guest_id_or_new_keeps_a_good_cookie():
    gid = new_guest_id()
    got, minted = guest_id_or_new(gid)
    assert got == gid and minted is False


def test_guest_id_or_new_mints_on_junk():
    for junk in [None, "", "short", "../../etc", "x" * 500, 12345, b"bytes", {"a": 1}]:
        got, minted = guest_id_or_new(junk)
        assert minted is True
        assert is_valid_guest_id(got)


def test_junk_cookies_do_not_share_a_bucket():
    """Two callers sending different garbage each get a fresh id, so they don't
    collide in one shared 'invalid' bucket."""
    a, _ = guest_id_or_new("../../etc/passwd")
    b, _ = guest_id_or_new("../../etc/passwd")
    assert a != b


# ── stale-save sweep ─────────────────────────────────────────────────────────


def test_sweep_removes_old_guest_saves_and_spares_fresh_ones(tmp_path):
    gdir = tmp_path / GUEST_DIR_NAME
    gdir.mkdir()
    old = gdir / "deadbeef_save1.json"
    new = gdir / "cafebabe_save1.json"
    old.write_text("{}")
    new.write_text("{}")
    stale = time.time() - 200 * 86400
    os.utime(old, (stale, stale))

    assert sweep_stale_guest_saves(tmp_path, max_age_days=90) == 1
    assert not old.exists()
    assert new.exists()


def test_sweep_never_touches_owner_files(tmp_path):
    """The owner's save is PERMANENT — no age sweeps it."""
    owner = tmp_path / "gamesave_save1.json"
    owner.write_text('{"numCredits":3850}')
    stale = time.time() - 9000 * 86400
    os.utime(owner, (stale, stale))
    (tmp_path / GUEST_DIR_NAME).mkdir()

    sweep_stale_guest_saves(tmp_path, max_age_days=1)
    assert owner.exists()
    assert owner.read_text() == '{"numCredits":3850}'


def test_sweep_is_a_noop_when_no_guest_dir_exists(tmp_path):
    assert sweep_stale_guest_saves(tmp_path) == 0