Skip to content

Commit b96fbbc

Browse files
committed
feat(cli): gate pushes on remote validation
1 parent cd18be6 commit b96fbbc

4 files changed

Lines changed: 491 additions & 42 deletions

File tree

src/openenv/cli/commands/push.py

Lines changed: 173 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,50 @@
44

55
from __future__ import annotations
66

7+
import json
78
import shutil
89
import sys
910
import tempfile
1011
from fnmatch import fnmatch
1112
from pathlib import Path
12-
from typing import Annotated
13+
from typing import Annotated, Any
1314

1415
import typer
1516
import yaml
1617
from huggingface_hub import HfApi, login, whoami
17-
18-
from .._cli_utils import _extract_hf_username, console, validate_env_structure
18+
from openenv.validation import (
19+
format_shared_validation_report,
20+
RemoteValidationError,
21+
run_local_validation,
22+
run_remote_validation,
23+
validation_source_digest,
24+
validation_source_path_allowed,
25+
ValidationProfile,
26+
ValidationReport,
27+
ValidationSeverity,
28+
ValidationStatus,
29+
)
30+
31+
from .._cli_utils import _extract_hf_username, console
1932

2033
app = typer.Typer(help="Push an OpenEnv environment to Hugging Face Spaces")
2134

2235

23-
DEFAULT_PUSH_IGNORE_PATTERNS = [".*", "__pycache__", "*.pyc"]
36+
DEFAULT_PUSH_IGNORE_PATTERNS = [
37+
".env",
38+
".env.*",
39+
".git/",
40+
".hg/",
41+
".mypy_cache/",
42+
".pytest_cache/",
43+
".ruff_cache/",
44+
".svn/",
45+
".tox/",
46+
".venv/",
47+
"__pycache__/",
48+
"*.pyc",
49+
]
50+
_VERSIONED_VALIDATION_REPORT = Path(".openenv/validation-report.json")
2451

2552

2653
def _format_kv_entry_for_error(entry: str, *, flag: str) -> str:
@@ -127,6 +154,20 @@ def _should_exclude_path(relative_path: Path, ignore_patterns: list[str]) -> boo
127154
)
128155

129156

157+
def _excludes_versioned_report(ignore_patterns: list[str]) -> bool:
158+
"""Return whether an upload pattern can omit the required author report."""
159+
required_paths = (
160+
_VERSIONED_VALIDATION_REPORT,
161+
*_VERSIONED_VALIDATION_REPORT.parents,
162+
)
163+
return any(
164+
_path_matches_pattern(required_path, pattern)
165+
for pattern in ignore_patterns
166+
for required_path in required_paths
167+
if required_path != Path(".")
168+
)
169+
170+
130171
def _read_ignore_file(ignore_path: Path) -> tuple[list[str], int]:
131172
"""Read ignore patterns from a file and return (patterns, ignored_negations)."""
132173
patterns: list[str] = []
@@ -198,7 +239,9 @@ def _ignore(path: str, names: list[str]) -> set[str]:
198239
# candidate is not under env_dir (e.g. symlink or
199240
# copytree root differs from env_dir); skip filtering.
200241
continue
201-
if _should_exclude_path(relative_path, ignore_patterns):
242+
if _should_exclude_path(
243+
relative_path, ignore_patterns
244+
) or not validation_source_path_allowed(relative_path):
202245
ignored.add(name)
203246

204247
return ignored
@@ -207,26 +250,16 @@ def _ignore(path: str, names: list[str]) -> set[str]:
207250

208251

