Is it safe to download a model file?

Some model files are programs in disguise: opening an old PyTorch file can run code. Files that hold only numbers are built not to. Here is how to tell them apart.

  • 6 min read
  • 16 min to go deeper
On this page
  1. In one minute
  2. Explore the numbers
  3. How it works
  4. What this means when you build
  5. Go deeperoptional
  6. Skills this lesson builds
  7. Check yourself
  8. Further reading
  9. Codeoptional

A model file is either a list of numbers or a list of steps to follow, and that decides what opening it can do. In a demo built for this lesson, the same six numbers are saved two ways. Opening the 158-byte pickle file (Python’s way of saving objects) makes Python call 1 function: the file has 28 steps and the call is step 25. Opening the 100-byte safetensors file (a format that holds a label and numbers) calls 0 functions, in 3 steps of reading. The demo’s function only writes down a note, but a real pickle can name any function that Python can import (find and make ready).

In one minute

A file that holds only numbers has nothing to run, but a pickle file can run code on your computer the moment you open it. A model is a big set of numbers (its weights, the values it learned). Think of a box with a packing list: you read the list and take the numbers out, and nothing in the box can act. Safetensors files work like that. A pickle file is closer to assembly instructions: you follow each step, and one step can say “now go and do this other job”. Many older PyTorch files are pickles, often named .bin, .pt or .pth (the PyTorch and Hugging Face pages below use those names), though a name does not prove the format. So prefer files that hold only numbers, load PyTorch files in weights-only mode (a setting that limits what the file may build while it opens), leave remote code (code a repository ships with its model) off unless you have read it, and use a publisher you trust.

Explore the numbers

Open the widget. It starts with both files fully opened. Drag the step slider back to 0 and then forward: two pickle steps turn orange, the import and the call, and the safetensors file finishes in 3 steps. Then tick “Restricted loader” and see where the pickle stops. (A loader is the program that opens a file and puts its contents in memory.) The widget shows what Python’s loader would do with the demo’s bytes. It only reads them and writes words; it runs nothing itself.

How it works

Two ways to open a file

The demo saves one table of six numbers, named layer.weight, with 2 rows and 3 columns.

Two lists. A pickle file is five groups of steps to follow, and two of them, an import and a call, run a function. A safetensors file is three parts to read: a length, a header and the numbers.

A safetensors file is read in 3 steps, and each only reads bytes. A pickle is read as 28 steps, which Python calls opcodes; the picture groups them into five. Most build things, such as a dictionary, a list or a number. Two do more. Step 19 names a function and asks the loader to import it (find it and make it ready). Step 25 calls it (makes it run).

What a pickle can do while it opens

Python’s pickle module saves an object as the steps that rebuild it. An object can say how: “to rebuild me, call this function with these inputs”. The demo does that once, with a function that only adds a note to a list. Opening the file adds 1 note.

The Python documentation (read 2026-10-19) says the module “is not secure” and “Only unpickle data you trust”. The file holds the function’s name, so whoever writes the file chooses it: any function Python can import, including ones that start programs or read your files. It is the same pattern as prompt injection (lesson 14): text from a stranger that a program takes for orders, here a loader instead of a model.

Files that hold only numbers

A safetensors file holds its header length, a header, then the numbers. The header is 68 bytes of text that says the table’s name, its kind of number, its shape and where its bytes sit. The loader reads it and takes the 24 bytes it points to. No step names a function, so the file cannot ask for one. The format’s README (read 2026-10-19) defines “safe” as being able to use a file “randomly downloaded” and “expect not to run arbitrary code”. GGUF, a second format that holds only numbers, has the same shape: a header, a list of tables, then the numbers.

A reader of either format is still a program that reads bytes from a stranger, so it must check them. The demo’s reader refuses a header longer than the file, and tables that overlap or run past the end.

Closing the door in the loader

You can keep pickle and limit it. A restricted loader checks each import against a short list and stops at any name not on it (the Python documentation calls this “Restricting Globals”). PyTorch’s weights-only mode follows a similar idea with its own, separate unpickler. Its notes (read 2026-10-19) say that unpickler “is not allowed to dynamically import anything during unpickling” and let you add more globals to its allowed list. The class in Go deeper is the Python documentation’s “Restricting Globals” example, not PyTorch’s code.

A flow chart. The loader meets an import and asks whether the name is on a short list. If yes, it imports it and carries on. If no, it stops with an error and nothing is called.

In the demo the list has 5 names. The loader stops at step 19, before the call at step 25, and the note list stays empty. PyTorch’s notes say the mode “narrows the surface of remote code execution attacks” and list limits, so it narrows the risk and is not a wall.

The demo and the real thing

The demo is small and its one function is harmless. Real files are much larger and hold many tables, and a real bad file would not be harmless.

What this means when you build

  • Prefer safetensors or GGUF files. They hold a label and numbers, so the loader has nothing to run. Hugging Face’s Transformers library, which loads models in code, uses safetensors weights when a repository has them.
  • Load PyTorch files in weights-only mode. Pass weights_only=True to torch.load. It is the default since version 2.6 unless you pass your own pickle module, and PyTorch’s page still says never to load data from an untrusted source.
  • Leave trust_remote_code off unless you have read the code. It lets a repository bring its own model code. Pin a specific revision (a fixed version of the repository) so that code cannot change under you.
  • Check who published the file. Hugging Face says its own pickle scan “is not 100% foolproof”, so a scan does not replace trust.
  • Give the files around the weights the same care. Configuration, tokenizer and code files come with the weights, and number-only weights do not make them safe.

Optionalabout 16 min

Go deeper

Optional, for readers who write Python. The code is in the code/ folder: modelfile.py writes and reads both files, harmless.py holds the one function the demo calls, and test_modelfile.py and test_widget.py are the tests. python3 show_numbers.py prints every number above.

The one function the demo calls

The demo’s pickle calls exactly one function, kept in its own file so you can see it has nothing else in it:

def ran_on_open(note):
    """Called by the loader while it opens the demo pickle; keeps the note in NOTES and hands it back."""
    NOTES.append(note)
    return note

harmless.py imports nothing, holds one list called NOTES and this function. A test reads the file’s syntax tree and fails if that stops being true. No file in the lesson folder holds a pickle: demo_pickle() builds the bytes when the code runs.

Why one object makes a call

class Opens:
    """An object whose pickle says: to rebuild me, call ran_on_open with this note."""

    def __reduce__(self):
        return (harmless.ran_on_open, (NOTE,))


def demo_pickle():
    """The six numbers plus one object that calls ran_on_open when the file is opened."""
    return pickle.dumps({NAME: list(VALUES), "note": Opens()}, protocol=PICKLE_PROTOCOL)

When Python saves an object it asks for __reduce__, which returns a function and a tuple of inputs for it (the Python documentation, read 2026-10-19, describes it as a callable and a tuple of arguments). Pickle writes down the function’s module and name and the inputs. It does not write the class. So the file says “import harmless.ran_on_open, call it with this text”, and the loader does what the file says. Whoever writes the bytes picks the function. That is the whole mechanism, and the demo uses it for the smallest harmless job there is.

The 28 steps of the demo pickle

opcodes reads the steps with pickletools.genops, which lists them without running anything, and kind marks which ones import and which ones call:

def opcodes(data):
    """[(position, name, argument)] for every opcode, read with pickletools.genops. Nothing is executed."""
    return [(pos, op.name, arg) for op, arg, pos in pickletools.genops(data)]


def kind(name):
    return "import" if name in IMPORTS else "call" if name in CALLS else "build"

The demo uses pickle protocol 3, which writes the import as one GLOBAL step with the two names as plain text. These are the steps that matter:

Step Action Opcode
5 push the first key BINUNICODE
10 push the six numbers (steps 10 to 15) BINFLOAT
16 put them in the list APPENDS
17 push the second key BINUNICODE
19 import the demo’s function GLOBAL
21 push the note’s text BINUNICODE
23 wrap it as the inputs TUPLE1
25 call the function REDUCE
27 fill the dictionary SETITEMS
28 hand the dictionary back STOP

The other 13 steps set up the dictionary and the list, and remember items; 8 of them are BINPUT. Position in the file runs from byte 0 to byte 157, which with the last step’s 1 byte makes the 158 in the headline. The picture shows the stack, the loader’s pile of items, at the import and at the call:

