Files
wehub-resource-sync 09e9f3545f
Test / Code Quality (push) Has been cancelled
Test / Test (macos-latest, Python 3.10) (push) Has been cancelled
Test / Test (macos-latest, Python 3.11) (push) Has been cancelled
Test / Test (macos-latest, Python 3.12) (push) Has been cancelled
Test / Test (macos-latest, Python 3.13) (push) Has been cancelled
Test / Test (macos-latest, Python 3.14) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.10) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.11) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.12) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.13) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.14) (push) Has been cancelled
Test / Test (windows-latest, Python 3.10) (push) Has been cancelled
Test / Test (windows-latest, Python 3.11) (push) Has been cancelled
Test / Test (windows-latest, Python 3.12) (push) Has been cancelled
Test / Test (windows-latest, Python 3.13) (push) Has been cancelled
Test / Test (windows-latest, Python 3.14) (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
dependency-audit / pip-audit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:30:13 +08:00

388 lines
14 KiB
Python

"""Symlink hardening for `notebooklm source add` file uploads.
The CLI must reject symlinked paths unless the user explicitly opts in via
``--follow-symlinks``. This guards against the footgun where a workspace
file like ``~/Downloads/foo.pdf`` is silently a symlink into ``/etc/`` or
similar — uploading the symlink target would leak sensitive content.
Tests cover both the **auto-detect** path (no ``--type``) and the
**explicit** ``--type file`` path — both must enforce the same gate.
The gate also rejects:
- Leaf symlinks (default).
- Parent-directory symlinks (``dir_link/file.pdf``).
- Broken symlinks (``exists()`` returns False but ``is_symlink()`` is True —
without the special-case we'd silently treat the path string as text
content).
When ``--follow-symlinks`` is passed, the resolved path is forwarded to
``add_file()`` so the upload opens exactly the file we validated (closes
the TOCTOU window).
"""
from __future__ import annotations
import os
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from click.testing import CliRunner
import notebooklm.auth as auth_module
import notebooklm.cli.helpers as helpers_module
from notebooklm import paths as paths_module
from notebooklm.notebooklm_cli import cli
from notebooklm.types import Source
def inject_client(client, *, recorder=None):
"""Local copy of the ``cli/conftest`` helper.
Defined inline because this test lives in ``tests/unit/`` (outside the
``tests/unit/cli`` package), so it cannot relatively import the shared helper
and a cross-package absolute import would be fragile.
"""
def factory(auth=None, **kwargs):
if recorder is not None:
recorder.append((auth, kwargs))
return client
return {"client_factory": factory}
@pytest.fixture
def runner() -> CliRunner:
return CliRunner()
@pytest.fixture
def mock_auth(tmp_path: Path, monkeypatch):
"""Stub auth loading + token fetch so CLI paths run offline.
Also points ``get_storage_path`` at a non-existent path inside the
test's ``tmp_path`` so ``build_cookie_jar`` takes the in-memory branch
(using the cookie dict returned by our mock) instead of reading any
real storage file the runner may have left behind from a prior test.
Without this, ``build_cookie_jar`` calls ``build_httpx_cookies_from_storage``
on the runner's default-profile storage, which validates against the
real cookie set and fails this test with
"Missing required cookies: SID, __Secure-1PSIDTS".
"""
fake_storage = tmp_path / "no_such_storage.json"
monkeypatch.setattr(paths_module, "get_storage_path", lambda profile=None, **_kw: fake_storage)
with (
patch.object(helpers_module, "load_auth_from_storage") as mock_load,
patch.object(
auth_module, "fetch_tokens_with_domains", new_callable=AsyncMock
) as mock_fetch,
):
mock_load.return_value = {
"SID": "test",
"HSID": "test",
"SSID": "test",
"APISID": "test",
"SAPISID": "test",
}
mock_fetch.return_value = ("csrf", "session")
yield mock_load
def _make_client() -> MagicMock:
"""Build a mock NotebookLMClient suitable for ``source add`` paths.
Pre-seeds notebook list so ``-n nb_123`` resolves cleanly.
"""
client = MagicMock()
client.__aenter__ = AsyncMock(return_value=client)
client.__aexit__ = AsyncMock(return_value=None)
for ns in ("notebooks", "sources", "artifacts", "chat", "research", "notes", "sharing"):
setattr(client, ns, MagicMock())
nb_obj = MagicMock()
nb_obj.id = "nb_123"
nb_obj.title = "Test Notebook"
client.notebooks.list = AsyncMock(return_value=[nb_obj])
client.notebooks.get = AsyncMock(return_value=nb_obj)
# Default upload stubs; individual tests override as needed.
client.sources.add_file = AsyncMock(return_value=Source(id="src_default", title="x"))
client.sources.add_text = AsyncMock(return_value=Source(id="src_text", title="x"))
return client
def _invoke(runner: CliRunner, mock_client: MagicMock, argv: list[str]):
"""Run the CLI with ``mock_client`` injected via ``ctx.obj``."""
return runner.invoke(cli, argv, obj=inject_client(mock_client), catch_exceptions=False)
class TestAutoDetectFilePath:
"""Auto-detect (no ``--type``) branch — the original PR surface."""
def test_plain_regular_file_uploads(self, runner, mock_auth, tmp_path: Path) -> None:
"""A non-symlinked file passes the gate and is uploaded.
Also locks down the *exact* path forwarded to ``add_file()``: the
resolved Path, not the raw argument. This is what closes the
TOCTOU window (issue called out by coderabbit on the initial
review pass).
"""
real_file = tmp_path / "doc.pdf"
real_file.write_bytes(b"fake pdf content")
assert not real_file.is_symlink()
mock_client = _make_client()
mock_client.sources.add_file = AsyncMock(
return_value=Source(id="src_real", title="doc.pdf")
)
result = _invoke(runner, mock_client, ["source", "add", str(real_file), "-n", "nb_123"])
assert result.exit_code == 0, result.output
mock_client.sources.add_file.assert_awaited_once()
called_path = mock_client.sources.add_file.await_args.args[1]
# The CLI passes the resolved path so add_file opens the same
# file we validated. For a non-symlink in a real tmp_path, the
# resolved path equals the raw input after expanduser().
assert called_path == str(real_file.expanduser().resolve())
def test_symlink_without_flag_is_rejected(self, runner, mock_auth, tmp_path: Path) -> None:
"""Symlink without ``--follow-symlinks`` exits 1 with a clear error."""
target = tmp_path / "target.pdf"
target.write_bytes(b"sensitive content")
link = tmp_path / "link.pdf"
os.symlink(target, link)
assert link.is_symlink()
mock_client = _make_client()
mock_client.sources.add_file = AsyncMock(
return_value=Source(id="should_not_be_used", title="should_not_be_used")
)
result = _invoke(runner, mock_client, ["source", "add", str(link), "-n", "nb_123"])
assert result.exit_code == 1
assert "symlink" in result.output.lower()
assert "--follow-symlinks" in result.output
assert str(link) in result.output
mock_client.sources.add_file.assert_not_awaited()
def test_symlink_with_flag_is_followed_and_uploaded(
self, runner, mock_auth, tmp_path: Path
) -> None:
"""``--follow-symlinks`` resolves the symlink and uploads the target.
Verifies the *resolved* path is what's handed to ``add_file()``
(not the symlink path), closing the TOCTOU window.
"""
target = tmp_path / "target.pdf"
target.write_bytes(b"real content")
link = tmp_path / "link.pdf"
os.symlink(target, link)
assert link.is_symlink()
mock_client = _make_client()
mock_client.sources.add_file = AsyncMock(
return_value=Source(id="src_resolved", title="target.pdf")
)
result = _invoke(
runner,
mock_client,
["source", "add", str(link), "-n", "nb_123", "--follow-symlinks"],
)
assert result.exit_code == 0, result.output
mock_client.sources.add_file.assert_awaited_once()
called_path = mock_client.sources.add_file.await_args.args[1]
# The upload must hit the *target*, not the link, after opt-in.
assert called_path == str(target.expanduser().resolve())
def test_broken_symlink_without_flag_is_rejected(
self, runner, mock_auth, tmp_path: Path
) -> None:
"""Broken symlink without flag is rejected by the symlink gate.
``exists()`` follows symlinks and returns False for dangling
links — without our special-case OR, the CLI would silently
treat the broken-link *path string* as text content. We assert
the symlink-rejection error fires.
"""
missing = tmp_path / "does_not_exist.pdf"
link = tmp_path / "broken_link.pdf"
os.symlink(missing, link)
assert link.is_symlink()
assert not link.exists() # broken — exists() follows the link
mock_client = _make_client()
mock_client.sources.add_text = AsyncMock(
return_value=Source(id="should_not_be_used_text", title="broken_link.pdf")
)
result = _invoke(runner, mock_client, ["source", "add", str(link), "-n", "nb_123"])
assert result.exit_code == 1
assert "symlink" in result.output.lower()
assert "--follow-symlinks" in result.output
# Critical: no upload of any kind. We must not silently turn the
# link string into a text source.
mock_client.sources.add_file.assert_not_awaited()
mock_client.sources.add_text.assert_not_awaited()
def test_broken_symlink_with_flag_falls_through_to_not_a_file(
self, runner, mock_auth, tmp_path: Path
) -> None:
"""Broken symlink + ``--follow-symlinks`` hits the regular-file gate.
After the symlink gate is bypassed, ``resolve()`` produces a path
whose target doesn't exist, so ``is_file()`` is False and we
surface "Not a regular file" instead of attempting an upload.
"""
missing = tmp_path / "does_not_exist.pdf"
link = tmp_path / "broken_link.pdf"
os.symlink(missing, link)
assert link.is_symlink()
assert not link.exists()
mock_client = _make_client()
mock_client.sources.add_file = AsyncMock(
return_value=Source(id="should_not_be_used", title="should_not_be_used")
)
result = _invoke(
runner,
mock_client,
["source", "add", str(link), "-n", "nb_123", "--follow-symlinks"],
)
assert result.exit_code == 1
assert "not a regular file" in result.output.lower()
mock_client.sources.add_file.assert_not_awaited()
def test_parent_directory_symlink_is_rejected(self, runner, mock_auth, tmp_path: Path) -> None:
"""A symlinked *parent* directory triggers the gate.
``raw.is_symlink()`` only inspects the leaf path, so
``dir_link/file.pdf`` would slip through a leaf-only check. The
helper walks parents — assert here that the gate fires.
"""
real_dir = tmp_path / "real_dir"
real_dir.mkdir()
real_file = real_dir / "doc.pdf"
real_file.write_bytes(b"content")
dir_link = tmp_path / "dir_link"
os.symlink(real_dir, dir_link)
path_through_link = dir_link / "doc.pdf"
assert path_through_link.exists() # the leaf itself is a real file
assert not path_through_link.is_symlink() # leaf alone looks clean
assert dir_link.is_symlink() # but the parent is a link
mock_client = _make_client()
result = _invoke(
runner, mock_client, ["source", "add", str(path_through_link), "-n", "nb_123"]
)
assert result.exit_code == 1
assert "symlink" in result.output.lower()
mock_client.sources.add_file.assert_not_awaited()
class TestExplicitTypeFile:
"""``--type file`` path — the security gap closed in the review pass.
Without these tests, ``source add link.pdf --type file`` would
silently bypass the symlink gate and leak the link target. Locked
down by parametrized variants that mirror the auto-detect tests.
"""
def test_explicit_type_file_with_plain_file_uploads(
self, runner, mock_auth, tmp_path: Path
) -> None:
"""Sanity check: ``--type file`` on a normal file still works."""
real_file = tmp_path / "doc.pdf"
real_file.write_bytes(b"fake pdf content")
mock_client = _make_client()
mock_client.sources.add_file = AsyncMock(
return_value=Source(id="src_real", title="doc.pdf")
)
result = _invoke(
runner,
mock_client,
["source", "add", str(real_file), "--type", "file", "-n", "nb_123"],
)
assert result.exit_code == 0, result.output
mock_client.sources.add_file.assert_awaited_once()
called_path = mock_client.sources.add_file.await_args.args[1]
assert called_path == str(real_file.expanduser().resolve())
def test_explicit_type_file_with_symlink_no_flag_is_rejected(
self, runner, mock_auth, tmp_path: Path
) -> None:
"""Critical: explicit ``--type file`` MUST honor the symlink gate.
This is the path most likely used by automation scripts (which
often hardcode ``--type``), so it's the higher-risk surface.
Pre-fix, this case bypassed the guard completely.
"""
target = tmp_path / "target.pdf"
target.write_bytes(b"sensitive content")
link = tmp_path / "link.pdf"
os.symlink(target, link)
mock_client = _make_client()
mock_client.sources.add_file = AsyncMock(
return_value=Source(id="should_not_be_used", title="should_not_be_used")
)
result = _invoke(
runner,
mock_client,
["source", "add", str(link), "--type", "file", "-n", "nb_123"],
)
assert result.exit_code == 1
assert "symlink" in result.output.lower()
assert "--follow-symlinks" in result.output
mock_client.sources.add_file.assert_not_awaited()
def test_explicit_type_file_with_symlink_and_flag_is_followed(
self, runner, mock_auth, tmp_path: Path
) -> None:
"""``--type file --follow-symlinks`` resolves and uploads."""
target = tmp_path / "target.pdf"
target.write_bytes(b"real content")
link = tmp_path / "link.pdf"
os.symlink(target, link)
mock_client = _make_client()
mock_client.sources.add_file = AsyncMock(
return_value=Source(id="src_resolved", title="target.pdf")
)
result = _invoke(
runner,
mock_client,
[
"source",
"add",
str(link),
"--type",
"file",
"-n",
"nb_123",
"--follow-symlinks",
],
)
assert result.exit_code == 0, result.output
mock_client.sources.add_file.assert_awaited_once()
called_path = mock_client.sources.add_file.await_args.args[1]
assert called_path == str(target.expanduser().resolve())