Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/skillspector/nodes/resolve_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,10 @@ def resolve_input(state: SkillspectorState) -> dict[str, object]:
exc.truncation.code,
)
raise
except (ValueError, FileNotFoundError):
except Exception:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Clean up interrupted materializations too

KeyboardInterrupt and cancellation signals derived from BaseException bypass this handler. If interruption arrives after _get_temp_dir() during _download_file, the partial download and handler directory remain; during _clone_git, the child process and its tree can remain as well. Nearby extraction and single-file-copy cleanup already uses except BaseException specifically to release resources before re-raising. Please make failure cleanup cover interruption paths, ensure an interrupted clone terminates its child before tree removal, and add a regression that raises after temp allocation.

# The graph fails before returning temp_dir_for_cleanup, so no caller
# can remove a partial download or extraction directory afterwards.
handler.cleanup()
raise

if skill_path and isinstance(skill_path, str) and skill_path.strip():
Expand Down
45 changes: 45 additions & 0 deletions tests/nodes/test_resolve_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,12 @@

"""Tests for resolve_input node."""

import io
import tempfile
import zipfile
from pathlib import Path

import httpx
import pytest

from skillspector.input_handler import TransitiveIngestTruncatedError
Expand Down Expand Up @@ -131,3 +135,44 @@ def cleanup(self) -> None:
}
assert cleaned == [True]
assert "private/source" not in str(raised.value)


@pytest.mark.parametrize("source", ["local-zip", "downloaded-zip"])
def test_failed_materialization_removes_the_handler_temp_dir(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, source: str
) -> None:
"""An input that fails to materialize leaves no temp directory or download behind."""
archive = io.BytesIO()
with zipfile.ZipFile(archive, "w") as bundle:
bundle.writestr("SKILL.md", "# Skill\n")
bundle.writestr("../outside.md", "escape\n")
created: list[Path] = []
real_mkdtemp = tempfile.mkdtemp

def mkdtemp(*args: str, **kwargs: str) -> str:
path = real_mkdtemp(*args, dir=tmp_path, **kwargs)
created.append(Path(path))
return path

monkeypatch.setattr("skillspector.input_handler.tempfile.mkdtemp", mkdtemp)
if source == "local-zip":
local_zip = tmp_path / "slip.zip"
local_zip.write_bytes(archive.getvalue())
input_path = str(local_zip)
else:
real_client = httpx.Client
transport = httpx.MockTransport(
lambda _request: httpx.Response(200, content=archive.getvalue())
)
monkeypatch.setattr(
"skillspector.input_handler.httpx.Client",
lambda *args, **kwargs: real_client(*args, transport=transport, **kwargs),
)
monkeypatch.setattr("skillspector.input_handler._is_private_ip", lambda _host: False)
input_path = "https://raw.githubusercontent.com/org/repo/main/skill.zip"

with pytest.raises(ValueError, match="zip-slip"):
resolve_input({"input_path": input_path})

assert created
assert [path for path in created if path.exists()] == []
Loading