Three snapshots of the loader’s stack. After the import step the stack holds the function ran_on_open. After the note text is pushed and wrapped, it holds the function and a one-item tuple of inputs. After the call step it holds the result, and one note has been recorded. Only the top of the stack is shown.

Hugging Face’s page on pickle scanning (read 2026-10-19) calls a pickle “basically a stack of instructions or opcodes” and says “The instructions that pose a threat are STACK_GLOBAL, GLOBAL and REDUCE”. Protocol 4 writes the import as STACK_GLOBAL, which takes its two names from items pushed earlier. The call is REDUCE in the demo; the code also lists OBJ, NEWOBJ and NEWOBJ_EX as steps that call.

The safetensors file, byte by byte

def write_safetensors(tensors, metadata=None):
    """tensors is {name: (dtype, shape, values)}. Row-major, little-endian, no padding."""
    header, body = {}, b""
    if metadata:
        header["__metadata__"] = dict(metadata)
    for name, (dtype, shape, values) in tensors.items():
        code, _ = DTYPES[dtype]
        assert len(values) == _count(shape)
        raw = struct.pack(f"<{len(values)}{code}", *values)
        header[name] = {"dtype": dtype, "shape": list(shape), "data_offsets": [len(body), len(body) + len(raw)]}
        body += raw
    text = json.dumps(header, separators=(",", ":")).encode("utf-8")
    return struct.pack("<Q", len(text)) + text + body

