diff --git a/assets/backends/redux.json b/assets/backends/redux.json index 1cfdc18..076ad6b 100644 --- a/assets/backends/redux.json +++ b/assets/backends/redux.json @@ -16,7 +16,7 @@ {"path": "config.json", "size": 12988, "sha256": "503c653b2e3bb788adbcb04f5abdee532d958686564081baeed133ff10143f6e"}, {"path": "ternary.json", "size": 57970, "sha256": "1221c6d3ce901ffe09c089da758a8db8b76189f80cff41c5afc244fc61e2051d"}, {"path": "tokenizer.json", "size": 1159960, "sha256": "bd321b096832a3f270bd3b2a88823957920f1a5c5ada71114a26ea729d0cbe91"}, - {"path": "README.md", "size": 8832, "sha256": "930055e6bac7d75114e719850ffaffc8d327e73b4295518c38c81399acd63e46"} + {"path": "README.md", "size": 8832, "sha256": "930055e6bac7d75114e719850ffaffc8d327e73b4295518c38c81399acd63e46", "compatible": [{"size": 8533, "sha256": "a8b327f983a8b8ff262ff7bead3a791fbed9350632002af8db85ab5cd84cdaa5"}]} ] }, "attribution": "Moondream/M87 Labs, Parakeet Redux, derived from NVIDIA Parakeet TDT 0.6B v3; original model card README.md retained with the files.", diff --git a/docs/design.md b/docs/design.md index 494b2f2..6a1324a 100644 --- a/docs/design.md +++ b/docs/design.md @@ -232,6 +232,11 @@ back to desktop/cloud inference. The benchmark ran on revision `fad622f25f303105c20d70e201bcc477c88b620c`, which no longer resolves after the upstream history was rewritten; the weights, config, ternary map and tokenizer at `2bf1286` are byte-identical to it (same SHA-256), only the model card README differs. +The manifest explicitly accepts the previous card's pinned size and SHA-256 for +existing installations, so updating FrameYap requires no model download. New +downloads must verify against the current revision's card; unknown or modified +cards are still rejected. The optional per-file `compatible` list contains exact +`size`/`sha256` pairs for previously installed artifacts, never download fallbacks. The earlier benchmark used this local API (its source belongs to the originating repository, not this project): diff --git a/python/frameyap/model_files.py b/python/frameyap/model_files.py index 62cf4ad..e5a3ecc 100644 --- a/python/frameyap/model_files.py +++ b/python/frameyap/model_files.py @@ -61,6 +61,7 @@ class ModelFile: path: str size: int sha256: str + compatible: tuple[tuple[int, str], ...] = () @dataclass(frozen=True) @@ -127,13 +128,28 @@ def _parse(value): parsed = [] names = set() for item in files: - _fields(item, ("path", "size", "sha256"), "model file") + if not isinstance(item, dict): + raise ManifestError("invalid model file fields") + _fields(item, ("path", "size", "sha256", "compatible") if "compatible" in item + else ("path", "size", "sha256"), "model file") name = _relative(item["path"], "model file path") size = item["size"] if name in names or type(size) is not int or not 0 < size <= _MAX_FILE or not isinstance(item["sha256"], str) or not _HASH.fullmatch(item["sha256"]): raise ManifestError("duplicate or invalid pinned model file") names.add(name) - parsed.append(ModelFile(name, size, item["sha256"])) + compatible = item.get("compatible", []) + if not isinstance(compatible, list) or len(compatible) > 8: + raise ManifestError("invalid compatible model files") + pins = [] + for previous in compatible: + _fields(previous, ("size", "sha256"), "compatible model file") + old_size, old_hash = previous["size"], previous["sha256"] + if (type(old_size) is not int or not 0 < old_size <= _MAX_FILE or + not isinstance(old_hash, str) or not _HASH.fullmatch(old_hash) or + (old_size, old_hash) in [(size, item["sha256"]), *pins]): + raise ManifestError("duplicate or invalid compatible model file") + pins.append((old_size, old_hash)) + parsed.append(ModelFile(name, size, item["sha256"], tuple(pins))) license_info = value["license"] _fields(license_info, ("id", "text"), "license") requirements = value["requirements"] @@ -177,7 +193,11 @@ def load_backends(manifest_dir=DEFAULT_MANIFEST_DIR): def check_file(directory, item): - """Return (reason, filename); reason None means the pinned file verifies.""" + """Verify the current pin or an explicitly compatible existing artifact. + + Downloaders construct a temporary ModelFile with only the current pin so + newly fetched bytes must match the revision advertised to the user. + """ root = Path(directory) if not root.is_absolute(): return "model_dir_not_absolute", item.path @@ -201,13 +221,14 @@ def check_file(directory, item): before = os.fstat(file_fd) if not stat.S_ISREG(before.st_mode): return "unsafe_file", item.path - if before.st_size != item.size: + pins = ((item.size, item.sha256), *item.compatible) + if before.st_size not in {size for size, _ in pins}: return "size_mismatch", item.path digest = hashlib.sha256() while chunk := os.read(file_fd, 1024 * 1024): digest.update(chunk) after = os.fstat(file_fd) - if (before.st_size, before.st_mtime_ns, before.st_ctime_ns) != (after.st_size, after.st_mtime_ns, after.st_ctime_ns) or digest.hexdigest() != item.sha256: + if (before.st_size, before.st_mtime_ns, before.st_ctime_ns) != (after.st_size, after.st_mtime_ns, after.st_ctime_ns) or (before.st_size, digest.hexdigest()) not in pins: return "hash_mismatch", item.path return None, None finally: diff --git a/tests/test_backends.py b/tests/test_backends.py index 0f7fdcd..4b92460 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -21,6 +21,9 @@ class BackendTests(unittest.TestCase): "tokenizer.json": (1159960, "bd321b096832a3f270bd3b2a88823957920f1a5c5ada71114a26ea729d0cbe91"), "README.md": (8832, "930055e6bac7d75114e719850ffaffc8d327e73b4295518c38c81399acd63e46"), }) + self.assertEqual(redux.files[-1].compatible, + ((8533, "a8b327f983a8b8ff262ff7bead3a791fbed9350632002af8db85ab5cd84cdaa5"),)) + self.assertTrue(all(not file.compatible for file in redux.files[:-1])) self.assertEqual(redux.launcher["protocol"], "frameyap-worker-v1") self.assertEqual(redux.launcher["arguments"], ["--model", "{model_dir}", "--threads", "{threads}", "--clip-dir", "{clip_dir}"]) self.assertIn("CC-BY-4.0", redux.license_id) @@ -47,6 +50,10 @@ class BackendTests(unittest.TestCase): lambda f: f["model"]["files"][0].update(path="../secret"), lambda f: f["model"]["files"][0].update(size=True), lambda f: f["model"]["files"][0].update(sha256="A" * 64), + lambda f: f["model"]["files"][0].update(compatible="bad"), + lambda f: f["model"]["files"][0].update(compatible=[{"size": True, "sha256": "a" * 64}]), + lambda f: f["model"]["files"][0].update(compatible=[{"size": 3, "sha256": "a" * 64}]), + lambda f: f["model"]["files"][0].update(compatible=[{"size": 4, "sha256": "a" * 64, "path": "other"}]), lambda f: f["launcher"]["arguments"].append("{unknown}"), lambda f: f["launcher"].update(path="/bin/sh"), lambda f: f["launcher"].update(protocol="not-the-wire-protocol"), diff --git a/tests/test_installer.py b/tests/test_installer.py index 458bb85..8817cb1 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -1206,6 +1206,57 @@ with patch.object(module, "check_host"), patch.object(module.urllib.request, "ur self.assertIn("mismatched", json.loads(output.getvalue())["message"]) fetch.assert_not_called() + def test_model_card_upgrade_retains_verified_legacy_without_network(self): + shutil.copyfile(REPO / "python/frameyap/model_files.py", + self.stage / "python/frameyap/model_files.py") + manifest = json.loads((REPO / "assets/backends/redux.json").read_text()) + old, new = b"previous attributed card", b"updated attributed model card" + def pin(content): + return {"size": len(content), "sha256": hashlib.sha256(content).hexdigest()} + manifest["model"]["files"] = [{"path": "README.md", **pin(old)}] + manifests = self.stage / "assets/backends" + manifests.mkdir() + card_manifest = manifests / "redux.json" + card_manifest.write_text(json.dumps(manifest)) + archive, digest = self.package("0.1.202609241530") + self.install("0.1.202609241530", archive, digest, "--without-model") + destination = self.base / "models" + destination.mkdir() + card = destination / "README.md" + card.write_bytes(old) + manifest["model"]["files"] = [{"path": "README.md", **pin(new), + "compatible": [pin(old)]}] + card_manifest.write_text(json.dumps(manifest)) + archive, digest = self.package("0.1.202609241531") + with patch.object(installer.urllib.request, "urlopen") as fetch: + self.install("0.1.202609241531", archive, digest, "--without-model") + module, backend, _ = installer.installed_backend(self.data / "frameyap", "redux") + self.assertEqual(module.check_model(backend, destination)["state"], "installed_verified") + argv = ["--install-model", "--model-dir", str(destination), "--yes", "--json"] + with contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(installer.cli(argv), 0) + fetch.assert_not_called() + self.assertEqual(card.read_bytes(), old) + card.write_bytes(b"x" * len(old)) + with contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(installer.cli(argv), 1) + fetch.assert_not_called() + card.unlink() + class Response(io.BytesIO): + def geturl(self): + return "https://huggingface.co/fixture" + # Fresh downloads require the current card even though the old one is + # permitted for already installed, independently hashed model files. + with patch.object(installer.urllib.request, "urlopen", return_value=Response(old)): + with contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(installer.cli(argv), 1) + self.assertFalse(card.exists()) + with patch.object(installer.urllib.request, "urlopen", return_value=Response(new)) as fetch: + with contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(installer.cli(argv), 0) + fetch.assert_called_once() + self.assertEqual(card.read_bytes(), new) + def test_runtime_install_is_explicit_cpu_pinned_and_updates_only_python_path(self): # subprocess is mocked: no venv, pip or network is touched. root = self.data / "frameyap" diff --git a/tests/test_model_files.py b/tests/test_model_files.py index 1f1eb6f..0a4e7da 100644 --- a/tests/test_model_files.py +++ b/tests/test_model_files.py @@ -74,6 +74,29 @@ class ModelFileTests(unittest.TestCase): (model / "nested").symlink_to(root) self.assertEqual(check_model(backend, model)["reason"], "unsafe_file") + def test_compatible_card_still_requires_exact_hash_and_safe_file(self): + with tempfile.TemporaryDirectory() as path: + root = Path(path) + _, backend = self.fixture(root) + current, previous = b"current card", b"old card" + item = ModelFile("README.md", len(current), hashlib.sha256(current).hexdigest(), + ((len(previous), hashlib.sha256(previous).hexdigest()),)) + backend = replace(backend, files=(item,)) + card = root / "README.md" + for content in (current, previous): + card.write_bytes(content) + self.assertEqual(check_model(backend, root)["state"], "installed_verified") + # A download temp pin must never accept an older artifact. + self.assertEqual(check_file(root, ModelFile(item.path, item.size, item.sha256))[0], + "size_mismatch") + card.write_bytes(b"bad card") + self.assertEqual(check_model(backend, root)["reason"], "hash_mismatch") + card.unlink() + outside = root / "outside" + outside.write_bytes(previous) + card.symlink_to(outside) + self.assertEqual(check_model(backend, root)["reason"], "unsafe_file") + def test_second_backend_status_without_runtime_changes(self): with tempfile.TemporaryDirectory() as path: root = Path(path)