mirror of
https://github.com/rookiestar28/ComfyUI-OpenClaw.git
synced 2026-08-14 00:48:07 +00:00
160 lines
6.3 KiB
Python
160 lines
6.3 KiB
Python
import json
|
|
import os
|
|
import shutil
|
|
import tempfile
|
|
import zipfile
|
|
from typing import Optional
|
|
|
|
from .pack_manifest import (
|
|
MAX_FILE_SIZE_MB,
|
|
MAX_MANIFEST_FILES,
|
|
PackError,
|
|
validate_manifest_integrity,
|
|
validate_pack_metadata,
|
|
)
|
|
from .pack_types import PackMetadata
|
|
|
|
MAX_COMPRESSION_RATIO = 100
|
|
|
|
|
|
class PackArchive:
|
|
@staticmethod
|
|
def _is_safe_path(base_dir: str, rel_path: str) -> bool:
|
|
# Prevent path traversal
|
|
abs_base = os.path.abspath(base_dir)
|
|
abs_target = os.path.abspath(os.path.join(base_dir, rel_path))
|
|
return abs_target.startswith(abs_base)
|
|
|
|
@staticmethod
|
|
def extract_pack(zip_path: str, target_dir: str) -> PackMetadata:
|
|
"""
|
|
Safely extracts a pack archive to target_dir.
|
|
1. Checks limits (count/size).
|
|
2. checks for symlinks/unsafe paths.
|
|
3. Extracts.
|
|
4. Validates manifest.json (integrity).
|
|
5. Validates pack.json (schema).
|
|
Returns the parsed PackMetadata.
|
|
"""
|
|
if not os.path.exists(zip_path):
|
|
raise PackError("Archive not found")
|
|
|
|
with zipfile.ZipFile(zip_path, "r") as zf:
|
|
# 1. Pre-flight Check
|
|
infos = zf.infolist()
|
|
if len(infos) > MAX_MANIFEST_FILES:
|
|
raise PackError(
|
|
f"Too many files in archive ({len(infos)} > {MAX_MANIFEST_FILES})"
|
|
)
|
|
|
|
total_size = sum(i.file_size for i in infos)
|
|
if total_size > MAX_FILE_SIZE_MB * 1024 * 1024:
|
|
raise PackError(f"Archive content too large ({total_size} bytes)")
|
|
|
|
# Check compression ratio
|
|
compressed_size = sum(i.compress_size for i in infos)
|
|
if compressed_size > 0:
|
|
ratio = total_size / compressed_size
|
|
if ratio > MAX_COMPRESSION_RATIO:
|
|
raise PackError(f"Compression ratio too high ({ratio:.1f} > {MAX_COMPRESSION_RATIO})")
|
|
|
|
# 2. Safety Check
|
|
for info in infos:
|
|
if (
|
|
info.filename.startswith("/")
|
|
or ".." in info.filename
|
|
or "\\" in info.filename
|
|
or any(c < ' ' for c in info.filename) # Control chars
|
|
):
|
|
raise PackError(f"Unsafe filename: {info.filename}")
|
|
|
|
# Check for symlinks (S_IFLNK - 0xA000)
|
|
# ZipInfo.external_attr: upper 16 bits are Unix permissions
|
|
attr = info.external_attr >> 16
|
|
if (attr & 0xF000) == 0xA000:
|
|
raise PackError(f"Symlinks not allowed: {info.filename}")
|
|
|
|
# 3. Extract to temp dir first
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
zf.extractall(tmp_dir)
|
|
|
|
# 4/5. Validate Manifest & Metadata *before* moving to final
|
|
manifest_path = os.path.join(tmp_dir, "manifest.json")
|
|
pack_json_path = os.path.join(tmp_dir, "pack.json")
|
|
|
|
if not os.path.exists(manifest_path):
|
|
raise PackError("Missing manifest.json")
|
|
if not os.path.exists(pack_json_path):
|
|
raise PackError("Missing pack.json")
|
|
|
|
try:
|
|
with open(manifest_path, "r", encoding="utf-8") as f:
|
|
manifest = json.load(f)
|
|
with open(pack_json_path, "r", encoding="utf-8") as f:
|
|
pack_meta = json.load(f)
|
|
except json.JSONDecodeError:
|
|
raise PackError("Invalid JSON in manifest or pack definition")
|
|
|
|
# Validate Metadata Schema
|
|
validate_pack_metadata(pack_meta)
|
|
|
|
# Validate Integrity
|
|
integrity_errors = validate_manifest_integrity(tmp_dir, manifest)
|
|
if integrity_errors:
|
|
raise PackError(
|
|
f"Integrity check failed: {'; '.join(integrity_errors)}"
|
|
)
|
|
|
|
# Safe to move to target
|
|
# Ensure target exists and is empty/ready
|
|
if os.path.exists(target_dir):
|
|
shutil.rmtree(
|
|
target_dir
|
|
) # Overwrite logic managed by registry, but here we clean up
|
|
os.makedirs(target_dir, exist_ok=True)
|
|
|
|
# Copy content
|
|
shutil.copytree(tmp_dir, target_dir, dirs_exist_ok=True)
|
|
|
|
return pack_meta # type: ignore
|
|
|
|
@staticmethod
|
|
def create_pack_archive(source_dir: str, output_zip: str):
|
|
"""
|
|
Creates a zip archive from source_dir.
|
|
Does NOT re-generate manifest (assumes it exists and is correct).
|
|
"""
|
|
if not os.path.exists(source_dir):
|
|
raise PackError("Source directory does not exist")
|
|
|
|
with zipfile.ZipFile(output_zip, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
files_to_add = []
|
|
for root, _, files in os.walk(source_dir):
|
|
for file in files:
|
|
full_path = os.path.join(root, file)
|
|
rel_path = os.path.relpath(full_path, source_dir)
|
|
files_to_add.append((full_path, rel_path))
|
|
|
|
# Deterministic order
|
|
files_to_add.sort(key=lambda x: x[1])
|
|
|
|
for full_path, rel_path in files_to_add:
|
|
# Deterministic metadata (timestamp)
|
|
# ZipInfo requires a tuple (year, month, day, hour, min, sec)
|
|
# We use a fixed epoch for reproducibility, or file mtime?
|
|
# Plan says "regenerate manifest deterministically".
|
|
# If we use file mtime, it changes if we touch files.
|
|
# Using fixed timestamp ensures identical binary hash for identical content.
|
|
# But standard zip tools use mtime.
|
|
# Let's use 1980-01-01 00:00:00 (DOS epoch)
|
|
zinfo = zipfile.ZipInfo(rel_path)
|
|
zinfo.date_time = (1980, 1, 1, 0, 0, 0)
|
|
zinfo.compress_type = zipfile.ZIP_DEFLATED
|
|
|
|
# Set regular file permissions (0o644)
|
|
# External attr: (0o100644 << 16) = 0x81A40000
|
|
zinfo.external_attr = 0x81A40000
|
|
|
|
with open(full_path, "rb") as f:
|
|
zf.writestr(zinfo, f.read())
|