phase: 121_git_source_tokens
**Phase 121 final verification pass — all green** (all 4 tasks already in `complete/`; verified, no defects found, no changes needed) - Verified implementation vs phase design: migration `0021` (reversible, round-tripped via `alembic downgrade base` + `upgrade head` → head `0021`), `GitSource.token` column, `normalize_credential`/`clone_url_for`/`sanitize_url`, clone callers switched (`sync.py`, `import_docs.py`), masked token fields in add form + editor, `extra="forbid"` output shapes - Tests: `uv run pytest` → 2662 passed, 0 failed (exit 0); `uv run pytest --cov=app --cov-report=term-missing` → TOTAL **99%** (≥90% gate) - Lint/types: `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings - E2E in isolation: `uv run pytest tests/e2e/test_git_source_tokens.py -v --no-cov` → **4 passed** Completion criteria: 1. Private repo (UI add or pasted embedded-token URL) clones with injected token; token absent from every API response, page text, title attr, and full HTML — **PASS** (integration raw-JSON assertions + E2E `_assert_token_nowhere`) 2. Legacy embedded-token rows still clone from stored URL; output sanitized — **PASS** (`test_sync_legacy_row_clones_with_original_stored_url`, `test_get_masks_legacy_embedded_token_row`, env-fallback masking) 3. Public/local sources byte-identical — **PASS** (verbatim-URL + no-userinfo-unchanged tests) 4. pytest / coverage / ruff / pyright — **PASS** (see above) 5. Commit + phase move — harness responsibility; task files already in `complete/`, changes left in working tree (no commit made, per protocol) Notable: no deviations; DB left at head, functional. Next pending phase: **122_image_documents** (then 123_chat_image_questions).
This commit is contained in:
@@ -21,9 +21,26 @@ CLI: the legacy ``DEFAULT_SOURCES`` fallback).
|
||||
(repo URLs of the effective git rows) so existing importers of the old
|
||||
name keep working; new code calls :func:`effective_sources` and
|
||||
branches on ``row.kind``.
|
||||
|
||||
Phase 121 (private git sources) adds the token mechanics next to the
|
||||
resolver — three pure helpers, no DB of their own:
|
||||
|
||||
* :func:`sanitize_url` — the OUTPUT mask: strips the ``user:pass@``
|
||||
userinfo of ``https?://`` URLs so no API/UI surface ever shows an
|
||||
embedded credential (legacy rows included; the stored value is
|
||||
untouched — LOCKED A2);
|
||||
* :func:`clone_url_for` — the CLONE-time credential: a row's
|
||||
``token`` column is injected into the URL handed to git, and only
|
||||
there (NULL token → the bare stored URL verbatim);
|
||||
* :func:`normalize_credential` — the WRITE-path normalizer: an
|
||||
old-style ``https://user:pass@host/repo.git`` URL pasted into the
|
||||
API is stored bare and the embedded credential is moved into the
|
||||
``token`` column (an explicit ``token`` field wins — LOCKED A6).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Literal
|
||||
|
||||
from sqlalchemy import select
|
||||
@@ -32,6 +49,109 @@ from sqlalchemy.orm import Session
|
||||
from app.config import get_settings
|
||||
from app.models import GitSource
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Phase 121 — the userinfo component of an ``https?://`` URL: the
|
||||
#: scheme, a run of one-or-more characters that are neither ``@`` nor
|
||||
#: ``/`` (the ``user`` or ``user:pass`` part), and the terminating
|
||||
#: ``@``. Deliberately a small anchored regex — never a URL parser
|
||||
#: re-serialization: for a credential-free URL there is no match and
|
||||
#: the input is returned byte-identical (the phase-50/35 contract that
|
||||
#: stored URLs surface verbatim when they carry no credential).
|
||||
_USERINFO_RE = re.compile(r"^(https?://)([^/@]+)@")
|
||||
|
||||
|
||||
def sanitize_url(url: str) -> str:
|
||||
"""Phase 121, LOCKED A2 — the token-free form of a source URL,
|
||||
for API/UI output only.
|
||||
|
||||
Strips the userinfo component of ``https?://`` URLs
|
||||
(``https://user:pass@host/path`` → ``https://host/path``);
|
||||
``ssh://``, ``git@`` (scp-style), and local paths are left
|
||||
untouched. Idempotent — and byte-identical for URLs that carry no
|
||||
userinfo (no match → the input unchanged, including a ``@`` inside
|
||||
the *path*, which is not userinfo). The stored row value is NOT
|
||||
modified: a legacy row whose credential is still embedded in
|
||||
``url`` keeps cloning with its original stored URL; this is the
|
||||
output mask that keeps that credential out of every response and
|
||||
the UI (the env-fallback rows get the same treatment — the env
|
||||
*value* itself is untouched, only the response is masked).
|
||||
"""
|
||||
return _USERINFO_RE.sub(r"\1", url, count=1)
|
||||
|
||||
|
||||
def clone_url_for(row: GitSource) -> str:
|
||||
"""Phase 121, LOCKED A2 — the URL git actually clones, with the
|
||||
row's credential injected ONLY here.
|
||||
|
||||
* ``token`` NULL/falsy → ``row.url`` verbatim: public repos and
|
||||
local rows behave byte-identically to pre-phase-121, and a
|
||||
legacy embedded-token row (``token`` NULL, credential in the
|
||||
stored URL) keeps cloning with its ORIGINAL stored URL — the
|
||||
credential keeps working;
|
||||
* an ``https?://`` row with a token →
|
||||
``https://x-access-token:<token>@<host>/<path>`` — any existing
|
||||
userinfo in the stored URL is replaced by the column credential
|
||||
(``x-access-token`` as the username: GitHub-agnostic, any host
|
||||
that accepts ``https://user:token@`` treats the first component
|
||||
opaquely — the task-02 assumption, task 02 step 4);
|
||||
* a non-https row with a token (``ssh://``/``git@``/local path)
|
||||
→ ``row.url`` unchanged + a WARNING log (a token cannot
|
||||
authenticate ssh — the owner must use a deploy key/agent there;
|
||||
the log names the repo via its sanitized URL, never the token).
|
||||
|
||||
``repo_name`` (and every other checkout-path derivation) keeps
|
||||
operating on the bare ``row.url`` — the checkout directory name is
|
||||
credential-free.
|
||||
"""
|
||||
token = row.token
|
||||
if not token:
|
||||
return row.url
|
||||
if not row.url.startswith(("https://", "http://")):
|
||||
logger.warning(
|
||||
"git source %s has a stored token but a non-https? URL — "
|
||||
"a token cannot authenticate ssh/git@ clones; the stored "
|
||||
"URL is used as-is (configure a deploy key or SSH agent "
|
||||
"for private ssh repos)",
|
||||
sanitize_url(row.url),
|
||||
)
|
||||
return row.url
|
||||
bare = sanitize_url(row.url)
|
||||
return bare.replace("://", f"://x-access-token:{token}@", 1)
|
||||
|
||||
|
||||
def normalize_credential(url: str, token: str | None) -> tuple[str, str | None]:
|
||||
"""Phase 121, LOCKED A6 — the write-path credential normalizer.
|
||||
|
||||
If the (``https?://``-only) URL carries userinfo, it is stripped
|
||||
for storage and the EMBEDDED CREDENTIAL becomes the effective
|
||||
token — UNLESS the caller also sent an explicit ``token``
|
||||
(non-None), which WINS (explicit beats embedded — a blank masked
|
||||
field, i.e. an explicit "", is a deliberate "no credential").
|
||||
Pasting the old-style ``https://user:ghp_…@host/repo.git`` URL
|
||||
still works and lands token-column-clean; the caller stores the
|
||||
bare URL + ``effective_token or None`` (an empty explicit token
|
||||
stores NULL) and runs its duplicate check on the BARE URL, so the
|
||||
same repo with a different token is still the same source
|
||||
(409, not a second row).
|
||||
|
||||
The embedded credential is the *password* part of a
|
||||
``user:pass`` userinfo (after the first colon — the password may
|
||||
contain further colons), or the whole userinfo run for the
|
||||
username-as-token form (``https://<token>@host/…``, the documented
|
||||
GitHub shape, no colon). Clean URLs and ``ssh://``/``git@``/local
|
||||
paths return ``(url, token)`` untouched — byte-identical
|
||||
pre-phase behavior.
|
||||
"""
|
||||
match = _USERINFO_RE.match(url)
|
||||
if match is None:
|
||||
return url, token
|
||||
userinfo = match.group(2)
|
||||
user, sep, password = userinfo.partition(":")
|
||||
embedded = password if sep else userinfo
|
||||
effective = token if token is not None else embedded
|
||||
return sanitize_url(url), effective
|
||||
|
||||
|
||||
def effective_sources(db: Session) -> tuple[list[GitSource], Literal["db", "env"]]:
|
||||
"""``(rows, origin)`` — the effective source rows (both kinds) and
|
||||
|
||||
Reference in New Issue
Block a user