fix: address 3 codex [P2] findings on list/get

- parse_repo_arg(): validate <name> / <owner>/<name> against a strict segment
  pattern. Not shell injection (argv list, no shell), but '?', '#', '..', an
  encoded slash or an extra path component could redirect the clone URL and
  the remote that gets persisted. Validate rather than quote — the forge's
  own naming rules are this narrow anyway.
- list now keys local folders on full_name, not bare name: an account that
  can see alice/cloud and bob/cloud showed BOTH as local when one was. `get`
  and `add` both record full_name; older entries fall back to <login>/<name>.
- list_repos truncation was off by one page: a repo total that is an exact
  multiple of the page size ends on a full page and was reported as
  truncated. One sentinel fetch past the cap separates complete from
  truncated.

Tests 65 -> 69, including hostile repo arguments and the exact-multiple case.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LTARYHX7GPepi3CH3tp5pg
This commit is contained in:
Nirav Patel
2026-08-22 14:29:05 -04:00
co-authored by Claude Opus 5
parent 88604a90f6
commit be11e319f5
2 changed files with 87 additions and 10 deletions
+46
View File
@@ -2,6 +2,7 @@
config handling, device-flow polling (mocked HTTP). Stdlib unittest only."""
import argparse
import io
import json
import os
import shlex
@@ -287,6 +288,20 @@ class TestListRepos(unittest.TestCase):
self.assertTrue(truncated)
self.assertEqual(len(repos), client.FORGE_MAX_PAGES * 50)
def test_exact_multiple_of_page_size_is_not_truncated(self):
"""Every page full up to the cap does not imply more exist -- a total
that is an exact multiple ends on a full page. The sentinel fetch
past the cap is what tells the two apart."""
def fake_http(method, url, headers=None, **kw):
page = int(url.split("page=")[1].split("&")[0])
return 200, self._page(page, 0 if page > client.FORGE_MAX_PAGES
else 50)
with mock.patch.object(client, "http_json", fake_http):
repos, truncated = client.list_repos(self.cfg)
self.assertFalse(truncated)
self.assertEqual(len(repos), client.FORGE_MAX_PAGES * 50)
def test_http_error_is_fatal_not_silent_empty(self):
with mock.patch.object(client, "http_json",
lambda *a, **k: (401, {"error": "bad token"})):
@@ -378,6 +393,37 @@ class TestGet(GitScenarioBase):
meta = client.load_config()["folders"][os.path.abspath(dest)]
self.assertEqual(meta["branch"], "main")
def test_get_rejects_hostile_repo_arguments(self):
for bad in ["../../etc/passwd", "alice/cloud?x=1", "alice/cloud#frag",
"a/b/c", "..", "-flag", "alice/../bob", "cloud%2f..",
"", "alice/"]:
with self.subTest(repo=bad):
with self.assertRaises(SystemExit):
client.cmd_get(argparse.Namespace(repo=bad, into=None))
def test_get_records_full_name_so_list_matches_the_right_owner(self):
dest = os.path.join(self.tmp, "pulled4")
client.cmd_get(argparse.Namespace(repo="cloud", into=dest))
meta = client.load_config()["folders"][os.path.abspath(dest)]
self.assertEqual(meta["full_name"], "alice/cloud")
def test_list_does_not_mark_a_same_named_other_owner_repo_as_local(self):
dest = os.path.join(self.tmp, "pulled5")
client.cmd_get(argparse.Namespace(repo="cloud", into=dest))
cfg = client.load_config()
repos = [{"name": "cloud", "full_name": "alice/cloud", "private": True,
"updated_at": "2026-08-20T00:00:00Z"},
{"name": "cloud", "full_name": "bob/cloud", "private": False,
"updated_at": "2026-08-20T00:00:00Z"}]
with mock.patch.object(client, "list_repos",
lambda c: (repos, False)), \
mock.patch("sys.stdout", new_callable=io.StringIO) as out:
client.cmd_list(argparse.Namespace())
lines = {l.split()[0]: l for l in out.getvalue().splitlines()
if l.strip()}
self.assertIn(os.path.abspath(dest), lines["alice/cloud"])
self.assertNotIn(os.path.abspath(dest), lines["bob/cloud"])
def test_get_never_puts_token_in_remote_url(self):
dest = os.path.join(self.tmp, "pulled3")
client.cmd_get(argparse.Namespace(repo="cloud", into=dest))