209252
def _validate_openenv_directory(directory: Path) -> tuple[str, dict]:
210-
"""
211-
Validate that the directory is an OpenEnv environment.
253+
"""Load the minimal manifest identity needed before shared validation.
212254
213255
Returns:
214256
`tuple` of `(env_name, manifest_data)`.
215257
"""
216-
# Use the comprehensive validation function
217-
try:
218-
warnings = validate_env_structure(directory)
219-
for warning in warnings:
220-
console.print(f"[bold yellow]⚠[/bold yellow] {warning}")
221-
except FileNotFoundError as e:
222-
raise typer.BadParameter(f"Invalid OpenEnv environment structure: {e}") from e
223-
224-
# Load and validate manifest
225258
manifest_path = directory / "openenv.yaml"
226259
try:
227-
with open(manifest_path, "r") as f:
260+
with manifest_path.open(encoding="utf-8") as f:
228261
manifest = yaml.safe_load(f)
229-
except Exception as e:
262+
except (OSError, yaml.YAMLError) as e:
230263
raise typer.BadParameter(f"Failed to parse openenv.yaml: {e}") from e
231264

232265
if not isinstance(manifest, dict):
@@ -239,6 +272,96 @@ def _validate_openenv_directory(directory: Path) -> tuple[str, dict]:
239272
return env_name, manifest
240273

241274

275+
def _run_publish_validation(
276+
directory: Path, *, remote: bool = True
277+
) -> ValidationReport:
278+
"""Run the shared strict publish profile locally or in an HF Sandbox."""
279+
if remote:
280+
return run_remote_validation(directory, profile=ValidationProfile.PUBLISH)
281+
return run_local_validation(directory, profile=ValidationProfile.PUBLISH)
282+
283+
284+
def _render_publish_gate(report: ValidationReport) -> None:
285+
"""Render author guidance and stop when blocking criteria are not satisfied."""
286+
console.print(format_shared_validation_report(report), markup=False)
287+
if report.passed:
288+
console.print("[bold green]✓[/bold green] Publish validation passed")
289+
return
290+
291+
blocking_skip = any(
292+
result.severity is ValidationSeverity.BLOCKING
293+
and result.status is ValidationStatus.SKIP
294+
for result in report.results
295+
)
296+
outcome = "incomplete" if blocking_skip else "failed"
297+
console.print(
298+
f"[bold red]✗[/bold red] Publish validation {outcome}; upload was not attempted"
299+
)
300+
raise typer.Exit(1)
301+
302+
303+
def _portable_report_value(value: Any, source_root: Path) -> Any:
304+
"""Remove machine- and Sandbox-specific source roots from a report payload."""
305+
if isinstance(value, dict):
306+
return {
307+
key: _portable_report_value(item, source_root)
308+
for key, item in value.items()
309+
}
310+
if isinstance(value, list):
311+
return [_portable_report_value(item, source_root) for item in value]
312+
if isinstance(value, str):
313+
local_root = str(source_root.resolve())
314+
portable = value.replace(f"{local_root}/", "./")
315+
portable = "." if portable == local_root else portable
316+
portable = portable.replace("/workspace/source/", "./")
317+
return "." if portable == "/workspace/source" else portable
318+
return value
319+
320+
321+
def _write_versioned_validation_report(
322+
staging_dir: Path, report: ValidationReport
323+
) -> None:
324+
"""Write the portable, explicitly non-certified author report into the upload."""
325+
payload = _portable_report_value(report.to_dict(), staging_dir)
326+
payload["target"] = "."
327+
payload["certified"] = False
328+
payload["certification_eligible"] = False
329+
report_path = staging_dir / _VERSIONED_VALIDATION_REPORT
330+
report_path.parent.mkdir(parents=True, exist_ok=True)
331+
report_path.write_text(
332+
f"{json.dumps(payload, indent=2, sort_keys=True)}\n",
333+
encoding="utf-8",
334+
)
335+
336+
337+
def _verify_snapshot_binding(staging_dir: Path, report: ValidationReport) -> None:
338+
"""Require the report digest to match the exact tree about to be uploaded."""
339+
try:
340+
actual_digest = validation_source_digest(staging_dir)
341+
except RemoteValidationError as exc:
342+
raise typer.BadParameter(str(exc)) from exc
343+
if report.source_digest is None:
344+
console.print(
345+
"[bold red]✗[/bold red] Publish validation report is missing its source digest"
346+
)
347+
raise typer.Exit(1)
348+
if report.source_digest != actual_digest:
349+
console.print(
350+
"[bold red]✗[/bold red] The staged environment changed after validation; upload was not attempted"
351+
)
352+
raise typer.Exit(1)
353+
354+
355+
def _validate_copy_source(env_dir: Path) -> None:
356+
"""Reject links so staging cannot copy content outside the source tree."""
357+
for candidate in env_dir.rglob("*"):
358+
if candidate.is_symlink():
359+
relative = candidate.relative_to(env_dir)
360+
raise typer.BadParameter(
361+
f"Source contains a symbolic link, which push does not follow: {relative}"
362+
)
363+
364+
242365
def _get_hf_username() -> str:
243366
"""Return the authenticated Hugging Face username from whoami()."""
244367
username = _extract_hf_username(whoami())
@@ -289,14 +412,19 @@ def _prepare_staging_directory(
289412
- Modifying Dockerfile to optionally enable web interface and update base image
290413
- Ensuring README has proper HF frontmatter (if interface enabled)
291414
"""
415+
_validate_copy_source(env_dir)
416+
292417
# Create staging directory structure
293418
staging_dir.mkdir(parents=True, exist_ok=True)
294419

