diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a3293e..d8b03cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,10 @@ jobs: - name: Compile backend run: python -m compileall -q backend + - name: Run backend unit tests + working-directory: backend + run: python -m unittest discover -s tests -v + frontend: runs-on: ubuntu-latest steps: diff --git a/backend/services/github.py b/backend/services/github.py index d345c48..94cd75a 100644 --- a/backend/services/github.py +++ b/backend/services/github.py @@ -73,16 +73,30 @@ def extract_zip(upload_path: Path): raise return repo_id, dest +def _resolve_repo_file(root: Path, candidate: Path): + root = root.resolve() + try: + resolved = candidate.resolve() + resolved.relative_to(root) + except (OSError, ValueError): + return None + if not resolved.is_file(): + return None + return resolved + + def iter_files(root: Path): + root = root.resolve() for p in root.rglob("*"): - if not p.is_file(): continue rel = p.relative_to(root) if any(part in SKIP_DIRS for part in rel.parts): continue - try: size = p.stat().st_size + resolved = _resolve_repo_file(root, p) + if resolved is None: continue + try: size = resolved.stat().st_size except OSError: continue if size > 300_000: continue if p.suffix.lower() in TEXT_EXTENSIONS or p.name.lower() in {"dockerfile","makefile"}: - yield p + yield resolved def read_text(path: Path): return path.read_text(encoding="utf-8", errors="replace") diff --git a/backend/tests/test_repository_boundary.py b/backend/tests/test_repository_boundary.py new file mode 100644 index 0000000..293ab5b --- /dev/null +++ b/backend/tests/test_repository_boundary.py @@ -0,0 +1,39 @@ +import tempfile +import unittest +from pathlib import Path + +from services.github import iter_files + + +class RepositoryFileBoundaryTests(unittest.TestCase): + def test_external_symlink_is_not_indexed(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + repo = root / "repo" + repo.mkdir() + outside = root / "outside.py" + outside.write_text("SECRET = True\n", encoding="utf-8") + link = repo / "linked.py" + try: + link.symlink_to(outside) + except (OSError, NotImplementedError): + self.skipTest("symlinks are unavailable on this platform") + + indexed = list(iter_files(repo)) + + self.assertEqual(indexed, []) + + def test_internal_file_is_still_indexed(self): + with tempfile.TemporaryDirectory() as temp_dir: + repo = Path(temp_dir) + source = repo / "src" / "main.py" + source.parent.mkdir() + source.write_text("print('ok')\n", encoding="utf-8") + + indexed = list(iter_files(repo)) + + self.assertEqual(indexed, [source.resolve()]) + + +if __name__ == "__main__": + unittest.main()