From a39041d4e98fe812ecb62ce85cb0ecc0c3409313 Mon Sep 17 00:00:00 2001 From: IMvision12 Date: Thu, 17 Sep 2026 15:53:21 -0700 Subject: [PATCH 1/2] fix --- docs/loading_weights.md | 14 +++++--- zeromodels/base/base_mixin.py | 6 ++-- zeromodels/conversion/converted_cache.py | 45 ++++++++++++++++++++---- 3 files changed, 53 insertions(+), 12 deletions(-) diff --git a/docs/loading_weights.md b/docs/loading_weights.md index 435a144a..246d4e48 100644 --- a/docs/loading_weights.md +++ b/docs/loading_weights.md @@ -194,10 +194,16 @@ model = Qwen3TextGenerate.from_weights( It works for both conversion paths. Way 1 has nothing to cache beyond the downloaded file. The cache key includes the source identity, the backend and dtype, and the quantization -recipe, so it cannot hand back a stale or differently configured model. For an `hf:` id the -source identity is the resolved **commit SHA**, so a repo that moves invalidates the entry. -A miss falls back to the normal path silently. On an ephemeral machine (Colab, CI) point -`ZEROMODELS_HOME` at persistent storage or the cache buys you nothing. +recipe to separate differently configured models. For an `hf:` id the source identity is +the resolved **commit SHA**, so a repo that moves invalidates the entry. A metadata +fingerprint detects stale or accidentally damaged entries; it is stored in the cache and is +not a signature or tamper-resistance mechanism. + +The cache is trusted local input: its Keras metadata names classes that are resolved during +deserialization. Do not place `ZEROMODELS_HOME` somewhere writable by untrusted users. New +cache directories use owner-only permissions where the filesystem supports them. A miss +falls back to the normal path silently. On an ephemeral machine (Colab, CI), point +`ZEROMODELS_HOME` at trusted persistent storage or the cache buys you nothing. ## Loading big checkpoints diff --git a/zeromodels/base/base_mixin.py b/zeromodels/base/base_mixin.py index 093fdfc3..7b0b7e43 100644 --- a/zeromodels/base/base_mixin.py +++ b/zeromodels/base/base_mixin.py @@ -304,8 +304,10 @@ def from_weights( Applies to models loaded as float (a built functional graph can't be re-quantized from a serialized skeleton, so quantized loads are not cached); a cache miss / failure silently falls back to the - source path. Best on a persistent disk: set ``ZEROMODELS_HOME`` - on ephemeral boxes. + source path. Cache metadata is trusted local input and may name + classes for Keras to deserialize; use only a location that is not + writable by untrusted users. Best on a persistent disk: set + ``ZEROMODELS_HOME`` on ephemeral boxes. **kwargs: Forwarded to the model constructor (or to ``from_hf`` when applicable). diff --git a/zeromodels/conversion/converted_cache.py b/zeromodels/conversion/converted_cache.py index a69ac52d..801c45b1 100644 --- a/zeromodels/conversion/converted_cache.py +++ b/zeromodels/conversion/converted_cache.py @@ -19,13 +19,27 @@ QUANTIZATION_FORMAT_VERSION = 1 +def _ensure_private_directory(directory): + """Create a cache directory with owner-only permissions where supported.""" + os.makedirs(directory, mode=0o700, exist_ok=True) + if os.name == "posix": + try: + os.chmod(directory, 0o700) + except OSError: + # Some mounted filesystems (for example cloud-drive FUSE mounts) do + # not implement chmod. The documented trusted-cache requirement still + # applies there. + pass + + def cache_root(): """Root directory for cached converted models. ``$ZEROMODELS_HOME/converted`` (else ``~/.cache/zeromodels/converted``), self-managed like the HF cache. On an ephemeral box (Colab), point ``ZEROMODELS_HOME`` at a persistent mount (Drive) to keep the benefit - across sessions. + across sessions. Cache metadata is trusted local input, not authenticated + content, so the location must not be writable by untrusted users. """ home = os.environ.get( "ZEROMODELS_HOME", @@ -142,7 +156,16 @@ def save_converted(model, directory, quantization, load_dtype=None): """ from safetensors.numpy import save_file - os.makedirs(directory, exist_ok=True) + root = os.path.abspath(cache_root()) + target = os.path.abspath(directory) + try: + if os.path.commonpath((root, target)) == root: + _ensure_private_directory(root) + except ValueError: + # Different drives on Windows cannot share a common path. ``directory`` + # is then an explicit external target rather than the configured cache. + pass + _ensure_private_directory(directory) weights = list(model.weights) keys = [f"{i:06d}" for i in range(len(weights))] @@ -176,7 +199,7 @@ def save_converted(model, directory, quantization, load_dtype=None): "backend": keras.backend.backend(), "load_dtype": load_dtype, "config": config, - "architecture_hash": _json_hash(config), + "architecture_fingerprint": _json_hash(config), "quantization": quant_id(quantization), "keying": "index", "keys": keys, @@ -193,7 +216,10 @@ def load_converted(directory, quantization, load_dtype): Deserializes the config to the model, then streams each cached tensor onto its weight by position. Raises on any count / shape / keying mismatch so the - caller can fall back to the source. + caller can fall back to the source. The architecture fingerprint detects + stale or accidental corruption only. Because Keras deserialization resolves + the classes named in ``meta.json``, cache directories must be trusted and not + writable by untrusted users. """ from zeromodels.base.base_mixin import build_dtype_scope @@ -214,8 +240,15 @@ def load_converted(directory, quantization, load_dtype): raise ValueError( f"Converted cache {key}={meta.get(key)!r} does not match {value!r}." ) - if meta.get("architecture_hash") != _json_hash(meta.get("config")): - raise ValueError("Converted cache architecture config is corrupt.") + fingerprint = meta.get("architecture_fingerprint") + if fingerprint is None: + # Compatibility with caches written before the field was accurately + # named. This legacy value has the same staleness-only semantics. + fingerprint = meta.get("architecture_hash") + if fingerprint != _json_hash(meta.get("config")): + raise ValueError( + "Converted cache architecture fingerprint is stale or damaged." + ) with build_dtype_scope(load_dtype): model = keras.saving.deserialize_keras_object(meta["config"]) From 02c50ff180349adb8605de66fa550cad4b85b1e7 Mon Sep 17 00:00:00 2001 From: IMvision12 Date: Thu, 17 Sep 2026 15:55:24 -0700 Subject: [PATCH 2/2] inline --- zeromodels/conversion/converted_cache.py | 27 +++++++++++------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/zeromodels/conversion/converted_cache.py b/zeromodels/conversion/converted_cache.py index 801c45b1..88800a60 100644 --- a/zeromodels/conversion/converted_cache.py +++ b/zeromodels/conversion/converted_cache.py @@ -19,19 +19,6 @@ QUANTIZATION_FORMAT_VERSION = 1 -def _ensure_private_directory(directory): - """Create a cache directory with owner-only permissions where supported.""" - os.makedirs(directory, mode=0o700, exist_ok=True) - if os.name == "posix": - try: - os.chmod(directory, 0o700) - except OSError: - # Some mounted filesystems (for example cloud-drive FUSE mounts) do - # not implement chmod. The documented trusted-cache requirement still - # applies there. - pass - - def cache_root(): """Root directory for cached converted models. @@ -158,14 +145,24 @@ def save_converted(model, directory, quantization, load_dtype=None): root = os.path.abspath(cache_root()) target = os.path.abspath(directory) + directories = [target] try: if os.path.commonpath((root, target)) == root: - _ensure_private_directory(root) + directories.insert(0, root) except ValueError: # Different drives on Windows cannot share a common path. ``directory`` # is then an explicit external target rather than the configured cache. pass - _ensure_private_directory(directory) + for cache_directory in directories: + os.makedirs(cache_directory, mode=0o700, exist_ok=True) + if os.name == "posix": + try: + os.chmod(cache_directory, 0o700) + except OSError: + # Some mounted filesystems (for example cloud-drive FUSE mounts) + # do not implement chmod. The documented trusted-cache requirement + # still applies there. + pass weights = list(model.weights) keys = [f"{i:06d}" for i in range(len(weights))]