295-
# Copy all files from env directory
420+
# Use the same file policy as the validation archive so every uploaded
421+
# source file is covered by the report's source digest.
296422
copy_ignore = _copytree_ignore_factory(env_dir, ignore_patterns)
297423
for item in env_dir.iterdir():
298424
relative_path = item.relative_to(env_dir)
299-
if _should_exclude_path(relative_path, ignore_patterns):
425+
if _should_exclude_path(
426+
relative_path, ignore_patterns
427+
) or not validation_source_path_allowed(relative_path):
300428
continue
301429

302430
dest = staging_dir / item.name
@@ -734,6 +862,13 @@ def push(
734862

735863
# Handle custom registry push
736864
if registry:
865+
try:
866+
validation_report = _run_publish_validation(env_dir, remote=False)
867+
except (RemoteValidationError, ValueError) as exc:
868+
console.print(f"[bold red]✗[/bold red] Publish validation failed: {exc}")
869+
raise typer.Exit(1) from exc
870+
_render_publish_gate(validation_report)
871+
737872
console.print("[bold cyan]Preparing to push to custom registry...[/bold cyan]")
738873
if enable_interface:
739874
console.print("[bold cyan]Web interface will be enabled[/bold cyan]")
@@ -778,6 +913,11 @@ def push(
778913
return
779914

780915
ignore_patterns = _load_ignore_patterns(env_dir, exclude)
916+
if _excludes_versioned_report(ignore_patterns):
917+
raise typer.BadParameter(
918+
"Push excludes must preserve the versioned author report at "
919+
f"{_VERSIONED_VALIDATION_REPORT.as_posix()}"
920+
)
781921

782922
# Ensure authentication for HuggingFace
783923
username = _ensure_hf_authenticated()
@@ -814,6 +954,19 @@ def push(
814954
enable_interface=enable_interface,
815955
)
816956

957+
console.print(
958+
"[bold cyan]Running strict publish validation in a dedicated Hugging Face Sandbox...[/bold cyan]"
959+
)
960+
try:
961+
validation_report = _run_publish_validation(staging_dir)
962+
except (RemoteValidationError, ValueError) as exc:
963+
console.print(f"[bold red]✗[/bold red] Remote validation failed: {exc}")
964+
raise typer.Exit(1) from exc
965+
_render_publish_gate(validation_report)
966+
_verify_snapshot_binding(staging_dir, validation_report)
967+
_write_versioned_validation_report(staging_dir, validation_report)
968+
_verify_snapshot_binding(staging_dir, validation_report)
969+
817970
if count > 1:
818971
base_repo_id = repo_id
819972
for i in range(1, count + 1):

src/openenv/validation/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
RemoteValidationError,
3232
run_remote_validation,
3333
validation_source_digest,
34+
validation_source_path_allowed,
3435
)
3536
from .security import ensure_official_hf_sandbox
3637

@@ -61,4 +62,5 @@
6162
"run_local_validation",
6263
"run_remote_validation",
6364
"validation_source_digest",
65+
"validation_source_path_allowed",
6466
]

src/openenv/validation/remote.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,11 @@ class RemoteValidationError(RuntimeError):
8686
"""Remote author validation could not produce a trustworthy report payload."""
8787

8888

89-
def _archive_path_allowed(relative: Path) -> bool:
89+
def validation_source_path_allowed(relative: str | Path) -> bool:
90+
"""Return whether a relative file path belongs in a validation snapshot."""
91+
relative = Path(relative)
92+
if relative.is_absolute() or ".." in relative.parts or not relative.parts:
93+
return False
9094
if any(part in _EXCLUDED_PARTS or part.startswith(".") for part in relative.parts):
9195
return False
9296
name = relative.name
@@ -119,7 +123,7 @@ def validation_source_digest(root: str | Path) -> str:
119123
digest = hashlib.sha256()
120124
for candidate in sorted(root_path.rglob("*")):
121125
relative = candidate.relative_to(root_path)
122-
if not _archive_path_allowed(relative) or not candidate.is_file():
126+
if not validation_source_path_allowed(relative) or not candidate.is_file():
123127
continue
124128
encoded_path = relative.as_posix().encode("utf-8")
125129
digest.update(len(encoded_path).to_bytes(8, "big"))
@@ -151,7 +155,7 @@ def normalize(info: tarfile.TarInfo) -> tarfile.TarInfo:
151155
for candidate in sorted(root.rglob("*")):
152156
relative = candidate.relative_to(root)
153157
if (
154-
not _archive_path_allowed(relative)
158+
not validation_source_path_allowed(relative)
155159
or candidate.is_symlink()
156160
or not (candidate.is_file() or candidate.is_dir())
157161
):

0 commit comments

Comments
 (0)