Files
ducoterra ee3efb28c9
Build and Push Containers / build-and-push-app (push) Successful in 4m35s
Build and Push Containers / build-and-push-db (push) Successful in 14s
phase: 106_document_dates
Everything is verified green. Final report:

**Phase 106 — Document dates (final verification pass; all 10 tasks already complete)**

- Verified all phase artifacts: alembic `0020` (dev DB at `0020`), `doc_dates.py`, git `file_commit_dates`, importer `doc_dates_by_root`/`dates_updated`, both entry-point wirings, date APIs + tree `created_at`/`updated_at`, LLM surfaces (prompt block, `read` line 2, appended `ls` field), `apply_recency_boost` in `retrieve()`, UI columns/badge, admin editor, mock-LLM regex — all present and correct; no defects found, no fixes needed.
- `uv run pytest --cov=app --cov-report=term-missing` → **2299 passed, TOTAL 99%** (>90% ✓)
- `uv run pytest tests/e2e/test_document_dates.py -v --no-cov` → **6/6 passed** in isolation (DB up)
- 12 regression E2E suites (retrieval_quality, whole_document_context, agent_document_tools, ls_tree_drilldown, read_truncation_cap, kb_tree, kb_tree_nav, document_viewer, edit_summaries, import_documents, sync_button, hidden_folders_toggle, smoke) → **all green in isolation**
- `uv run ruff check .` → clean; `uv run pyright` → **0 errors, 0 warnings**

**Completion criteria:** 1) non-null `created_at` + 0020 upgrade/downgrade on dev DB ✓ (real-Alembic integration tests) 2) sync refresh/older/manual-persists/content-reset/no sources_meta bump ✓ 3) zip/tar mtime + future→today ✓ 4) LLM date surfaces + cross-check ✓ 5) UI Created/Updated/badge positions ✓ 6) admin editor set+revert round-trip ✓ 7) old-correct-beats-new-similar (defaults & boost-off) + near-tie + `BOR_RECENCY_BOOST=0` byte-identical ✓ 8) full gate ✓ 9) commit/phase-move — left to harness per instructions.

- **Notable:** recency default tuned 0.001 → **0.0007** (task 07 step 5 explicitly permits; measured margins recorded in `test_recency_boost.py` docstring).
- **Next pending phase:** none — `todo/` holds only this phase.
2026-09-13 19:28:05 -04:00

299 lines
14 KiB
Python

