test: skip symlink-planting cases where the host cannot create symlinks (#5414)

* test: skip symlink-planting tests where the host lacks symlink privilege

* test: address symlink helper review feedback

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
GGbond 2026-09-14 11:01:31 +08:00 committed by GitHub
parent a2d417e0da
commit 9861c296d4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 75 additions and 27 deletions

View File

@ -0,0 +1,22 @@
"""Symlink-planting helper shared by suites that exercise symlink confinement.
Windows only grants symlink creation with Developer Mode enabled or an
elevated process, so a plain ``symlink_to`` raises ``OSError: [WinError
1314]`` on a stock Windows contributor machine. Suites that need real
symlinks skip exactly at the creation point instead of erroring, while hosts
with the privilege (CI, Linux, Developer Mode) run the full assertion.
"""
from __future__ import annotations
from pathlib import Path
import pytest
def symlink_or_skip(link: Path, target: Path, *, target_is_directory: bool = False) -> None:
"""Create ``link`` -> ``target``, skipping the test when the host cannot."""
try:
link.symlink_to(target, target_is_directory=target_is_directory)
except OSError:
pytest.skip("symlink creation is unavailable on this host (on Windows it requires Developer Mode or elevation)")

View File

@ -7,7 +7,7 @@ import os
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from support.symlinks import symlink_or_skip
from app.channels.base import Channel
from app.channels.message_bus import InboundMessage, MessageBus, OutboundMessage, ResolvedAttachment
@ -237,10 +237,7 @@ class TestResolveAttachments:
uploads_dir.mkdir(parents=True)
victim = uploads_dir / "secret.pdf"
victim.write_bytes(b"%PDF-1.4 secret")
try:
(outputs_dir / "report.pdf").symlink_to(victim)
except OSError:
pytest.skip("symlinks are unavailable on this platform")
symlink_or_skip(outputs_dir / "report.pdf", victim)
with patch("app.gateway.path_utils.get_paths", return_value=paths):
result = _resolve_attachments("t1", ["/mnt/user-data/outputs/report.pdf"], user_id="owner-1")
@ -316,7 +313,7 @@ class TestInboundFileIngestion:
uploads_dir = tmp_path / "uploads"
uploads_dir.mkdir()
outside_file = tmp_path / "outside-created.txt"
(uploads_dir / "victim.txt").symlink_to(outside_file)
symlink_or_skip(uploads_dir / "victim.txt", outside_file)
msg = InboundMessage(
channel_name="test-channel",
@ -345,7 +342,7 @@ class TestInboundFileIngestion:
uploads_dir = tmp_path / "uploads"
uploads_dir.mkdir()
missing_target = tmp_path / "missing-created.txt"
(uploads_dir / "victim.txt").symlink_to(missing_target)
symlink_or_skip(uploads_dir / "victim.txt", missing_target)
msg = InboundMessage(
channel_name="test-channel",

View File

@ -8,6 +8,7 @@ from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from support.symlinks import symlink_or_skip
from app.channels.commands import KNOWN_CHANNEL_COMMANDS
from app.channels.dingtalk import (
@ -2310,7 +2311,7 @@ class TestReceiveFile:
uploads = tmp_path / "uploads"
uploads.mkdir()
outside = tmp_path / "outside.txt"
(uploads / "image.png").symlink_to(outside)
symlink_or_skip(uploads / "image.png", outside)
_patch_uploads(monkeypatch, uploads)
channel._download_by_code = AsyncMock(return_value=b"PWNED")

View File

@ -16,6 +16,7 @@ from pathlib import Path
import pytest
import yaml
from support.symlinks import symlink_or_skip
from deerflow.extensions.cli import find_project_root
from deerflow.extensions.loader import ExtensionSpec
@ -1636,7 +1637,7 @@ def test_local_install_rejects_symlinks_before_copying_or_resolving(tmp_path: Pa
_write_local_extension(source)
outside = tmp_path / "operator-secret.txt"
outside.write_text("do not vendor me", encoding="utf-8")
(source / "linked-secret.txt").symlink_to(outside)
symlink_or_skip(source / "linked-secret.txt", outside)
original_pyproject = (root / "backend" / "pyproject.toml").read_bytes()
with pytest.raises(ValueError, match="symbolic links"):

View File

@ -6,6 +6,7 @@ from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest
from support.symlinks import symlink_or_skip
from app.channels import feishu as feishu_module
from app.channels.commands import KNOWN_CHANNEL_COMMANDS
@ -248,7 +249,7 @@ def test_feishu_receive_file_does_not_follow_planted_symlink(tmp_path, monkeypat
uploads = paths.sandbox_uploads_dir("thread-1", user_id="ou-user")
victim = tmp_path / "victim.txt"
victim.write_bytes(b"SAFE")
(uploads / "report.txt").symlink_to(victim)
symlink_or_skip(uploads / "report.txt", victim)
channel = _feishu_file_channel(_feishu_file_response("report.txt", b"PAYLOAD"))
provider = MagicMock()
@ -273,7 +274,7 @@ def test_feishu_receive_file_reserves_dangling_symlink_name(tmp_path, monkeypatc
paths.ensure_thread_dirs("thread-1", user_id="ou-user")
uploads = paths.sandbox_uploads_dir("thread-1", user_id="ou-user")
missing_target = tmp_path / "missing.txt"
(uploads / "report.txt").symlink_to(missing_target)
symlink_or_skip(uploads / "report.txt", missing_target)
channel = _feishu_file_channel(_feishu_file_response("report.txt", b"PAYLOAD"))
provider = MagicMock()

View File

@ -12,6 +12,7 @@ from pathlib import Path
import pytest
from fastapi import HTTPException
from support.symlinks import symlink_or_skip
from app.gateway.path_utils import OUTPUTS_VIRTUAL_ROOT, normalize_outputs_virtual_path, resolve_outputs_confined_path
from deerflow.config.paths import Paths
@ -32,13 +33,6 @@ def thread_dirs(tmp_path, monkeypatch) -> tuple[Path, Path]:
return outputs, uploads
def _symlink_or_skip(link: Path, target: Path) -> None:
try:
link.symlink_to(target)
except OSError:
pytest.skip("symlinks are unavailable on this platform")
class TestNormalizeOutputsVirtualPath:
def test_returns_canonical_absolute_virtual_path(self) -> None:
assert normalize_outputs_virtual_path("mnt/user-data/outputs/report.md") == "/mnt/user-data/outputs/report.md"
@ -100,7 +94,7 @@ class TestResolveOutputsConfinedPath:
outputs, uploads = thread_dirs
victim = uploads / "victim.txt"
victim.write_text("before", encoding="utf-8")
_symlink_or_skip(outputs / "linked.txt", victim)
symlink_or_skip(outputs / "linked.txt", victim)
with pytest.raises(HTTPException) as exc_info:
resolve_outputs_confined_path(THREAD_ID, "/mnt/user-data/outputs/linked.txt", user_id=USER_ID)
@ -113,7 +107,7 @@ class TestResolveOutputsConfinedPath:
outputs, _ = thread_dirs
outside = tmp_path / "outside.txt"
outside.write_text("outside", encoding="utf-8")
_symlink_or_skip(outputs / "linked.txt", outside)
symlink_or_skip(outputs / "linked.txt", outside)
with pytest.raises(HTTPException) as exc_info:
resolve_outputs_confined_path(THREAD_ID, "/mnt/user-data/outputs/linked.txt", user_id=USER_ID)
@ -129,7 +123,7 @@ class TestResolveOutputsConfinedPath:
real_outputs.mkdir()
(real_outputs / "report.md").write_text("hello", encoding="utf-8")
outputs.rmdir()
_symlink_or_skip(outputs, real_outputs)
symlink_or_skip(outputs, real_outputs)
with pytest.raises(HTTPException) as exc_info:
resolve_outputs_confined_path(THREAD_ID, "/mnt/user-data/outputs/report.md", user_id=USER_ID)

View File

@ -2,11 +2,11 @@
from __future__ import annotations
import os
import stat
from unittest.mock import patch
import pytest
from support.symlinks import symlink_or_skip
from deerflow.config.paths import Paths
from deerflow.skills.storage import get_or_new_skill_storage, reset_skill_storage
@ -151,7 +151,7 @@ def test_rejects_dotdot_only(storage):
def test_rejects_symlink_pointing_outside(tmp_path, storage, skill_dir):
outside = tmp_path / "outside.txt"
link = skill_dir / "escape_link.txt"
os.symlink(outside, link)
symlink_or_skip(link, outside)
with pytest.raises(ValueError, match="skill directory"):
storage.write_custom_skill("demo-skill", "escape_link.txt", "x")
@ -160,7 +160,7 @@ def test_rejects_symlink_dir_pointing_outside(tmp_path, storage, skill_dir):
outside_dir = tmp_path / "outside_dir"
outside_dir.mkdir()
link_dir = skill_dir / "linked_dir"
os.symlink(outside_dir, link_dir)
symlink_or_skip(link_dir, outside_dir)
with pytest.raises(ValueError, match="skill directory"):
storage.write_custom_skill("demo-skill", "linked_dir/file.txt", "x")
@ -175,7 +175,7 @@ def test_allows_symlink_within_skill_dir(tmp_path, storage, skill_dir):
real_file = skill_dir / "real.md"
real_file.write_text("real")
link = skill_dir / "alias.md"
os.symlink(real_file, link)
symlink_or_skip(link, real_file)
# Should not raise
storage.write_custom_skill("demo-skill", "alias.md", "updated")
# resolve() writes through to the real target file

View File

@ -2,6 +2,8 @@ import json
from types import SimpleNamespace
from unittest.mock import patch
from support.symlinks import symlink_or_skip
from deerflow.community.aio_sandbox.aio_sandbox import AioSandbox
from deerflow.config.paths import Paths
from deerflow.sandbox.local.local_sandbox import LocalSandbox, PathMapping
@ -358,7 +360,7 @@ def test_find_grep_matches_skips_symlink_outside_root(tmp_path) -> None:
workspace.mkdir()
outside = tmp_path / "outside.txt"
outside.write_text("TODO outside\n", encoding="utf-8")
(workspace / "outside-link.txt").symlink_to(outside)
symlink_or_skip(workspace / "outside-link.txt", outside)
matches, truncated = find_grep_matches(workspace, "TODO")

View File

@ -20,6 +20,7 @@ from skill_review_waivers import (
parse_manifest,
validate_manifest_against_facts,
)
from support.symlinks import symlink_or_skip
from deerflow.skills.review.analyzer import analyze_skill_package
from deerflow.skills.review.readers import LocalDirectoryReader
@ -221,7 +222,7 @@ def test_matching_waiver_rejects_symlinked_package_outside_repository(tmp_path:
external_target, digest = _write_target(external_root)
public_root = tmp_path / "skills/public"
public_root.mkdir(parents=True)
(public_root / "demo").symlink_to(external_target.parents[1], target_is_directory=True)
symlink_or_skip(public_root / "demo", external_target.parents[1], target_is_directory=True)
assert (
matching_waiver(

View File

@ -0,0 +1,29 @@
"""Pin the shared symlink-planting helper's two contracts (TDD for test infra)."""
from __future__ import annotations
from pathlib import Path
import pytest
from support.symlinks import symlink_or_skip
def test_creates_real_symlink_when_the_host_allows_it(tmp_path: Path) -> None:
target = tmp_path / "real.txt"
target.write_text("payload", encoding="utf-8")
link = tmp_path / "link.txt"
symlink_or_skip(link, target) # skips itself on hosts without the privilege
assert link.is_symlink()
assert link.read_text(encoding="utf-8") == "payload"
def test_skips_when_symlink_creation_is_denied(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
def _denied(self: Path, target: Path, *, target_is_directory: bool = False) -> None:
raise OSError(1314, "privilege not held")
monkeypatch.setattr(Path, "symlink_to", _denied)
with pytest.raises(pytest.skip.Exception):
symlink_or_skip(tmp_path / "link.txt", tmp_path / "real.txt")