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
19 changes: 19 additions & 0 deletions ctfcli/core/challenge.py
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,20 @@ def _get_existing_solution_id(self) -> int | None:
return solution["id"]
return None

def _delete_solution_files(self, content: str) -> None:
locations = re.findall(r"/files/([^)\s]+)", content or "")
if not locations:
return

remote_files = self.api.get("/api/v1/files?type=solution").json()["data"]
file_ids_by_location = {f["location"]: f["id"] for f in remote_files}

for location in locations:
file_id = file_ids_by_location.get(location)
if file_id is not None:
r = self.api.delete(f"/api/v1/files/{file_id}")
r.raise_for_status()

def _create_solution(self):
resolved_solution = self._resolve_solution_path()
if not resolved_solution:
Expand All @@ -564,6 +578,11 @@ def _create_solution(self):
r.raise_for_status()
solution_id = r.json()["data"]["id"]
else:
r = self.api.get(f"/api/v1/solutions/{solution_id}")
r.raise_for_status()
previous_content = r.json()["data"].get("content") or ""
self._delete_solution_files(previous_content)

# Keep solution state in sync and clear stale content before rebuilding references.
r = self.api.patch(
f"/api/v1/solutions/{solution_id}",
Expand Down
63 changes: 63 additions & 0 deletions tests/core/test_challenge.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,25 @@ def mock_get(*args, **kwargs):
"data": [{"id": 9, "challenge_id": 1, "state": "hidden", "content": "old"}],
}
return mock_response
if path == "/api/v1/solutions/9":
mock_response = MagicMock()
mock_response.json.return_value = {
"success": True,
"data": {
"id": 9,
"challenge_id": 1,
"state": "hidden",
"content": "old ![x](/files/stale-location/old.png)",
},
}
return mock_response
if path == "/api/v1/files?type=solution":
mock_response = MagicMock()
mock_response.json.return_value = {
"success": True,
"data": [{"id": 42, "location": "stale-location/old.png"}],
}
return mock_response
return MagicMock()

mock_api: MagicMock = mock_api_constructor.return_value
Expand All @@ -275,6 +294,8 @@ def mock_get(*args, **kwargs):
challenge._create_solution()

mock_api.post.assert_not_called()
# Previously uploaded solution files should be deleted before re-uploading
mock_api.delete.assert_called_once_with("/api/v1/files/42")
mock_api.patch.assert_has_calls(
[
call("/api/v1/solutions/9", json={"state": "solved", "content": ""}),
Expand All @@ -283,6 +304,48 @@ def mock_get(*args, **kwargs):
any_order=True,
)

@mock.patch("ctfcli.core.challenge.API")
def test_delete_solution_files_removes_referenced_files(self, mock_api_constructor: MagicMock):
challenge = Challenge(self.minimal_challenge)
challenge.challenge_id = 1

def mock_get(*args, **kwargs):
if args[0] == "/api/v1/files?type=solution":
mock_response = MagicMock()
mock_response.json.return_value = {
"success": True,
"data": [
{"id": 1, "location": "loc-a/a.png"},
{"id": 2, "location": "loc-b/b.png"},
{"id": 3, "location": "loc-c/c.png"},
],
}
return mock_response
return MagicMock()

mock_api: MagicMock = mock_api_constructor.return_value
mock_api.get.side_effect = mock_get

challenge._delete_solution_files("![a](/files/loc-a/a.png) and ![c](/files/loc-c/c.png)")

# Only files referenced in the content should be deleted, not the unreferenced one
mock_api.delete.assert_has_calls(
[call("/api/v1/files/1"), call("/api/v1/files/3")],
any_order=True,
)
self.assertEqual(mock_api.delete.call_count, 2)

@mock.patch("ctfcli.core.challenge.API")
def test_delete_solution_files_noop_without_references(self, mock_api_constructor: MagicMock):
challenge = Challenge(self.minimal_challenge)
challenge.challenge_id = 1

mock_api: MagicMock = mock_api_constructor.return_value
challenge._delete_solution_files("no files referenced here")

mock_api.get.assert_not_called()
mock_api.delete.assert_not_called()

@mock.patch("ctfcli.core.challenge.API")
def test_does_not_create_solution_if_not_specified(self, mock_api_constructor: MagicMock):
challenge = Challenge(self.minimal_challenge)
Expand Down