This follows the layout in the safetensors README (read 2026-10-19): the first 8 bytes are an unsigned little-endian 64-bit number, the size of the header; the header is JSON text that must begin with {; each entry gives dtype, shape and data_offsets (begin and end, counted from the start of the numbers); and the numbers follow in row-major order. For the demo the header is:

{"layer.weight":{"dtype":"F32","shape":[2,3],"data_offsets":[0,24]}}

That is 68 bytes. Six numbers of 4 bytes each are 24 bytes, so the file is 8 + 68 + 24 = 100 bytes.

A bar of 100 bytes in three parts: bytes 0 to 8 hold the header length, 68; bytes 8 to 76 hold the header text; bytes 76 to 100 hold six 4-byte numbers. Below, the six numbers are shown in two rows of three.

The reader must not trust the header. Its first checks refuse a file shorter than 8 bytes, a length past the end of the file or past the README’s 100 MB cap, and a header that does not begin with {:

def read_safetensors(data):
    """(tensors, metadata). Reading only unpacks numbers: nothing in the file is imported, run or evaluated."""
    if len(data) < 8:
        raise FormatError("shorter than 8 bytes")
    (n,) = struct.unpack_from("<Q", data, 0)
    if n > MAX_HEADER or 8 + n > len(data):
        raise FormatError("header length is larger than the file or the 100 MB cap")
    raw = data[8:8 + n]
    if not raw.startswith(b"{"):
        raise FormatError("header does not begin with {")

Later checks refuse repeated names, a byte range that does not fit its shape, and (after sorting the ranges) a table that overlaps another, leaves a hole, or leaves bytes after the last one unaccounted for:

    expected = 0
    for begin, end in sorted(spans):
        if begin != expected:
            raise FormatError("tensors overlap or leave a hole")
        expected = end
    if expected != len(body):
        raise FormatError("bytes after the last tensor are not indexed")
    return tensors, metadata

The README requires the numbers to be fully indexed, with no holes, and names polyglot files (one file valid as two formats) as what that prevents. A test feeds the reader 26 broken files and checks that each one is refused.

The restricted loader and the scanner

class Restricted(pickle.Unpickler):
    """The 'Restricting Globals' pattern from the Python pickle documentation: only the allowed names may be imported."""

    def find_class(self, module, name):
        if (module, name) in ALLOWED:
            return super().find_class(module, name)
        raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden")


def load_restricted(data):
    return Restricted(io.BytesIO(data)).load()

Python calls find_class whenever a file asks for a name, so this is the one place to say no. ALLOWED holds five pairs: range, set, frozenset, complex and slice, from the Python documentation’s example. Run on the demo pickle it raises global 'harmless.ran_on_open' is forbidden at step 19, and the note list is empty. Run on a pickle that holds only the six numbers it returns them, with no import at all.

The scanner reads the same steps without loading anything and reports each import that is not on the list:

def scan(data):
    """What a name-based scanner flags: every import not on the allowed list, as 'module.name', or 'unreadable'."""
    return [f"{m}.{n}" if m is not None else "unreadable" for _, m, n in imports(data) if m is None or (m, n) not in ALLOWED]

On the demo it returns ['harmless.ran_on_open'], and on the numbers-only pickle []. It checks names, not intent: it flags this function though the function is harmless. Where the names do not sit as plain text just before an import, it reports “unreadable” and does not pass it. Hugging Face says its scan is “not 100% foolproof” and that its lists of safe and unsafe names are kept “in a best-effort manner”. A name list is a way to warn a person, and it does not replace a trusted source.

Proof it works

cd lessons/is-it-safe-to-download-a-model-file/code
python3 -m pytest -q

The tests cover: opening the demo pickle records 1 note and gives back the six numbers; opening the safetensors file records 0 notes, even when a name in its header reads like a function call; the safetensors reader refuses the pickle’s bytes and 26 broken files; the restricted loader refuses the demo pickle at step 19 with 0 notes, in protocols 3 and 4, yet loads a pickle of range(3); the opcode list has 28 steps with 1 import and 1 call; the scanner flags the demo by name and flags an unreadable import; and the numbers 158, 100, 68, 28, 19 and 25. One more test reads harmless.py and fails if it imports anything or does more than the one job, and one reads every file in the folder for words that start programs or run text and for stored model files. The lesson’s code blocks are checked as verbatim excerpts of the source. A second file, test_widget.py, runs the widget’s JavaScript reader in Node.js (a way to run JavaScript outside a browser) on five pickles and two safetensors files, and checks that its steps and its answers equal Python’s. That file is the only one in the folder that starts another program, and it starts only Node.js, on the widget’s own script.

What the demo leaves out

  • One small table. Real checkpoints hold many tables and are much larger.
  • No zip archive. Since PyTorch 1.6.0, torch.save writes a ZIP64 archive: a data.pkl file for the pickle, and separate files for the numbers (PyTorch’s serialization notes, read 2026-10-19). Weights-only loading restricts the unpickler (the notes’ words); the lesson’s reading is that it reads the data.pkl part, which the notes do not say outright.
  • A list of 5 names. The demo’s rule is an allowlist of 5 imports. PyTorch’s documented rule differs: its weights-only unpickler “is not allowed to dynamically import anything during unpickling” and builds only what state dicts of “plain torch.Tensors” and “some other primitive types” need, and add_safe_globals “Marks the given globals as safe for weights_only load”. This lesson does not read PyTorch’s code.
  • A mode with limits. The notes say weights_only=True “narrows the surface of remote code execution attacks” and does not guard against denial of service (a file that makes the loader use up memory or time). They also say memory corruption, which PyTorch tries to prevent, “might still be possible”.
  • GGUF. Its specification (read 2026-10-19) puts a header first (a magic word “GGUF”, a version, counts, key-value metadata), then a description of each table, padding to an alignment (32 by default), then the numbers. The demo does not write it.
  • Bugs in readers. Any reader of any format can have bugs. This lesson cites no security audit of safetensors or GGUF.
  • The attack itself. The demo calls a function that writes a note. How a bad file is built is outside this lesson.

Where this is used in practice

The Python documentation (read 2026-10-19) says that unpickling imports any class or function a file asks for by default, which “permits the unpickler to import and invoke arbitrary code”, and shows find_class as the place to restrict it. It suggests formats such as JSON for untrusted data and signing data with hmac.

PyTorch’s torch.load page (version 2.14 docs, read 2026-10-19) says it “uses an unpickler under the hood” and carries the warning “Never load data from an untrusted source”. With weights_only on, the unpickler is restricted to “tensors, primitive types, dictionaries and any types added via torch.serialization.add_safe_globals()”. Its serialization notes say that from version 2.6 weights_only=True is used “if the pickle_module argument is not passed”.

The safetensors README (read 2026-10-19) says the format exists to stop a file from running code, and lists pickle as “Unsafe, runs arbitrary code”. It also claims that it is “almost impossible to use malicious files to DOS attack a user”. That is the project’s own claim, and no independent audit is cited here.

The Transformers documentation (v5.20.2 in the page, read 2026-10-19) says that when a repository has safetensors weights, from_pretrained() loads them, and that “Traditionally, PyTorch model weights are serialized with the pickle utility which is known to be insecure. Safetensor files are more secure and faster to load.” For custom model code it asks for trust_remote_code=True and suggests a pinned revision as an extra layer.

The common thread: pick a format with nothing to run, narrow the loader when you cannot, and know who published the file. This lesson’s demo matches that reading.

Skills this lesson builds

  • Explaining why opening a pickle can run a function and opening a safetensors file cannot, in terms of steps to follow and data to read.
  • Listing a pickle’s opcodes with pickletools.genops and marking the import and call steps without loading the file.
  • Writing a restricted unpickler with a short list of names and testing that it refuses anything else.
  • Writing a reader for a binary format that checks its input (lengths, offsets, overlaps) before it trusts it.
  • Choosing a model file format and loader settings by what each can do when it opens.

Portfolio project: write a small tool that takes the bytes of a pickle, lists its opcodes without loading it, and prints each import step with a verdict from your own short list. Test it on pickles you build yourself from one harmless function, publish a table of what it flags and what it cannot read, and say in the README what a check on names cannot tell you.

Check yourself

  1. Why can opening a pickle run a function when opening a safetensors file cannot?
Answer

A pickle is a list of steps for the loader to follow, and two kinds of step import a function and call it. A safetensors file is a length, a header and numbers: the loader unpacks them, and no step in the layout names a function.

  1. The restricted loader stops the demo pickle at step 19. Why is that early enough, and why would stopping at step 25 be too late?
Answer

Step 19 is the import that names the function, and step 25 is the call. Stopping at the import means the call never happens and the note list stays empty. Stopping at the call is too late: the function would already be running. Importing a module can itself run that module’s code, so the import step is the doorway.

  1. A file is named model.safetensors. Name two things you should still check.
Answer

Any two of: that its bytes really are that format (a name does not prove it, and a reader that checks its input will refuse a bad header); who published it; and the configuration, tokenizer and code files in the same repository, especially if trust_remote_code is on.

  1. What does weights_only=True do in torch.load, and why is it not a guarantee?
Answer

This describes PyTorch’s documented rule, not the demo’s list of 5 imports. The unpickler is restricted to tensors, primitive types and dictionaries, plus types the caller adds, and is not allowed to import anything dynamically. PyTorch’s own notes say it narrows the surface of remote code execution attacks and list limits: no guard against denial of service, and memory corruption “might still be possible”. The torch.load page still says never to load data from an untrusted source.

  1. The scanner flags harmless.ran_on_open, though that function is harmless. Is that a bug?
Answer

No. The scanner checks names against a list, not what a function does, so it flags anything that is not on the list and leaves the decision to a person. It also flags an import whose names it cannot read, and Hugging Face says such scans are “not 100% foolproof”.

Further reading

Web pages were read on 2026-10-19.

Gentle

Deep

Optional

Code

Each file runs on its own. Download it, or read it here.

embed_widget.py38 linesDownload embed_widget.py
Show code
"""Keep the copy of the demo files inside ../widget.html equal to the ones modelfile.py builds.

    python3 embed_widget.py          rewrite the data block in widget.html
    python3 embed_widget.py --check  exit 1 if widget.html differs (test_widget.py runs the same check)

The data block holds the two demo files as hexadecimal text. The page shows what is in them and runs nothing.
"""
import json
import re
import sys
from pathlib import Path

import harmless
import modelfile as mf

WIDGET = Path(__file__).resolve().parent.parent / "widget.html"
CONFIG_BLOCK = re.compile(r'(<script type="application/json" id="mf-config">)(.*?)(</script>)', re.S)


def config():
    """What the page needs: the bytes of both demo files, the name and note of the one function, and the restricted loader's list."""
    return {"pickle": mf.demo_pickle().hex(), "safetensors": mf.demo_safetensors().hex(), "func": harmless.ran_on_open.__name__, "note": mf.NOTE,
            "allowed": [f"{m}.{n}" for m, n in mf.ALLOWED]}


def embed(html):
    """html with the data block rewritten from modelfile.py."""
    settings = json.dumps(config(), ensure_ascii=False, separators=(", ", ": "))
    return CONFIG_BLOCK.sub(lambda m: m.group(1) + "\n" + settings + "\n" + m.group(3), html, count=1)


if __name__ == "__main__":
    current = WIDGET.read_text(encoding="utf-8")
    updated = embed(current)
    if "--check" in sys.argv:
        sys.exit(0 if updated == current else 1)
    WIDGET.write_text(updated, encoding="utf-8")
    print("widget.html data block now matches modelfile.py")
harmless.py9 linesDownload harmless.py
Show code
"""The one function the demo pickle calls. It records a note and does nothing else."""

NOTES = []


def ran_on_open(note):
    """Called by the loader while it opens the demo pickle; keeps the note in NOTES and hands it back."""
    NOTES.append(note)
    return note
modelfile.py193 linesDownload modelfile.py
Show code
"""Two files that hold the same six numbers: a pickle that calls a function while it opens, and a safetensors file that cannot.

Standard library only. The one function the demo pickle calls is harmless.ran_on_open, which records a note and does nothing else.
The pickle is built here from Python objects at run time; no pickle file is stored anywhere.
"""
import io
import json
import pickle
import pickletools
import struct

import harmless

NAME = "layer.weight"
SHAPE = (2, 3)
VALUES = (1.5, -0.25, 0.75, 2.0, -1.0, 0.125)             # exact in float32 and in float64, so both formats give back the same numbers
NOTE = "the demo file was opened"
PICKLE_PROTOCOL = 3                                        # protocol 3 writes the import as one GLOBAL opcode with the two names as plain text
MAX_HEADER = 100_000_000                                   # 100 MB, as in the safetensors README and MAX_HEADER_SIZE in safetensors/src/tensor.rs
DTYPES = {"F32": ("f", 4), "I32": ("i", 4)}
ALLOWED = (("builtins", "range"), ("builtins", "set"), ("builtins", "frozenset"), ("builtins", "complex"), ("builtins", "slice"))
IMPORTS = ("GLOBAL", "STACK_GLOBAL", "INST")               # opcodes that ask the loader to import a name
CALLS = ("REDUCE", "OBJ", "NEWOBJ", "NEWOBJ_EX")           # opcodes that make the loader call it
STRING_PUSHES = ("SHORT_BINUNICODE", "BINUNICODE", "BINUNICODE8", "UNICODE")
MEMO = ("MEMOIZE", "PUT", "BINPUT", "LONG_BINPUT")        # bookkeeping: remember the top item


class FormatError(ValueError):
    """The bytes are not a well-formed safetensors file."""


class Opens:
    """An object whose pickle says: to rebuild me, call ran_on_open with this note."""

    def __reduce__(self):
        return (harmless.ran_on_open, (NOTE,))


def demo_pickle():
    """The six numbers plus one object that calls ran_on_open when the file is opened."""
    return pickle.dumps({NAME: list(VALUES), "note": Opens()}, protocol=PICKLE_PROTOCOL)


def plain_pickle():
    """The same six numbers and nothing else: a pickle that imports and calls nothing."""
    return pickle.dumps({NAME: list(VALUES)}, protocol=PICKLE_PROTOCOL)


# ---------- safetensors: an 8-byte length, a JSON header, then raw numbers ----------

def write_safetensors(tensors, metadata=None):
    """tensors is {name: (dtype, shape, values)}. Row-major, little-endian, no padding."""
    header, body = {}, b""
    if metadata:
        header["__metadata__"] = dict(metadata)
    for name, (dtype, shape, values) in tensors.items():
        code, _ = DTYPES[dtype]
        assert len(values) == _count(shape)
        raw = struct.pack(f"<{len(values)}{code}", *values)
        header[name] = {"dtype": dtype, "shape": list(shape), "data_offsets": [len(body), len(body) + len(raw)]}
        body += raw
    text = json.dumps(header, separators=(",", ":")).encode("utf-8")
    return struct.pack("<Q", len(text)) + text + body


def demo_safetensors():
    return write_safetensors({NAME: ("F32", SHAPE, VALUES)})


def _count(shape):
    n = 1
    for side in shape:
        n *= side
    return n


def _no_duplicates(pairs):
    out = {}
    for key, value in pairs:
        if key in out:
            raise FormatError(f"duplicate key {key!r}")
        out[key] = value
    return out


def _is_int(x):
    return isinstance(x, int) and not isinstance(x, bool)


def read_safetensors(data):
    """(tensors, metadata). Reading only unpacks numbers: nothing in the file is imported, run or evaluated."""
    if len(data) < 8:
        raise FormatError("shorter than 8 bytes")
    (n,) = struct.unpack_from("<Q", data, 0)
    if n > MAX_HEADER or 8 + n > len(data):
        raise FormatError("header length is larger than the file or the 100 MB cap")
    raw = data[8:8 + n]
    if not raw.startswith(b"{"):
        raise FormatError("header does not begin with {")
    try:
        header = json.loads(raw.decode("utf-8"), object_pairs_hook=_no_duplicates)
    except (UnicodeDecodeError, json.JSONDecodeError, RecursionError) as e:
        raise FormatError(f"header is not JSON: {e}") from e
    metadata = header.pop("__metadata__", {})
    if not isinstance(metadata, dict) or not all(isinstance(k, str) and isinstance(v, str) for k, v in metadata.items()):
        raise FormatError("__metadata__ must map text to text")
    body = data[8 + n:]
    spans, tensors = [], {}
    for name, info in header.items():
        if not isinstance(info, dict) or set(info) != {"dtype", "shape", "data_offsets"} or not isinstance(info["dtype"], str) or info["dtype"] not in DTYPES:
            raise FormatError(f"{name!r}: needs dtype, shape and data_offsets, with a known dtype")
        shape, offsets = info["shape"], info["data_offsets"]
        if not isinstance(shape, list) or not all(_is_int(s) and s >= 0 for s in shape):
            raise FormatError(f"{name!r}: bad shape")
        if not isinstance(offsets, list) or len(offsets) != 2 or not all(_is_int(o) for o in offsets):
            raise FormatError(f"{name!r}: bad data_offsets")
        begin, end = offsets
        code, size = DTYPES[info["dtype"]]
        if not 0 <= begin <= end <= len(body) or end - begin != _count(shape) * size:
            raise FormatError(f"{name!r}: offsets run past the data or do not fit the shape")
        spans.append((begin, end))
        tensors[name] = (info["dtype"], tuple(shape), struct.unpack(f"<{_count(shape)}{code}", body[begin:end]))
    expected = 0
    for begin, end in sorted(spans):
        if begin != expected:
            raise FormatError("tensors overlap or leave a hole")
        expected = end
    if expected != len(body):
        raise FormatError("bytes after the last tensor are not indexed")
    return tensors, metadata


# ---------- pickle: a list of instructions ----------

def opcodes(data):
    """[(position, name, argument)] for every opcode, read with pickletools.genops. Nothing is executed."""
    return [(pos, op.name, arg) for op, arg, pos in pickletools.genops(data)]


def kind(name):
    return "import" if name in IMPORTS else "call" if name in CALLS else "build"


def imports(data):
    """[(step, module, name)] for each import opcode; module is None when the two names are not plain text just before it."""
    found, recent = [], []
    for step, (_, name, arg) in enumerate(opcodes(data)):
        if name in MEMO:
            continue
        if name in ("GLOBAL", "INST"):
            module, _, attr = arg.partition(" ")
            found.append((step, module, attr))
        elif name == "STACK_GLOBAL":
            module, attr = ([None, None] + recent)[-2:]
            found.append((step, module, attr) if module is not None and attr is not None else (step, None, None))
        recent = (recent + [arg if name in STRING_PUSHES else None])[-2:]
    return found


def scan(data):
    """What a name-based scanner flags: every import not on the allowed list, as 'module.name', or 'unreadable'."""
    return [f"{m}.{n}" if m is not None else "unreadable" for _, m, n in imports(data) if m is None or (m, n) not in ALLOWED]


class Restricted(pickle.Unpickler):
    """The 'Restricting Globals' pattern from the Python pickle documentation: only the allowed names may be imported."""

    def find_class(self, module, name):
        if (module, name) in ALLOWED:
            return super().find_class(module, name)
        raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden")


def load_restricted(data):
    return Restricted(io.BytesIO(data)).load()


def first_refused(data):
    """The step (0-based) at which load_restricted stops, or None if it never does."""
    for step, module, name in imports(data):
        if module is None or (module, name) not in ALLOWED:
            return step
    return None


def numbers():
    """Every number the lesson prints about the two demo files."""
    p, s = demo_pickle(), demo_safetensors()
    ops = opcodes(p)
    return {"pickle_bytes": len(p), "safetensors_bytes": len(s), "header_bytes": struct.unpack_from("<Q", s, 0)[0],
            "opcodes": len(ops), "imports": sum(kind(o[1]) == "import" for o in ops), "calls": sum(kind(o[1]) == "call" for o in ops),
            "import_step": first_refused(p) + 1, "call_step": next(i for i, o in enumerate(ops, 1) if kind(o[1]) == "call"),
            "safetensors_steps": 3}
show_numbers.py28 linesDownload show_numbers.py
Show code
"""Prints every number the lesson quotes. Loading the demo pickle calls harmless.ran_on_open once; the safetensors file calls nothing."""
import pickle

import harmless
import modelfile as mf


def main():
    n = mf.numbers()
    print(f"pickle file: {n['pickle_bytes']} bytes, {n['opcodes']} opcodes, {n['imports']} import and {n['calls']} call")
    print(f"  import is step {n['import_step']}, call is step {n['call_step']}")
    print(f"safetensors file: {n['safetensors_bytes']} bytes, header {n['header_bytes']} bytes, {n['safetensors_steps']} reading steps, 0 imports, 0 calls")
    print("header text:", mf.demo_safetensors()[8:8 + n["header_bytes"]].decode())

    tensors, _ = mf.read_safetensors(mf.demo_safetensors())
    print("safetensors opened; notes recorded:", len(harmless.NOTES), "; numbers:", tensors[mf.NAME][2])
    loaded = pickle.loads(mf.demo_pickle())
    print("pickle opened; notes recorded:", len(harmless.NOTES), "; numbers:", tuple(loaded[mf.NAME]))
    harmless.NOTES.clear()
    try:
        mf.load_restricted(mf.demo_pickle())
    except pickle.UnpicklingError as e:
        print("restricted loader:", e, "; notes recorded:", len(harmless.NOTES))
    print("scanner flags:", mf.scan(mf.demo_pickle()), "; flags the plain pickle:", mf.scan(mf.plain_pickle()))


if __name__ == "__main__":
    main()
test_modelfile.py186 linesDownload test_modelfile.py
Show code
"""Guards for modelfile.py: what opening each demo file does, what the restricted loader and the scanner say, and that the folder holds no payload."""
import ast
import pickle
import re
import struct
from pathlib import Path

import pytest

import harmless
import modelfile as mf

FOLDER = Path(__file__).resolve().parent.parent


@pytest.fixture(autouse=True)
def clean_notes():
    harmless.NOTES.clear()
    yield
    harmless.NOTES.clear()


def make(header_text, body=b""):
    """A safetensors file with a header written by hand, for the cases the writer would never produce."""
    raw = header_text.encode("utf-8")
    return struct.pack("<Q", len(raw)) + raw + body


def test_opening_the_pickle_calls_the_function_and_still_gives_the_numbers():
    """Guards the lesson's headline: one call happens while the file opens, and nothing about the result shows it."""
    loaded = pickle.loads(mf.demo_pickle())
    assert harmless.NOTES == [mf.NOTE]
    assert loaded[mf.NAME] == list(mf.VALUES) and loaded["note"] == mf.NOTE


def test_opening_the_safetensors_file_calls_nothing_even_with_hostile_looking_text():
    """Guards the claim that reading a weight-only file runs nothing: text in names and metadata is only text, and the numbers match the pickle's."""
    tensors, _ = mf.read_safetensors(mf.demo_safetensors())
    assert tensors == {mf.NAME: ("F32", mf.SHAPE, mf.VALUES)} and harmless.NOTES == []
    odd = {"harmless.ran_on_open": ("F32", (1,), (1.5,)), "c harmless\nran_on_open\n": ("I32", (2,), (1, -2))}
    meta = {"note": "call harmless.ran_on_open when you read this", "format": "pt"}
    tensors, metadata = mf.read_safetensors(mf.write_safetensors(odd, meta))
    assert tensors == {k: (d, s, v) for k, (d, s, v) in odd.items()} and metadata == meta and harmless.NOTES == []


def test_pickle_bytes_are_not_a_safetensors_file():
    """Guards the two formats being told apart by shape: a pickle fed to the safetensors reader is refused and nothing is called."""
    for data in (mf.demo_pickle(), mf.plain_pickle(), b"", b"\x80\x03"):
        with pytest.raises(mf.FormatError):
            mf.read_safetensors(data)
    assert harmless.NOTES == []


def test_malformed_safetensors_files_are_each_refused():
    """Guards the checks the README lists (offsets in range, no overlap, no hole, no unindexed bytes, size matches shape, header length, header start,
    duplicate keys, known dtype, text-only metadata) and the type checks behind them (entry, keys, shape, offsets, dtype, text, nesting): each broken
    file raises FormatError, never a TypeError or a struct error, and calls nothing. The lesson quotes the number of rows."""
    a = '"a":{"dtype":"F32","shape":[3],"data_offsets":[%d,%d]}'
    not_utf8 = b'{"\xff":1}'
    good = make("{" + a % (0, 12) + "}", bytes(12))
    bad = {
        "past the end": make('{"a":{"dtype":"F32","shape":[6],"data_offsets":[0,24]}}', bytes(12)),
        "size does not fit shape": make('{"a":{"dtype":"F32","shape":[2],"data_offsets":[0,12]}}', bytes(12)),
        "overlap": make("{" + a % (0, 12) + "," + a.replace('"a"', '"b"') % (8, 20) + "}", bytes(20)),
        "hole": make("{" + a % (0, 12) + "," + a.replace('"a"', '"b"') % (16, 28) + "}", bytes(28)),
        "bytes after the last tensor": make("{" + a % (0, 12) + "}", bytes(16)),
        "header longer than the file": struct.pack("<Q", 10 ** 6) + good[8:],
        "header over the cap": struct.pack("<Q", mf.MAX_HEADER + 1) + good[8:],
        "header does not start with {": make(" {" + a % (0, 12) + "}", bytes(12)),
        "unknown dtype": make('{"a":{"dtype":"XX","shape":[3],"data_offsets":[0,12]}}', bytes(12)),
        "duplicate key": make("{" + a % (0, 12) + "," + a % (0, 12) + "}", bytes(12)),
        "metadata value is a number": make('{"__metadata__":{"n":1},' + a % (0, 12) + "}", bytes(12)),
        "boolean in the shape": make('{"a":{"dtype":"F32","shape":[true],"data_offsets":[0,4]}}', bytes(4)),
        "not JSON": make("{nope", bytes(4)),
        "header is not UTF-8": struct.pack("<Q", len(not_utf8)) + not_utf8,
        "header nested too deep": make('{"a":' + "[" * 1000000 + "]" * 1000000 + "}"),
        "entry is not an object": make('{"a":["dtype","shape","data_offsets"]}'),
        "entry misses a key": make('{"a":{"dtype":"F32","shape":[3]}}'),
        "entry has an extra key": make('{"a":{"dtype":"F32","shape":[3],"data_offsets":[0,12],"x":1}}', bytes(12)),
        "dtype is a list": make('{"a":{"dtype":["F32"],"shape":[3],"data_offsets":[0,12]}}', bytes(12)),
        "shape is a number": make('{"a":{"dtype":"F32","shape":3,"data_offsets":[0,12]}}', bytes(12)),
        "negative sides that multiply to the size": make('{"a":{"dtype":"F32","shape":[-1,-3],"data_offsets":[0,12]}}', bytes(12)),
        "offsets are a number": make('{"a":{"dtype":"F32","shape":[3],"data_offsets":5}}', bytes(12)),
        "three offsets": make('{"a":{"dtype":"F32","shape":[3],"data_offsets":[0,12,12]}}', bytes(12)),
        "boolean offset": make('{"a":{"dtype":"F32","shape":[1],"data_offsets":[false,4]}}', bytes(4)),
        "float offset": make('{"a":{"dtype":"F32","shape":[1],"data_offsets":[0,4.0]}}', bytes(4)),
        "negative begin offset": make('{"a":{"dtype":"F32","shape":[2],"data_offsets":[-4,4]}}', bytes(4)),
    }
    lesson = (FOLDER / "lesson.md").read_text(encoding="utf-8")
    assert lesson.count(f"{len(bad)} broken files") == 2, "the lesson quotes how many broken files the reader refuses"
    assert mf.read_safetensors(good)[0]["a"] == ("F32", (3,), (0.0, 0.0, 0.0))
    for what, data in bad.items():
        with pytest.raises(mf.FormatError):
            mf.read_safetensors(data)
        assert harmless.NOTES == [], what


def test_restricted_loader_stops_at_the_import_before_anything_is_called():
    """Guards the fix: the demo file is refused at its import with no note recorded, a plain pickle still loads, and an allowed name is still imported and called."""
    with pytest.raises(pickle.UnpicklingError, match=r"global 'harmless\.ran_on_open' is forbidden"):
        mf.load_restricted(mf.demo_pickle())
    assert harmless.NOTES == []
    for wrong in (b"charmless\nrange\n.", b"cbuiltins\nran_on_open\n."):             # an allowed name from the wrong module, an allowed module with a name it lacks
        with pytest.raises(pickle.UnpicklingError, match="is forbidden"):
            mf.load_restricted(wrong)
    assert mf.load_restricted(mf.plain_pickle()) == {mf.NAME: list(mf.VALUES)}
    with_range = pickle.dumps({"r": range(3)}, protocol=mf.PICKLE_PROTOCOL)
    assert mf.load_restricted(with_range) == {"r": range(3)}
    assert [mf.kind(o[1]) for o in mf.opcodes(with_range)].count("import") == 1                # it did import and call: the allowed list is a list of names
    four = pickle.dumps({mf.NAME: list(mf.VALUES), "note": mf.Opens()}, protocol=4)            # protocol 4 spells the import as STACK_GLOBAL
    assert "STACK_GLOBAL" in [o[1] for o in mf.opcodes(four)]
    with pytest.raises(pickle.UnpicklingError, match=r"global 'harmless\.ran_on_open' is forbidden"):
        mf.load_restricted(four)
    assert harmless.NOTES == []


def test_opcode_listing_finds_one_import_then_one_call_without_running_anything():
    """Guards the numbers the lesson quotes (28 opcodes, import at step 19, call at step 25) and that listing is read-only."""
    ops = mf.opcodes(mf.demo_pickle())
    kinds = [mf.kind(o[1]) for o in ops]
    assert len(ops) == 28 and kinds.count("import") == 1 and kinds.count("call") == 1
    assert kinds.index("import") + 1 == 19 and kinds.index("call") + 1 == 25 and ops[18][2] == "harmless ran_on_open"
    assert [o[1] for o in mf.opcodes(mf.plain_pickle())].count("REDUCE") == 0 and not mf.imports(mf.plain_pickle())
    assert [mf.kind(name) for name in ("REDUCE", "OBJ", "NEWOBJ", "NEWOBJ_EX")] == ["call"] * 4              # the four steps the lesson says call
    assert harmless.NOTES == []


def test_scanner_flags_names_not_intent_and_flags_what_it_cannot_read():
    """Guards the lesson's limit on scanners: the harmless demo is flagged because its name is not on the list, allowed names pass,
    and an import whose names are not plain text just before it is flagged as unreadable instead of passed."""
    assert mf.scan(mf.demo_pickle()) == ["harmless.ran_on_open"] and mf.scan(mf.plain_pickle()) == []
    assert mf.scan(pickle.dumps(range(3), protocol=mf.PICKLE_PROTOCOL)) == []
    assert mf.scan(pickle.dumps(mf.Opens(), protocol=4)) == ["harmless.ran_on_open"]
    from_memo = b"\x80\x04\x8c\x08builtins\x94h\x00\x93."                    # STACK_GLOBAL whose second name comes from the memo, not from a push
    assert mf.scan(from_memo) == ["unreadable"] and mf.first_refused(from_memo) == 4
    assert mf.first_refused(mf.demo_pickle()) == 18 and mf.first_refused(mf.plain_pickle()) is None


def test_the_numbers_the_lesson_quotes():
    """Guards the lesson text against a changed demo: a different value, name or protocol moves every size."""
    assert mf.numbers() == {"pickle_bytes": 158, "safetensors_bytes": 100, "header_bytes": 68, "opcodes": 28, "imports": 1, "calls": 1,
                            "import_step": 19, "call_step": 25, "safetensors_steps": 3}
    header = mf.demo_safetensors()[8:76].decode()
    assert header == '{"layer.weight":{"dtype":"F32","shape":[2,3],"data_offsets":[0,24]}}'


def test_the_only_function_the_demo_calls_records_a_note_and_nothing_else():
    """Guards the safety rule: harmless.py imports nothing, holds one list and one function, and the function appends to the list and returns its argument."""
    tree = ast.parse((FOLDER / "code" / "harmless.py").read_text(encoding="utf-8"))
    body = [n for n in tree.body if not (isinstance(n, ast.Expr) and isinstance(n.value, ast.Constant))]
    assert [type(n).__name__ for n in body] == ["Assign", "FunctionDef"] and body[0].targets[0].id == "NOTES" and body[1].name == "ran_on_open"
    fn = [n for n in body[1].body if not (isinstance(n, ast.Expr) and isinstance(n.value, ast.Constant))]
    assert ast.unparse(fn[0]) == "NOTES.append(note)" and ast.unparse(fn[1]) == "return note" and len(fn) == 2
    assert not any(isinstance(n, (ast.Import, ast.ImportFrom)) for n in ast.walk(tree))


def test_no_payload_words_and_no_stored_model_files_in_the_lesson_folder():
    """Guards the first rule of the lesson: no word that starts a program or runs text, no old palette colours, and no pickle or checkpoint file on disk.
    The words are spelt in pieces so this file does not hold them. test_widget.py starts Node on the widget's own script and so names the process module."""
    banned = ["os" + ".system", "ev" + "al(", "ex" + "ec(", "sub" + "process", "0072" + "B2", "56B4" + "E9", "D55E" + "00", "__imp" + "ort__", "pop" + "en("]
    files = [p for p in FOLDER.rglob("*") if p.is_file()]
    assert files and not [p.name for p in files if p.suffix in (".pkl", ".pt", ".pth", ".bin", ".ckpt", ".pyc")]
    assert not [p for p in FOLDER.rglob("__pycache__")]
    for path in files:
        text = path.read_text(encoding="utf-8")
        for word in banned:
            if word == "sub" + "process" and path.name == "test_widget.py":
                continue
            assert word.lower() not in text.lower(), (path.name, word)
    lesson = (FOLDER / "lesson.md").read_text(encoding="utf-8")
    assert not re.search(r"\]\([^)]*\.(pkl|pt|pth|bin|ckpt)\)", lesson), "no model file is offered for download"


def test_lesson_code_blocks_are_verbatim_excerpts_and_its_numbers_are_the_codes():
    """The lesson quotes these files. If the code changes and the lesson does not, the reader sees code that was never tested or numbers that moved."""
    lesson = (FOLDER / "lesson.md").read_text(encoding="utf-8")
    source = (FOLDER / "code" / "modelfile.py").read_text(encoding="utf-8") + "\n" + (FOLDER / "code" / "harmless.py").read_text(encoding="utf-8")
    blocks = re.findall(r"^```python\n(.*?)^```", lesson, flags=re.S | re.M)
    assert len(blocks) >= 6, "the lesson has no python blocks to check"
    for block in blocks:
        assert block.rstrip("\n") in source, f"not in the code folder:\n{block}"
    n = mf.numbers()
    header = mf.demo_safetensors()[8:8 + n["header_bytes"]].decode()
    for fact in (f"{n['pickle_bytes']}-byte pickle", f"{n['safetensors_bytes']}-byte safetensors", f"{n['opcodes']} steps", f"step {n['import_step']}",
                 f"step {n['call_step']}", f"{n['header_bytes']} bytes", f"```json\n{header}\n```", "8 + 68 + 24 = 100"):
        assert fact in lesson, fact
test_widget.py310 linesDownload test_widget.py
Show code
"""Guards for ../widget.html: its data block, its size, that its readers see the same opcodes and header as modelfile.py, and what a reader sees."""
import json
import pickle
import re
import struct
import subprocess

import pytest

import harmless
import modelfile as mf
from embed_widget import CONFIG_BLOCK, WIDGET, config, embed

HTML = WIDGET.read_text(encoding="utf-8")
ROOT = HTML[HTML.index('<div class="mf-root">'):HTML.index("<script")]
OUTSIDE = re.sub(r"<details>.*?</details>", "", ROOT, flags=re.S)

# Reads the byte readers out of the widget between its marker comments, runs them with Node on files that Python built, and compares every
# opcode (position, end, name, argument), the step where the restricted loader stops, and every safetensors field, with what Python found.
PARITY = r"""
import fs from 'node:fs';
const [fixture, widget] = process.argv.slice(2);
const fx = JSON.parse(fs.readFileSync(fixture, 'utf8'));
const html = fs.readFileSync(widget, 'utf8');
const js = html.slice(html.indexOf('/* ---------- readers ('), html.indexOf('/* ---------- the words'));
const api = new Function("'use strict';" + js + '; return {toBytes, num, readOps, firstRefused, readSafe};')();
const bad = [];
let checks = 0;
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
fx.pickles.forEach((p) => {
  const ops = api.readOps(api.toBytes(p.hex));
  checks += 2;
  if (!same(ops.map((o) => [o.pos, o.end, o.name, o.arg]), p.ops)) bad.push(['ops', p.name, ops.slice(0, 3)]);
  if (api.firstRefused(ops, fx.allowed) !== p.first) bad.push(['first refused', p.name, api.firstRefused(ops, fx.allowed), p.first]);
});
fx.safe.forEach((s) => {
  const got = api.readSafe(api.toBytes(s.hex));
  checks++;
  if (!same(got, s.read)) bad.push(['safetensors', s.name, got, s.read]);
});
[[2, '2.0'], [1.5, '1.5'], [-0.25, '-0.25'], [0.125, '0.125'], [-1, '-1.0']].forEach(([x, t]) => { checks++; if (api.num(x) !== t) bad.push(['num', x, api.num(x)]); });
console.log(JSON.stringify({ checks, bad: bad.slice(0, 4) }));
process.exit(bad.length ? 1 : 0);
"""

# Runs the page script against a small stand-in for the DOM, moves the slider, presses the buttons and the checkbox, and reads back what a reader sees.
PAGE = r"""
import fs from 'node:fs';
const [widget, fixture] = process.argv.slice(2);
const html = fs.readFileSync(widget, 'utf8');
const fx = JSON.parse(fs.readFileSync(fixture, 'utf8'));
const config = html.match(/<script type="application\/json" id="mf-config">([\s\S]*?)<\/script>/)[1];
const code = html.match(/<script>([\s\S]*?)<\/script>/)[1];

let active = null;
function el(tag) {
  const e = { tag, children: [], listeners: {}, value: '', checked: false, disabled: false, style: {}, attrs: {}, focus: () => { active = e; } };
  let text = '';                                     // as in a browser, setting the text of an element drops its children
  Object.defineProperty(e, 'textContent', { get: () => text, set: (v) => { text = v; e.children.length = 0; } });
  e.setAttribute = (k, v) => { e.attrs[k] = v; };
  e.addEventListener = (type, f) => (e.listeners[type] = e.listeners[type] || []).push(f);
  e.appendChild = (c) => (e.children.push(c), c);
  return e;
}
function open() {
  const page = { root: el('html'), messages: [], parent: { other: true } };
  fx.ids.forEach((id) => { page[id] = el('x'); });
  page['mf-config'].textContent = config;
  const doc = { getElementById: (id) => page[id], createElement: el, documentElement: page.root, get activeElement() { return active; } };
  const win = { addEventListener: (type, f) => page.messages.push(f) };
  new Function('document', 'window', 'parent', code)(doc, win, page.parent);
  return page;
}
const fire = (e, type) => (e.listeners[type || 'click'] || []).forEach((f) => f({ target: e }));
const bad = [];
const want = (what, got, expected) => { if (JSON.stringify(got) !== JSON.stringify(expected)) bad.push([what, got, expected]); };
const states = (page, id) => page[id].children.map((c) => c.attrs['data-s']);
const lit = (pre) => pre.children.filter((c) => c.attrs['class'] === 'mf-on').map((c) => c.textContent);
const step = (page, n) => { page['mf-step'].value = String(n); fire(page['mf-step'], 'input'); };

const first = open();
want('opening: headline', first['mf-head'].textContent, fx.text.opening);
want('opening: slider', [first['mf-step'].value, first['mf-step'].max, first['mf-n'].textContent, first['mf-max'].textContent], ['28', '28', '28', '28']);
want('opening: sizes', [first['mf-psize'].textContent, first['mf-ssize'].textContent], [String(fx.pickleBytes), String(fx.safeBytes)]);
want('opening: counts', [first['mf-pcount'].textContent, first['mf-scount'].textContent], [fx.text.one, fx.text.none]);
want('opening: pickle strip', states(first, 'mf-pstrip'), Array(27).fill('done').concat(['now']));
want('opening: safetensors strip', states(first, 'mf-sstrip'), ['done', 'done', 'now']);
want('opening: kinds', first['mf-pstrip'].children.map((c) => c.attrs['data-k']).map((k, i) => (k === 'build' ? '' : i + k)).filter(Boolean), ['18import', '24call']);
want('opening: labels over the strip', first['mf-marks'].children.map((c) => c.textContent), ['import', 'call']);
want('opening: safetensors line', first['mf-sline'].textContent, fx.text.safeDone);
want('opening: next is off, nothing spoken', [first['mf-next'].disabled, first['mf-live'].textContent], [true, '']);

const page = open();
step(page, 19);
want('step 19: line', page['mf-pline'].textContent, fx.text.importLine);
want('step 19: headline', page['mf-head'].textContent, fx.text.after19);
want('step 19: strip', states(page, 'mf-pstrip').indexOf('now'), 18);
want('step 19: counts', page['mf-pcount'].textContent, fx.text.none);
want('step 19: bytes outlined', lit(page['mf-phex']), [fx.hex.importStep]);
want('dragging stays silent', page['mf-live'].textContent, '');
step(page, 25);
want('step 25: line', page['mf-pline'].textContent, fx.text.callLine);
want('step 25: counts', page['mf-pcount'].textContent, fx.text.one);
want('step 25: bytes outlined', lit(page['mf-phex']), [fx.hex.callStep]);
fire(page['mf-step'], 'change');
want('release speaks', page['mf-live'].textContent, page['mf-head'].textContent + ' ' + page['mf-pline'].textContent);
step(page, 2);
want('step 2: safetensors bytes', [page['mf-sline'].textContent, lit(page['mf-shex'])], [fx.text.safeStep2, [fx.hex.header]]);
want('step 2: safetensors strip', states(page, 'mf-sstrip'), ['done', 'now', 'todo']);

// every slider stop, with the restricted loader off and on: calls count REDUCE steps reached, and the stop is at the first refused import
[false, true].forEach((restricted) => {
  const p = open();
  p['mf-gate'].checked = restricted;
  for (let n = 0; n <= 28; n++) {
    step(p, n);
    const stopped = restricted && n > fx.first;
    const calls = stopped ? 0 : fx.calls[n];
    want('stop ' + n + ' restricted ' + restricted + ': counts', p['mf-pcount'].textContent, 'Functions called: ' + calls + ' · Notes recorded: ' + calls);
    want('stop ' + n + ': stopped words', p['mf-head'].textContent.startsWith('The restricted loader stopped'), stopped);
    want('stop ' + n + ': safetensors calls', p['mf-scount'].textContent, fx.text.none);
    want('stop ' + n + ': next', p['mf-next'].disabled, n >= 28 || stopped);
    if (stopped) {
      const s = states(p, 'mf-pstrip');
      want('stopped strip ' + n, [s[fx.first], s.slice(fx.first + 1).every((x) => x === 'skip'), s.slice(0, fx.first).every((x) => x === 'done')], ['stop', true, true]);
    }
  }
});

// the restricted loader at the end of the file says where it stopped and that nothing ran; unticking restores the opening
const gate = open();
gate['mf-gate'].checked = true; fire(gate['mf-gate'], 'change');
want('restricted: headline', gate['mf-head'].textContent, fx.text.stopped);
want('restricted: line', gate['mf-pline'].textContent, fx.text.stopLine);
want('restricted: speaks', gate['mf-live'].textContent, fx.text.stopped + ' ' + fx.text.stopLine);
want('restricted: safetensors unchanged', gate['mf-sline'].textContent, fx.text.safeDone);
gate['mf-gate'].checked = false; fire(gate['mf-gate'], 'change');
want('unticked: headline', gate['mf-head'].textContent, fx.text.opening);

// the restricted loader never shows a step past its stop: the counter, the spoken value and the slider say where it is, and Next is off
const stop = open();
stop['mf-gate'].checked = true; step(stop, 25);
want('restricted at 25: counter, spoken value, slider, next', [stop['mf-n'].textContent, stop['mf-step'].attrs['aria-valuetext'], stop['mf-step'].value, stop['mf-next'].disabled],
     [String(fx.first + 1), 'step ' + (fx.first + 1) + ' of 28', String(fx.first + 1), true]);

// the bytes outlined in the safetensors dump at its three reading steps: the length, the header, the numbers
[[1, 8], [2, 68], [3, 24]].forEach(([n, bytes]) => {
  step(page, n);
  want('safetensors step ' + n + ': bytes outlined', lit(page['mf-shex']).map((t) => t.split(' ').length), [bytes]);
});

// Next step and Start over
const btn = open();
fire(btn['mf-reset']);
want('start over', [btn['mf-step'].value, btn['mf-pline'].textContent, btn['mf-sline'].textContent, btn['mf-next'].disabled], ['0', fx.text.notStarted, 'Not started.', false]);
btn['mf-live'].textContent = '';                  // Start over spoke; only the click on Next may fill it now
fire(btn['mf-next']);
want('next advances by one step', [btn['mf-step'].value, btn['mf-n'].textContent, btn['mf-live'].textContent !== ''], ['1', '1', true]);
btn['mf-next'].focus();
for (let i = 0; i < 40; i++) fire(btn['mf-next']);
want('next stops at the end', [btn['mf-step'].value, btn['mf-next'].disabled, btn['mf-head'].textContent, active === btn['mf-step']], ['28', true, fx.text.opening, true]);

// the host page theme: only the parent window may set it, and only to light or dark
const themed = open();
const send = (data, source) => themed.messages.forEach((f) => f({ data, source }));
send({ type: 'spicyshark:theme', theme: 'dark' }, { other: false });
send({ type: 'spicyshark:theme', theme: 'purple' }, themed.parent);
send({ type: 'other', theme: 'dark' }, themed.parent);
send(null, themed.parent);
want('ignored messages', themed.root.attrs, {});
send({ type: 'spicyshark:theme', theme: 'dark' }, themed.parent);
want('theme from the parent', themed.root.attrs, { 'data-theme': 'dark' });

console.log(JSON.stringify({ bad: bad.slice(0, 5) }));
process.exit(bad.length ? 1 : 0);
"""

PICKLES = {
    "demo": mf.demo_pickle(),
    "plain": mf.plain_pickle(),
    "range": pickle.dumps({"r": range(3), "s": {1, 2}}, protocol=3),
    "many kinds": pickle.dumps({"a": (1, None, True, False, 70000, -5, 300), "b": [1, 2, 3], "c": (1,), "d": (1, 2), "e": (), "f": 0.5, "g": "xé"}, protocol=3),
    "two imports": pickle.dumps([mf.Opens(), range(2)], protocol=3),
}


def hexed(data, begin, end):
    return " ".join(f"{b:02x}" for b in data[begin:end])


def run_node(tmp_path, script, fixture, args):
    (tmp_path / "fixture.json").write_text(json.dumps(fixture), encoding="utf-8")
    (tmp_path / "check.mjs").write_text(script, encoding="utf-8")
    run = subprocess.run(["node", str(tmp_path / "check.mjs"), *args(tmp_path)], capture_output=True, text=True, timeout=120)
    assert run.returncode == 0, run.stdout + run.stderr
    return json.loads(run.stdout)


def arg_text(arg):
    return "" if arg is None else repr(arg) if isinstance(arg, float) else str(arg)


def pickle_fixture(name, data):
    ops = mf.opcodes(data)
    ends = [o[0] for o in ops[1:]] + [len(data)]
    first = mf.first_refused(data)
    return {"name": name, "hex": data.hex(), "ops": [[pos, end, op, arg_text(arg)] for (pos, op, arg), end in zip(ops, ends)], "first": -1 if first is None else first}


def safe_fixture(name, data):
    tensors, _ = mf.read_safetensors(data)
    (n,) = struct.unpack_from("<Q", data, 0)
    header = json.loads(data[8:8 + n])
    read = {k: {"dtype": d, "shape": list(s), "offsets": header[k]["data_offsets"], "values": list(v)} for k, (d, s, v) in tensors.items()}
    return {"name": name, "hex": data.hex(), "read": {"headerBytes": n, "tensors": read}}


def test_widget_data_block_matches_the_python_constants():
    """Guards a constant edit that was not copied into the widget (run embed_widget.py)."""
    assert embed(HTML) == HTML


def test_embed_rewrites_a_widget_whose_data_block_drifted():
    """Guards embed() skipping the block: a hand-edited note or file must be put back."""
    drifted = HTML.replace(mf.NOTE, "something else", 1).replace('"builtins.range"', '"builtins.set"', 1)
    assert drifted != HTML and embed(drifted) == HTML
    assert json.loads(CONFIG_BLOCK.search(HTML).group(2)) == json.loads(json.dumps(config()))
    assert bytes.fromhex(config()["pickle"]) == mf.demo_pickle() and bytes.fromhex(config()["safetensors"]) == mf.demo_safetensors()


def test_every_element_the_script_looks_up_exists_inside_the_root():
    """Guards a widget that never starts: the parity run covers only the readers, so a misspelt id passed there and threw in the browser."""
    script = HTML[HTML.index("<script>"):]
    lookups = set(re.findall(r"\$\('([\w-]+)'\)", script)) | {f"mf-{k}" for k in ("head", "pline", "pcount", "sline", "scount")}
    assert "$('mf-' + k)" in script                        # the five text lines are looked up by suffix; they are added above
    missing = sorted(i for i in lookups if i != "mf-config" and f'id="{i}"' not in ROOT)
    assert not missing, missing
    assert 'id="mf-config"' in HTML


def test_widget_is_small_asks_for_nothing_online_and_runs_no_text():
    assert len(HTML.splitlines()) <= 300 and len(HTML.encode("utf-8")) < 40 * 1024
    page = CONFIG_BLOCK.sub("", HTML)
    for banned in ("http://", "https://", "fetch(", "XMLHttpRequest", "WebSocket", "sendBeacon", "import(", "new Function", "innerHTML", "<form", 'type="submit"'):
        assert banned not in page, banned
    assert " src=" not in page and "@import" not in page


def test_widget_shows_at_most_five_controls_and_hides_the_rest():
    """Guards the first screen at 390 px: the slider, the two buttons and the checkbox are all there is; the byte dumps sit in a closed details element."""
    assert len(re.findall(r"<(?:select|button|input|textarea)\b", OUTSIDE)) <= 5
    assert "<details><summary>More settings</summary>" in ROOT and "<details open" not in ROOT
    assert re.search(r"<details><summary>More settings</summary>.*?id=\"mf-phex\".*?id=\"mf-shex\"", ROOT, re.S)


def test_widget_keeps_the_site_theme_listener_and_palette():
    assert "d.type !== 'spicyshark:theme'" in HTML and "e.source !== parent" in HTML and "parent === window" in HTML
    for pair in ("--bg: light-dark(#ffffff, #0a0f17)", "--panel: light-dark(#ebf0f6, #131b28)", "--ink: light-dark(#0a1626, #f4f7fb)",
                 "--mute: light-dark(#44566b, #a3b1c4)", "--ctl: light-dark(#66768a, #7a8a9f)", "--ink-on: light-dark(#f6f8fb, #05070c)",
                 "--green: light-dark(#00855F, #009E73)", "--orange: light-dark(#b36200, #E69F00)", ":root[data-theme=light]", ":root[data-theme=dark]"):
        assert pair in HTML, pair
    assert not set(re.findall(r"#[0-9a-fA-F]{3,8}\b", HTML)) - {"#ffffff", "#0a0f17", "#ebf0f6", "#131b28", "#0a1626", "#f4f7fb", "#44566b", "#a3b1c4",
                                                               "#66768a", "#7a8a9f", "#f6f8fb", "#05070c", "#00855F", "#009E73", "#b36200", "#E69F00"}


def test_widget_says_what_it_does_and_does_not_do():
    """Guards the sentence the lesson promises: the page shows what Python's loader would do and runs nothing itself, visible without opening anything."""
    assert "The widget shows what Python&rsquo;s loader would do with the bytes; it only reads them and writes words, and runs nothing itself." in OUTSIDE
    leaves_out = re.search(r"<details><summary>What this toy leaves out</summary>(.*?)</details>", ROOT, re.S).group(1)
    for said in ("millions or billions of numbers", "you can add types to its allowed list", "could name any function Python can import", "no holes"):
        assert said in leaves_out, said
    assert ".mf-root .mf-btns button { flex: 1 1 auto; white-space: nowrap; }" in HTML      # a button wraps to the next row, never its label
    assert 'class="mf-sr" id="mf-live" role="status" aria-live="polite" aria-atomic="true"' in ROOT


def test_widget_readers_see_what_python_sees(tmp_path):
    """Guards the widget's JavaScript drifting from modelfile.py: the lesson prints Python's numbers and the reader sees the widget's, so the opcode
    list, the step where the restricted loader stops and the safetensors header must agree on the demo files and on other pickles."""
    pickles = [pickle_fixture(name, data) for name, data in PICKLES.items()]
    safe = [safe_fixture("demo", mf.demo_safetensors()),
            safe_fixture("two tensors", mf.write_safetensors({"a": ("F32", (3,), (0.5, 1.0, -2.0)), "b": ("I32", (2,), (7, -9))}, {"format": "pt"}))]
    assert pickles[0]["first"] == 18 and pickles[1]["first"] == -1 and pickles[2]["first"] == -1 and pickles[4]["first"] > 0
    allowed = config()["allowed"]
    result = run_node(tmp_path, PARITY, {"pickles": pickles, "safe": safe, "allowed": allowed}, lambda d: [str(d / "fixture.json"), str(WIDGET)])
    assert result["bad"] == [] and result["checks"] == 2 * len(pickles) + len(safe) + 5


def test_widget_page_shows_what_the_lesson_names(tmp_path):
    """Guards the part a reader sees: the opening state (one function called by the pickle, none by safetensors), the import at step 19 and the call at
    step 25, every slider stop with the restricted loader off and on, the stop cell, the buttons, what is spoken on release, and the theme message."""
    ops = mf.opcodes(mf.demo_pickle())
    calls = [sum(1 for o in ops[:n] if o[1] == "REDUCE") for n in range(len(ops) + 1)]
    n = mf.numbers()
    fx = {"ids": sorted(set(re.findall(r'id="(mf-[\w-]+)"', HTML))), "pickleBytes": n["pickle_bytes"], "safeBytes": n["safetensors_bytes"], "calls": calls,
          "first": mf.first_refused(mf.demo_pickle()),
          "hex": {"importStep": hexed(mf.demo_pickle(), ops[18][0], ops[19][0]), "callStep": hexed(mf.demo_pickle(), ops[24][0], ops[25][0]),
                  "header": hexed(mf.demo_safetensors(), 8, 8 + n["header_bytes"])},
          "text": {"opening": "Opening the pickle called a function; opening the safetensors file called nothing.",
                   "after19": "After step 19 the pickle has called nothing; the safetensors file has called nothing.",
                   "stopped": "The restricted loader stopped the pickle at step 19 and called nothing. The safetensors file opens as before.",
                   "importLine": "Step 19 of 28 (GLOBAL): IMPORT harmless.ran_on_open. A file could name any function Python can import here.",
                   "callLine": 'Step 25 of 28 (REDUCE): CALL ran_on_open("the demo file was opened"). It records a note and does nothing else.',
                   "stopLine": "Step 19 of 28: the loader meets the import of harmless.ran_on_open, which is not on its list of 5 names. It stops with an error. "
                               "Nothing was called and the numbers did not load.",
                   "notStarted": "Not started. Move the slider or press Next step.",
                   "one": "Functions called: 1 · Notes recorded: 1", "none": "Functions called: 0 · Notes recorded: 0",
                   "safeStep2": 'Step 2 of 3: read the 68-byte header. It is plain text: layer.weight is F32, shape [2, 3], at bytes 0 to 24.',
                   "safeDone": "Step 3 of 3: read those 24 bytes as 6 numbers: 1.5, -0.25, 0.75, 2.0, -1.0, 0.125. Done: nothing was imported or called."}}
    result = run_node(tmp_path, PAGE, fx, lambda d: [str(WIDGET), str(d / "fixture.json")])
    assert result == {"bad": []}