Skip to content
Closed
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
45 changes: 34 additions & 11 deletions src/blaxel/core/image/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -786,7 +786,7 @@ def _create_zip(self, build_dir: Path) -> bytes:
build_dir = build_dir.resolve()
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
for file_path in build_dir.rglob("*"):
for file_path in sorted(build_dir.rglob("*")):
if file_path.is_file():
# Resolve to handle symlinks and get the real path
resolved_path = file_path.resolve()
Expand All @@ -799,11 +799,22 @@ def _create_zip(self, build_dir: Path) -> bytes:
f"Path traversal detected: {file_path} resolves outside build directory"
)

arcname = file_path.relative_to(build_dir)
zf.write(resolved_path, arcname)
# ZIP headers normally include source mtimes and traversal order,
# making byte-identical contexts produce different archives. Pin
# those fields so the archive checksum is a stable content key.
arcname = file_path.relative_to(build_dir).as_posix()
info = zipfile.ZipInfo(arcname, date_time=(1980, 1, 1, 0, 0, 0))
info.compress_type = zipfile.ZIP_DEFLATED
info.external_attr = (resolved_path.stat().st_mode & 0xFFFF) << 16
with resolved_path.open("rb") as source, zf.open(info, "w") as target:
shutil.copyfileobj(source, target, length=1024 * 1024)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Large files lose ZIP64 sizing

Low Severity

Manual ZipInfo entries leave file_size at 0 before ZipFile.open(..., "w"). Unlike the previous zf.write() path, ZIP64 is not selected up front, so a context file larger than 4 GiB can fail when the zip member is closed.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8aaa496. Configure here.

zip_buffer.seek(0)
return zip_buffer.getvalue()

@staticmethod
def _context_hash(zip_content: bytes) -> str:
return f"sha256-{hashlib.sha256(zip_content).hexdigest()}"