"""Safe archive unpacking for uploaded sources (phase 49, task 01).
Pure file-system logic — no FastAPI/DB imports. The API layer
(``POST /api/git-sources/upload``, phase 49, task 02) calls these and
maps :class:`ArchiveUploadError` to status codes (422).
The guarantees (phase 49 locked decisions):
* :func:`archive_source_name` derives the source name from the uploaded
filename — ONE trailing archive suffix stripped, longest-first so
``homelab.tar.gz`` yields ``homelab`` (never ``homelab.tar``) — and
rejects anything that would not be a safe single folder name (the name
becomes a directory under ``BOR_UPLOAD_DIR`` and the ``git_sources``
row's ``path``).
* :func:`unpack_archive` extracts ``.zip`` or ``.tar`` (gz/bz2/xz
transparently) into a fresh directory, rejecting absolute member
paths, ``..`` traversal, symlink/hardlink targets that escape the
unpack directory, and device/FIFO members — and counting every
extracted byte against a cap (zip-bomb guard). Any failure removes the
partial ``target_dir`` so no half-unpacked tree survives.
* :func:`unpack_archive` also restores each regular file's member mtime
(the zip DOS ``date_time`` or the tar ``mtime``) — phase 106, D2:
uploaded archives keep their file dates, which the importer reads as
the document creation date. Directories, symlinks, and hardlinks are
untouched; the cap and every safety check above are unchanged (the
``utime`` sits after a successful ``_write_capped``, so a failed
unpack still removes the partial tree).
* :func:`swap_in` makes ``new_dir`` become ``final_dir`` with **no
missing window**: the previous folder is renamed to a unique
same-filesystem ``.old-`` sibling first, the new folder is renamed
into place, then the sibling is deleted. A failed swap restores the
previous folder (best effort) and removes ``new_dir``.
"""
from __future__ import annotations
import os
import re
import shutil
import stat
import tarfile
import uuid
import zipfile
from datetime import UTC, datetime
from pathlib import Path, PurePosixPath
from typing import IO
#: Accepted archive suffixes, LONGEST FIRST — ``.tar.gz`` must match
#: before ``.tar`` would (phase 49 locked decision: these four formats
#: only).
ARCHIVE_SUFFIXES: tuple[str, ...] = (".tar.gz", ".tgz", ".zip", ".tar")
#: A client-supplied filename never legitimately contains a path
#: separator — reject rather than strip (defense in depth; the upload
#: also carries a browsed-path, which the browser normalizes).
_SEPARATOR_RE = re.compile(r"[/\\]")
#: Windows drive-letter prefix (``C:``) — an absolute member path in a
#: cross-platform zip.
_DRIVE_RE = re.compile(r"^[A-Za-z]:")
#: Streaming read size while counting extracted bytes.
_CHUNK_SIZE = 1 << 20
class ArchiveUploadError(Exception):
"""A user-safe archive/unpack error (the API maps it to a status).
Messages name the problem — and the cap where relevant — never the
archive's content and never any path beyond the owner's own upload
directory.
"""
def archive_source_name(filename: str) -> str:
"""The source name for an uploaded archive filename.
The filename is used exactly as sent — a client can never
legitimately embed a ``/`` or ``\\``, so a separator is *rejected*
(the defensive basename step below is then a no-op, kept for the
contract). ONE trailing archive suffix from :data:`ARCHIVE_SUFFIXES`
is removed, matched case-insensitively and longest-first:
``homelab.tar.gz`` → ``homelab``, ``a.tar.gz`` → ``a`` (a single
compound strip, not a double one), ``a.zip.zip`` → ``a.zip``. The
stem keeps its original case — the name becomes a folder name on a
Linux filesystem (case-sensitive).
Raises:
ArchiveUploadError: the filename is empty, contains a path
separator, or the stripped stem is empty / ``.`` / ``..`` /
contains control characters (the API maps to 422).
"""
if not filename:
raise ArchiveUploadError("empty file name")
if _SEPARATOR_RE.search(filename):
raise ArchiveUploadError("file name contains a path separator")
# No separator above, so the basename is the name itself — kept
# explicit so the "take the basename" contract lives in one place.
base = filename.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
lowered = base.lower()
for suffix in ARCHIVE_SUFFIXES: # longest first
if lowered.endswith(suffix):
base = base[: -len(suffix)]
break
if lowered == suffix.lstrip("."): # bare suffix ("tar.gz") — no stem
base = ""
break
if not base or base in (".", ".."):
raise ArchiveUploadError("archive file name has no usable source name")
if any(ord(c) < 0x20 or ord(c) == 0x7F for c in base):
raise ArchiveUploadError("archive file name contains control characters")
return base
def _member_dest(name: str, target: Path) -> Path:
"""The destination path for one archive member, or raise.
Rejects empty names, absolute names (POSIX ``/``, Windows ``\\`` or
drive letters), and any ``..`` path component (zip names are
``/``-separated; backslash forms are normalized before the check).
The final path is resolved against the target (following any symlink
an earlier member may have created) and must stay inside ``target``.
"""
if not name:
raise ArchiveUploadError("archive member with an empty name")
if name.startswith(("/", "\\")) or _DRIVE_RE.match(name):
raise ArchiveUploadError("archive member with an absolute path")
if ".." in PurePosixPath(name).parts or ".." in name.replace("\\", "/").split("/"):
raise ArchiveUploadError("archive member path traversal")
dest = target / name
target_resolved = target.resolve()
dest_resolved = dest.resolve()
if dest_resolved != target_resolved and target_resolved not in dest_resolved.parents:
raise ArchiveUploadError("archive member path escapes the unpack directory")
return dest
def _link_target_resolved(linkname: str, link_dir: Path, target: Path) -> Path:
"""Resolve a symlink/hardlink target against its member's directory.
The resolved target must stay inside ``target`` — anything else
(absolute targets, ``..`` climbs out) is rejected. Returns the
resolved path (used directly for hardlinks).
"""
if not linkname:
raise ArchiveUploadError("archive link with an empty target")
# An absolute linkname wins over the join (pathlib semantics) and is
# then caught by the containment check below.
resolved = (link_dir / linkname).resolve()
target_resolved = target.resolve()
if resolved != target_resolved and target_resolved not in resolved.parents:
raise ArchiveUploadError("archive link target escapes the unpack directory")
return resolved
def _write_capped(src: IO[bytes], dst: Path, max_extract_bytes: int, total: list[int]) -> None:
"""Stream ``src`` to ``dst``, counting into ``total[0]``.
Raises as soon as the cumulative extracted bytes EXCEED
``max_extract_bytes`` (the cap itself is exactly reachable). The
error names the cap, not the archive content.
"""
dst.parent.mkdir(parents=True, exist_ok=True)
with open(dst, "wb") as out:
while chunk := src.read(_CHUNK_SIZE):
total[0] += len(chunk)
if total[0] > max_extract_bytes:
raise ArchiveUploadError(
f"archive exceeds the {max_extract_bytes}-byte extraction cap"
)
out.write(chunk)
def _unpack_zip(archive: Path, target: Path, max_extract_bytes: int) -> None:
total: list[int] = [0]
with zipfile.ZipFile(archive) as zf:
for member in zf.infolist():
# The high 16 bits of external_attr are the Unix mode when
# present. Modes may be 0 (Windows-made zips), bare permission
# bits (CPython ``writestr``: 0o600), or a full mode with the
# file-type bits — only the latter can prove a member is a
# symlink/device/FIFO, and only those are rejected; entries
# without type bits are decided by the member name.
mode = member.external_attr >> 16
if stat.S_ISLNK(mode):
raise ArchiveUploadError("zip archives with symlink entries are not allowed")
if mode & 0o170000 and not (stat.S_ISREG(mode) or stat.S_ISDIR(mode)):
raise ArchiveUploadError("zip archives with non-regular entries are not allowed")
dest = _member_dest(member.filename, target)
if member.filename.endswith("/") or (mode and stat.S_ISDIR(mode)):
dest.mkdir(parents=True, exist_ok=True)
else:
with zf.open(member) as src:
_write_capped(src, dest, max_extract_bytes, total)
# Phase 106 (D2): restore the member's DOS mtime — a
# tz-agnostic epoch value, UTC-rendered exactly like an
# mtime — so uploaded archives keep their file dates.
# Regular files only; an OSError here still removes the
# partial tree in unpack_archive like any write failure.
# ``ns=`` takes INTEGER nanoseconds (a float seconds
# value raises TypeError), so convert explicitly.
mtime = datetime(*member.date_time, tzinfo=UTC).timestamp()
mtime_ns = int(mtime * 1_000_000_000)
os.utime(dest, ns=(mtime_ns, mtime_ns))
def _unpack_tar(archive: Path, target: Path, max_extract_bytes: int) -> None:
# ``r:*`` auto-detects plain/gz/bz2/xz compression.
total: list[int] = [0]
with tarfile.open(archive, mode="r:*") as tf:
for member in tf.getmembers():
dest = _member_dest(member.name, target)
if member.issym():
_link_target_resolved(member.linkname, dest.parent, target)
dest.parent.mkdir(parents=True, exist_ok=True)
os.symlink(member.linkname, dest)
elif member.islnk():
resolved = _link_target_resolved(member.linkname, dest.parent, target)
dest.parent.mkdir(parents=True, exist_ok=True)
os.link(resolved, dest)
elif member.isdir():
dest.mkdir(parents=True, exist_ok=True)
elif member.isreg():
src = tf.extractfile(member)
if src is None:
raise ArchiveUploadError("corrupt archive member")
_write_capped(src, dest, max_extract_bytes, total)
# Phase 106 (D2): restore the member's mtime (epoch
# seconds — a tz-agnostic value) so uploaded archives
# keep their file dates. Regular files only; an OSError
# here still removes the partial tree in unpack_archive.
# ``ns=`` takes INTEGER nanoseconds (a float seconds
# value raises TypeError), so convert explicitly.
mtime_ns = int(member.mtime * 1_000_000_000)
os.utime(dest, ns=(mtime_ns, mtime_ns))
else: # char/block device, FIFO
raise ArchiveUploadError(
"tar archives with device or FIFO members are not allowed"
)
def unpack_archive(archive: Path, target_dir: Path, max_extract_bytes: int) -> None:
"""Extract ``archive`` into ``target_dir`` (created empty here).
``target_dir`` must NOT exist yet — the caller passes a fresh unique
path (a temp sibling of the final folder). On ANY failure — bad or
corrupt archive, unsafe member, cap exceeded, OS error — the partial
``target_dir`` is removed so no half-unpacked tree survives, and the
failure is raised as :class:`ArchiveUploadError` (the module's only
public exception type).
"""
if target_dir.exists() or target_dir.is_symlink():
raise ArchiveUploadError("the unpack target already exists")
target_dir.mkdir(parents=True)
try:
# Content-sniff the container: a .zip that is really a tar (or a
# truncated file) falls through to the tar reader and fails
# loudly there instead of half-extracting.
if zipfile.is_zipfile(archive):
_unpack_zip(archive, target_dir, max_extract_bytes)
else:
_unpack_tar(archive, target_dir, max_extract_bytes)
except Exception as exc:
shutil.rmtree(target_dir, ignore_errors=True)
if isinstance(exc, ArchiveUploadError):
raise
raise ArchiveUploadError("could not unpack the archive") from exc
def swap_in(new_dir: Path, final_dir: Path) -> None:
"""Atomically make ``new_dir`` become ``final_dir`` (no missing window).
If ``final_dir`` exists it is first renamed to a unique same-
filesystem sibling ``<name>.old-<hex>``, then ``new_dir`` is renamed
into place, and the ``.old-`` sibling is deleted (re-uploads replace
the previous content in place — one folder, no stale files). If it
does not exist, this is a plain rename.
On a rename failure the previous folder is restored (best effort) and
``new_dir`` removed, then the failure is raised as
:class:`ArchiveUploadError` — a failed upload never leaves the
previous folder, row, or KB in a mixed state.
"""
old_dir: Path | None = None
try:
if final_dir.exists():
old_dir = final_dir.with_name(final_dir.name + ".old-" + uuid.uuid4().hex)
os.rename(final_dir, old_dir)
os.rename(new_dir, final_dir)
except OSError as exc:
if old_dir is not None:
try:
os.rename(old_dir, final_dir)
except OSError:
shutil.rmtree(old_dir, ignore_errors=True)
shutil.rmtree(new_dir, ignore_errors=True)
raise ArchiveUploadError("could not replace the previous folder") from exc
if old_dir is not None:
shutil.rmtree(old_dir)