"""Path resolution for scitex-audit local state.
Follows the SciTeX local-state-directories convention
(``01_ecosystem/06_local-state-directories.md``):
* Regenerable data (alert reports, symlinks) goes under
``<pkg-short>/`` — never outside the package namespace.
* Resolves via ``SCITEX_AUDIT_DIR`` env var, project scope, or user
scope (in that precedence order).
* ``SCITEX_DIR`` relocates the user-scope root atomically.
* Standalone resolver — no ``scitex_dev`` import, no ``PathManager``
dependency.
This module also owns the one-time migration of legacy
``~/.scitex/security/`` (from the pre-absorption scitex-security 0.1.x
package) into the new ``~/.scitex/audit/github-alerts/`` layout. The
migration is triggered automatically on package import via
``_migrate_legacy_security_dir`` — no manual user step. See ADR-0001
(scitex-dev #139) for the absorption design.
"""
from __future__ import annotations
import logging
import os
import shutil
import warnings
from pathlib import Path
#: Package short name (``scitex-audit`` → ``audit``).
PKG_SHORT = "audit"
#: GitHub-alerts subdirectory under the package short name. Keeps the
#: github checker output cleanly separated from bandit/shellcheck/pip-
#: audit results that share the same parent.
ALERTS_SUBDIR = "github-alerts"
#: Legacy location (pre-v0.2) — CWD-relative seed scitex-security
#: carried over from its own pre-v0.2 days. Kept here so users
#: migrating straight from a very old scitex-security still get a
#: clean read.
_LEGACY_REL_ALERTS_DIR = Path("logs") / "security"
#: Legacy USER-scope directory from scitex-security 0.1.x.
_LEGACY_USER_SECURITY_SUBDIR = "security"
#: Marker file recording one-shot migrations (per category).
_LEGACY_REL_MARKER = ".migrated_from_legacy"
_LEGACY_SECURITY_USER_MARKER = ".migrated_from_scitex_security_user"
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Scope resolution
# ---------------------------------------------------------------------------
def _project_root() -> Path | None:
"""Walk up from ``cwd`` to find the repository root (``.git`` sentinel)."""
cwd = Path.cwd().resolve()
for parent in [cwd] + list(cwd.parents):
if (parent / ".git").is_dir():
return parent
return None
def _scitex_dir() -> Path:
"""User-scope SciTeX root (``$SCITEX_DIR`` or ``~/.scitex``)."""
raw = os.environ.get("SCITEX_DIR", Path.home() / ".scitex")
return Path(raw).expanduser().resolve()
def _resolve_user_scope() -> Path:
"""User-scope alerts root.
Returns ``~/.scitex/audit/github-alerts/runtime/`` (or
``$SCITEX_DIR/audit/github-alerts/runtime/`` when ``SCITEX_DIR`` is set).
"""
return _scitex_dir() / PKG_SHORT / ALERTS_SUBDIR / "runtime"
def _resolve_project_scope() -> Path | None:
"""Project-scope root, if inside a git repo.
Returns ``<project>/.scitex/audit/github-alerts/runtime/`` or ``None``.
"""
root = _project_root()
if root is None:
return None
return root / ".scitex" / PKG_SHORT / ALERTS_SUBDIR / "runtime"
def _get_primary_alerts_dir() -> Path:
"""Resolve the primary alerts directory with full precedence chain.
Precedence (highest first):
1. ``$SCITEX_AUDIT_DIR`` env var
2. Project scope (``<project>/.scitex/audit/github-alerts/runtime/``)
3. User scope (``~/.scitex/audit/github-alerts/runtime/``)
"""
env = os.environ.get("SCITEX_AUDIT_DIR")
if env:
# Honour the env var verbatim — operator chose the location;
# don't append the ALERTS_SUBDIR on top of an explicit path.
return Path(env).expanduser().resolve()
project = _resolve_project_scope()
if project is not None:
return project
return _resolve_user_scope()
# ---------------------------------------------------------------------------
# Legacy migration: pre-v0.2 CWD-relative (carried over from scitex-security)
# ---------------------------------------------------------------------------
def _legacy_rel_alerts_dir_exists() -> bool:
"""Check if the old CWD-relative ``./logs/security`` exists."""
return _LEGACY_REL_ALERTS_DIR.is_dir()
def _migrate_from_legacy_rel(primary: Path) -> None:
"""One-time migration: move ``./logs/security/*`` → *primary*.
Only runs if *primary* was not previously migrated (marker file
guard). Emits a ``DeprecationWarning`` on first occurrence.
"""
if not _legacy_rel_alerts_dir_exists():
return
marker = primary / _LEGACY_REL_MARKER
if marker.exists():
return
legacy = _LEGACY_REL_ALERTS_DIR.resolve()
warnings.warn(
f"DEPRECATED: {legacy} is deprecated. "
f"Alert files are now written to {primary}. "
f"Set $SCITEX_AUDIT_DIR to override.",
DeprecationWarning,
stacklevel=3,
)
primary.mkdir(parents=True, exist_ok=True)
for f in legacy.glob("security-*.txt"):
shutil.move(str(f), str(primary / f.name))
# Clean up the old symlink if it exists
old_link = legacy / "security-latest.txt"
if old_link.is_symlink() or old_link.exists():
old_link.unlink()
marker.touch()
def _check_legacy_fallback_read(target_dir: Path | None) -> Path | None:
"""If *target_dir* is the default and the legacy dir exists, return it.
Keeps the old path readable for one minor version per §8 of the
local-state-directories convention. Guards against infinite loops
because ``get_latest_alerts_file`` is the caller in that path.
"""
if target_dir is not None:
return target_dir
if _legacy_rel_alerts_dir_exists():
return _LEGACY_REL_ALERTS_DIR.resolve()
return None
# ---------------------------------------------------------------------------
# Legacy migration: ~/.scitex/security/ (from absorbed scitex-security 0.1.x)
# ---------------------------------------------------------------------------
def _legacy_security_user_dir() -> Path:
"""Path that scitex-security 0.1.x wrote to under the SciTeX root."""
return _scitex_dir() / _LEGACY_USER_SECURITY_SUBDIR
def _migrate_legacy_security_dir() -> None:
"""One-shot migration of ``~/.scitex/security/`` → ``~/.scitex/audit/github-alerts/``.
Runs automatically on package import (called from
``scitex_audit/__init__.py``). Guarded by a marker file so it only
fires once per user. Per ADR-0001 §"Locked decisions": NO manual
user step. Prefers ``os.symlink`` so users keep one storage
location; falls back to ``shutil.move`` when symlinks aren't
available on the platform (e.g. Windows without dev-mode).
No-ops if the legacy dir doesn't exist, or if the absorbing dir
already exists, or if the marker file says we already migrated.
Wrapped in try/except by the caller; this body itself only raises
on genuinely-unexpected filesystem corruption.
"""
legacy = _legacy_security_user_dir()
if not legacy.exists():
return
audit_root = _scitex_dir() / PKG_SHORT
marker = audit_root / _LEGACY_SECURITY_USER_MARKER
if marker.exists():
return
target = audit_root / ALERTS_SUBDIR
if target.exists():
# The new location already has data — operator has migrated
# manually or run scitex-audit before. Record the marker so we
# don't keep re-checking.
audit_root.mkdir(parents=True, exist_ok=True)
marker.touch()
return
audit_root.mkdir(parents=True, exist_ok=True)
moved = False
try:
os.symlink(legacy, target)
logger.info(
"scitex-audit: linked legacy %s → %s "
"(per ADR-0001 absorption of scitex-security)",
legacy,
target,
)
moved = True
except OSError:
# Platform doesn't support symlinks (Windows w/o dev mode,
# cross-device link, etc.). Fall back to a real move.
try:
shutil.move(str(legacy), str(target))
logger.info(
"scitex-audit: moved legacy %s → %s "
"(per ADR-0001 absorption of scitex-security)",
legacy,
target,
)
moved = True
except OSError as exc:
# Last-ditch: log + leave both dirs in place. Avoid
# raising — a path migration glitch must not break import.
logger.warning(
"scitex-audit: could not migrate legacy %s → %s: %s. "
"Continuing with the new location empty; the old data "
"is still readable at %s.",
legacy,
target,
exc,
legacy,
)
if moved:
marker.touch()
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
[docs]
def get_default_alerts_dir() -> Path:
"""Return the default alerts directory, creating it if needed.
Runs the pre-v0.2 CWD-relative migration when the legacy
``./logs/security`` directory is detected. The user-scope
``~/.scitex/security/`` migration is run separately on package
import (``_migrate_legacy_security_dir``).
"""
primary = _get_primary_alerts_dir()
_migrate_from_legacy_rel(primary)
primary.mkdir(parents=True, exist_ok=True)
return primary
__all__ = [
"PKG_SHORT",
"ALERTS_SUBDIR",
"get_default_alerts_dir",
]
# EOF