Skip to content

Commit 2f23704

Browse files
committed
test(ci): avoid FastAPI dependency by extracting delta bundle processing
Move process_delta_bundle/get_workspace_key into scripts/upload_delta_bundle so unit tests don’t need to import scripts/upload_service (and therefore don’t require fastapi in CI). Update upload_service to delegate to the new module and adjust traversal tests accordingly.
1 parent f5826df commit 2f23704

3 files changed

Lines changed: 262 additions & 247 deletions

File tree

scripts/upload_delta_bundle.py

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
import os
2+
import json
3+
import tarfile
4+
import hashlib
5+
import logging
6+
from pathlib import Path
7+
from typing import Dict, Any
8+
9+
10+
try:
11+
from scripts.workspace_state import _extract_repo_name_from_path
12+
except ImportError:
13+
_extract_repo_name_from_path = None
14+
15+
16+
logger = logging.getLogger(__name__)
17+
18+
WORK_DIR = os.environ.get("WORK_DIR", "/work")
19+
20+
21+
def get_workspace_key(workspace_path: str) -> str:
22+
"""Generate 16-char hash for collision avoidance in remote uploads.
23+
24+
Remote uploads may have identical folder names from different users,
25+
so uses longer hash than local indexing (8-chars) to ensure uniqueness.
26+
27+
Both host paths (/home/user/project/repo) and container paths (/work/repo)
28+
should generate the same key for the same repository.
29+
"""
30+
repo_name = Path(workspace_path).name
31+
return hashlib.sha256(repo_name.encode("utf-8")).hexdigest()[:16]
32+
33+
34+
def _cleanup_empty_dirs(path: Path, stop_at: Path) -> None:
35+
"""Recursively remove empty directories up to stop_at (exclusive)."""
36+
try:
37+
path = path.resolve()
38+
stop_at = stop_at.resolve()
39+
except Exception:
40+
pass
41+
while True:
42+
try:
43+
if path == stop_at or not path.exists() or not path.is_dir():
44+
break
45+
if any(path.iterdir()):
46+
break
47+
path.rmdir()
48+
path = path.parent
49+
except Exception:
50+
break
51+
52+
53+
def process_delta_bundle(workspace_path: str, bundle_path: Path, manifest: Dict[str, Any]) -> Dict[str, int]:
54+
"""Process delta bundle and return operation counts."""
55+
operations_count = {
56+
"created": 0,
57+
"updated": 0,
58+
"deleted": 0,
59+
"moved": 0,
60+
"skipped": 0,
61+
"failed": 0,
62+
}
63+
64+
try:
65+
# CRITICAL: Always materialize writes under WORK_DIR using a slugged repo directory.
66+
# Do NOT write directly into the client-supplied workspace_path, since that may be a host
67+
# path (e.g. /home/user/repo) that is not mounted/visible to the watcher/indexer.
68+
if _extract_repo_name_from_path:
69+
repo_name = _extract_repo_name_from_path(workspace_path)
70+
if not repo_name:
71+
repo_name = Path(workspace_path).name
72+
else:
73+
repo_name = Path(workspace_path).name
74+
75+
# Workspace slug: <repo_name>-<16charhash>. This ensures uniqueness across users/workspaces
76+
# that may share the same leaf folder name.
77+
workspace_key = get_workspace_key(workspace_path)
78+
workspace = Path(WORK_DIR) / f"{repo_name}-{workspace_key}"
79+
workspace.mkdir(parents=True, exist_ok=True)
80+
slug_repo_name = f"{repo_name}-{workspace_key}"
81+
82+
workspace_root = workspace.resolve()
83+
84+
def _safe_join(base: Path, rel: str) -> Path:
85+
# SECURITY: Prevent path traversal / absolute-path writes by ensuring the resolved
86+
# candidate path stays within the intended workspace root.
87+
rp = Path(str(rel))
88+
if str(rp) in {".", ""}:
89+
raise ValueError("Invalid operation path")
90+
if rp.is_absolute():
91+
raise ValueError(f"Absolute paths are not allowed: {rel}")
92+
base_resolved = base.resolve()
93+
candidate = (base_resolved / rp).resolve()
94+
try:
95+
ok = candidate.is_relative_to(base_resolved)
96+
except Exception:
97+
ok = os.path.commonpath([str(base_resolved), str(candidate)]) == str(base_resolved)
98+
if not ok:
99+
raise ValueError(f"Path escapes workspace: {rel}")
100+
return candidate
101+
102+
with tarfile.open(bundle_path, "r:gz") as tar:
103+
ops_member = None
104+
for member in tar.getnames():
105+
if member.endswith("metadata/operations.json"):
106+
ops_member = member
107+
break
108+
109+
if not ops_member:
110+
raise ValueError("operations.json not found in bundle")
111+
112+
ops_file = tar.extractfile(ops_member)
113+
if not ops_file:
114+
raise ValueError("Cannot extract operations.json")
115+
116+
operations_data = json.loads(ops_file.read().decode("utf-8"))
117+
operations = operations_data.get("operations", [])
118+
119+
# Best-effort: extract git history metadata for watcher to ingest
120+
try:
121+
git_member = None
122+
for member in tar.getnames():
123+
if member.endswith("metadata/git_history.json"):
124+
git_member = member
125+
break
126+
if git_member:
127+
git_file = tar.extractfile(git_member)
128+
if git_file:
129+
history_bytes = git_file.read()
130+
history_dir = workspace / ".remote-git"
131+
history_dir.mkdir(parents=True, exist_ok=True)
132+
bundle_id = manifest.get("bundle_id") or "unknown"
133+
history_path = history_dir / f"git_history_{bundle_id}.json"
134+
try:
135+
history_path.write_bytes(history_bytes)
136+
except Exception as write_err:
137+
logger.debug(
138+
f"[upload_service] Failed to write git history manifest: {write_err}",
139+
)
140+
except Exception as git_err:
141+
logger.debug(f"[upload_service] Error extracting git history metadata: {git_err}")
142+
143+
for operation in operations:
144+
op_type = operation.get("operation")
145+
rel_path = operation.get("path")
146+
147+
if not rel_path:
148+
operations_count["skipped"] += 1
149+
continue
150+
151+
# Defensive guard: if the operation path already includes the slugged repo name
152+
# ("<repo>-<hash>/..."), then writing it under workspace_root would create
153+
# a nested slug directory ("slug/slug/..."), which is almost always client misuse.
154+
if rel_path == slug_repo_name or rel_path.startswith(slug_repo_name + "/"):
155+
msg = (
156+
f"[upload_service] Refusing to apply operation {op_type} for suspicious path {rel_path} "
157+
f"which already contains workspace slug {slug_repo_name}"
158+
)
159+
logger.error(msg)
160+
raise ValueError(msg)
161+
162+
target_path = _safe_join(workspace_root, rel_path)
163+
164+
safe_source_path = None
165+
source_rel_path = None
166+
if op_type == "moved":
167+
source_rel_path = operation.get("source_path") or operation.get("source_relative_path")
168+
if source_rel_path:
169+
safe_source_path = _safe_join(workspace_root, source_rel_path)
170+
171+
try:
172+
if op_type == "created":
173+
file_member = None
174+
for member in tar.getnames():
175+
if member.endswith(f"files/created/{rel_path}"):
176+
file_member = member
177+
break
178+
179+
if file_member:
180+
file_content = tar.extractfile(file_member)
181+
if file_content:
182+
target_path.parent.mkdir(parents=True, exist_ok=True)
183+
target_path.write_bytes(file_content.read())
184+
operations_count["created"] += 1
185+
else:
186+
operations_count["failed"] += 1
187+
else:
188+
operations_count["failed"] += 1
189+
190+
elif op_type == "updated":
191+
file_member = None
192+
for member in tar.getnames():
193+
if member.endswith(f"files/updated/{rel_path}"):
194+
file_member = member
195+
break
196+
197+
if file_member:
198+
file_content = tar.extractfile(file_member)
199+
if file_content:
200+
target_path.parent.mkdir(parents=True, exist_ok=True)
201+
target_path.write_bytes(file_content.read())
202+
operations_count["updated"] += 1
203+
else:
204+
operations_count["failed"] += 1
205+
else:
206+
operations_count["failed"] += 1
207+
208+
elif op_type == "moved":
209+
file_member = None
210+
for member in tar.getnames():
211+
if member.endswith(f"files/moved/{rel_path}"):
212+
file_member = member
213+
break
214+
215+
if file_member:
216+
file_content = tar.extractfile(file_member)
217+
if file_content:
218+
target_path.parent.mkdir(parents=True, exist_ok=True)
219+
target_path.write_bytes(file_content.read())
220+
operations_count["moved"] += 1
221+
else:
222+
operations_count["failed"] += 1
223+
else:
224+
operations_count["failed"] += 1
225+
226+
if safe_source_path is not None and source_rel_path:
227+
if safe_source_path.exists():
228+
try:
229+
safe_source_path.unlink()
230+
operations_count["deleted"] += 1
231+
_cleanup_empty_dirs(safe_source_path.parent, workspace)
232+
except Exception as del_err:
233+
logger.error(
234+
f"Error deleting source file for move {source_rel_path}: {del_err}",
235+
)
236+
237+
elif op_type == "deleted":
238+
if target_path.exists():
239+
target_path.unlink()
240+
_cleanup_empty_dirs(target_path.parent, workspace)
241+
operations_count["deleted"] += 1
242+
else:
243+
operations_count["skipped"] += 1
244+
245+
else:
246+
operations_count["skipped"] += 1
247+
248+
except Exception as e:
249+
logger.error(f"Error processing operation {op_type} for {rel_path}: {e}")
250+
operations_count["failed"] += 1
251+
252+
return operations_count
253+
254+
except Exception as e:
255+
logger.error(f"Error processing delta bundle: {e}")
256+
raise

0 commit comments

Comments
 (0)