def _create_sandbox_payload(self, name: str, memory: int = 4096) -> Sandbox:
"""
Create the sandbox payload for deployment.
Expand All @@ -829,7 +840,7 @@ def _create_sandbox_payload(self, name: str, memory: int = 4096) -> Sandbox:
return Sandbox(metadata=metadata, spec=spec)

def _create_sandbox_with_upload_sync(
self, sandbox: Sandbox
self, sandbox: Sandbox, context_hash: str | None = None
) -> tuple[Response[Sandbox], str | None]:
"""
Create or update a sandbox with the upload query parameter.
Expand All @@ -842,6 +853,9 @@ def _create_sandbox_with_upload_sync(
"""
name = sandbox.metadata.name if sandbox.metadata else ""
body = sandbox.to_dict()
params = {"upload": "true"}
if context_hash:
params["contextHash"] = context_hash

# Try PUT first (update), fall back to POST (create)
http_client = client.get_httpx_client()
Expand All @@ -851,7 +865,7 @@ def _create_sandbox_with_upload_sync(
method="put",
url=f"/sandboxes/{name}",
json=body,
params={"upload": "true"},
params=params,
headers={"Content-Type": "application/json"},
)

Expand All @@ -861,7 +875,7 @@ def _create_sandbox_with_upload_sync(
method="post",
url="/sandboxes",
json=body,
params={"upload": "true"},
params=params,
headers={"Content-Type": "application/json"},
)

Expand All @@ -887,7 +901,7 @@ def _create_sandbox_with_upload_sync(
return result, upload_url

async def _create_sandbox_with_upload(
self, sandbox: Sandbox
self, sandbox: Sandbox, context_hash: str | None = None
) -> tuple[Response[Sandbox], str | None]:
"""
Create or update a sandbox with the upload query parameter (async).
Expand All @@ -900,6 +914,9 @@ async def _create_sandbox_with_upload(
"""
name = sandbox.metadata.name if sandbox.metadata else ""
body = sandbox.to_dict()
params = {"upload": "true"}
if context_hash:
params["contextHash"] = context_hash

# Try PUT first (update), fall back to POST (create)
http_client = client.get_async_httpx_client()
Expand All @@ -909,7 +926,7 @@ async def _create_sandbox_with_upload(
method="put",
url=f"/sandboxes/{name}",
json=body,
params={"upload": "true"},
params=params,
headers={"Content-Type": "application/json"},
)

Expand All @@ -919,7 +936,7 @@ async def _create_sandbox_with_upload(
method="post",
url="/sandboxes",
json=body,
params={"upload": "true"},
params=params,
headers={"Content-Type": "application/json"},
)

Expand Down Expand Up @@ -1175,12 +1192,15 @@ def build_sync(
try:
# Create zip
zip_content = self._create_zip(build_dir)
context_hash = self._context_hash(zip_content)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Context hash still follows file mtimes

Medium Severity

contextHash is the digest of the zip from write_temp(), which still embeds manifest.json with Image.hash. That hash is built from local-file mtimes, so identical contents with different mtimes still produce different archives and cache keys on the real sandbox upload path. The new zip-header pinning does not make the uploaded payload byte-stable.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8aaa496. Configure here.


# Create sandbox payload
sandbox_payload = self._create_sandbox_payload(name, memory)

# Create/update sandbox and get upload URL
response, upload_url = self._create_sandbox_with_upload_sync(sandbox_payload)
response, upload_url = self._create_sandbox_with_upload_sync(
sandbox_payload, context_hash
)

if response.status_code.value >= 400:
raise RuntimeError(
Expand Down Expand Up @@ -1258,12 +1278,15 @@ async def build(
try:
# Create zip (sync, as it's file I/O)
zip_content = self._create_zip(build_dir)
context_hash = self._context_hash(zip_content)

# Create sandbox payload
sandbox_payload = self._create_sandbox_payload(name, memory)

# Create/update sandbox and get upload URL
response, upload_url = await self._create_sandbox_with_upload(sandbox_payload)
response, upload_url = await self._create_sandbox_with_upload(
sandbox_payload, context_hash
)

if response.status_code.value >= 400:
raise RuntimeError(
Expand Down
53 changes: 53 additions & 0 deletions tests/core/test_image.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
"""Tests for Image builder functionality."""

import importlib
import json
import os
import shutil
import tempfile
from pathlib import Path
from types import SimpleNamespace

import pytest

from blaxel.core.image import ImageBuildContext, ImageInstance, LocalFile

image_module = importlib.import_module("blaxel.core.image.image")


@pytest.fixture
def temp_dir():
Expand Down Expand Up @@ -1256,3 +1260,52 @@ def test_sandbox_api_added_at_end_of_dockerfile(self):
assert entrypoint_idx > run_idx
# Entrypoint should be last
assert entrypoint_idx > copy_idx


def test_build_archive_and_context_hash_are_content_stable(temp_dir):
first = temp_dir / "first"
second = temp_dir / "second"
first.mkdir()
second.mkdir()
for directory in (first, second):
(directory / "b.txt").write_text("second")
(directory / "a.txt").write_text("first")

os.utime(first / "a.txt", (1_700_000_000, 1_700_000_000))
os.utime(second / "a.txt", (1_800_000_000, 1_800_000_000))

image = ImageInstance.from_registry("python:3.11")
first_zip = image._create_zip(first)
second_zip = image._create_zip(second)

assert first_zip == second_zip
assert image._context_hash(first_zip) == image._context_hash(second_zip)
assert image._context_hash(first_zip).startswith("sha256-")
assert len(image._context_hash(first_zip)) == 71


def test_upload_request_carries_context_hash(monkeypatch):
calls = []

class FakeClient:
def request(self, **kwargs):
calls.append(kwargs)
return SimpleNamespace(
status_code=200,
headers={"x-blaxel-upload-url": "https://upload.example"},
content=b"{}",
json=lambda: {},
)

monkeypatch.setattr(type(image_module.client), "get_httpx_client", lambda _self: FakeClient())
image = ImageInstance.from_registry("python:3.11")
sandbox = image._create_sandbox_payload("cached")
context_hash = f"sha256-{'a' * 64}"

_, upload_url = image._create_sandbox_with_upload_sync(sandbox, context_hash)

assert upload_url == "https://upload.example"
assert calls[0]["params"] == {
"upload": "true",
"contextHash": context_hash,
}
Loading