mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-10-12 02:16:43 +00:00
* fix(api): reject path-separator characters in serialized object names
`ObjectSerializerDisk._get_path()` joined the caller-supplied `name`
straight onto the output directory. Names are generated server-side, but
they round-trip through the client inside node fields (`TensorField.
tensor_name`, `LatentsField.latents_name`, `ConditioningField.
conditioning_name`, `DenoiseMaskField.mask_name`), so on the way back in
they are attacker-controlled: any authenticated user who can submit a
graph could put `../` (or an absolute path) in one and make `load()`
`torch.load()` a file anywhere the server process can read. A node that
renders the loaded tensor - `tensor_mask_to_image` is the easy one -
then hands the contents back as an image the caller owns.
Validate at the single choke point: a name must be a plain filename.
`PureWindowsPath` is used for the check so that backslash- and
drive-letter-shaped payloads are rejected on posix too, rather than only
blowing up on Windows deployments.
Also pin `weights_only=True` on the `torch.load()` call. It has been the
torch default since 2.6 and we require >=2.7, so this is a no-op today,
but the file being loaded is named by untrusted input and the safe
globals allowlist is only meaningful while the unpickling path stays
shut.
Reported by Dan Aridor.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* fix(api): stop client-supplied model keys escaping the model images folder
Extends the object-serializer guard from the previous commit to the other
name-to-path service, and factors the shared predicate out into
`invokeai/app/util/path_safety.py` so the next such service has one
obvious thing to call. Two copies of a security predicate is how this bug
class survived in the first place: the pre-6.7 hardening fixed
`ImageFileStorageDisk.get_path()` and left its siblings alone.
`ModelImageFileStorageDisk` joined an unvalidated `model_key`. Reachable
over HTTP only in its Windows-backslash shape - uvicorn percent-decodes
before Starlette routes, so `%2F` never matches the `{key}` path segment -
but `save()` was also building its own path instead of going through
`get_path()`, so a guard on `get_path()` alone would have left the write
side open.
Worse, `_process_preview_image()` builds
`model_images_path / f"{model_key}.webp"` by hand, bypassing the service
entirely, and `model_key` there is whatever the install request asked for:
`ModelRecordChanges.key` is an optional body field and
`ModelConfigFactory` honours it verbatim. A key of `../../pwned` writes
the thumbnail two levels outside the store. That key also reaches
`dest_dir = models_path / info.key` in `install_path()`, which creates the
directory and moves the model into it. Validating `ModelRecordChanges.key`
closes both at the point the untrusted value enters; the hand-built join
is guarded too, since it is a backend util that takes a bare `Path`.
`update_model_image` and `delete_model_image` now resolve the model record
before touching the filesystem. Neither checked that the key named a model
at all, so an upload for an unknown key created a stray file; resolving
first makes that a 404, and keeps a rejected key from reaching a service
whose `except Exception` would have laundered it into a 500. Note that
`update_model_image` can now return 404, which its `responses` dict does
not declare - declaring it would churn `openapi.json` and `schema.ts`,
which are otherwise byte-identical across this branch.
Two gaps in the predicate itself, from an adversarial review of the
previous commit: `pathlib` keeps trailing dots and spaces but the Win32
normaliser strips them at open time, so `".. "` reached the parent
directory on Windows; and control characters were accepted, which is not
"safe to join" for a name that also ends up in a header or a log line.
Reported by Dan Aridor (the tensor case); the model-key cases were found
while hardening the surrounding code.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* fix(api): let the image-delete route clean up orphans; check the record under the claim
Review follow-ups on the model-image routes:
- DELETE /i/{key}/image no longer requires the model's record. Deleting a model
leaves its cover image behind, and this route is how that orphan is removed, so
the record check made orphans permanent. A missing image or a key that is not a
plain filename (rejected by the storage service) now maps to 404 instead of 500.
- PATCH /i/{key}/image resolves the record while holding the key claim. Deletion
and conversion claim the key too, so the model can no longer disappear between
the check and the save.
- Document the PATCH route's 404 in OpenAPI; regenerate openapi.json and schema.ts.
- Correct the ModelRecordChanges.key comment: install_path()'s join is not
guarded at the sink (#9596).
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* fix(api): case-fold model key claims; make the image-delete traversal test bite
- A cover image is `<key>.webp`, and on a case-insensitive filesystem a case
variant of a key names the same file. Claims were compared exactly, so an
image delete for `K` could run while a conversion held `k` and unlink the
image the conversion was carrying over. Claims are now case-folded.
- The image-delete traversal test only used backslash and `..` keys, which are
literal (missing) filenames on posix and 404 even with no key validation.
Add `../escaped`, which does escape the folder when unguarded.
- Correct the PATCH comment: removing an external provider's models does not
take the claim, so that path can still leave an orphan image.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* test(model-manager): cover legacy unsafe image key
* fix(api): 404 an upload for a legacy unsafe key; guard install_path at the sink
- PATCH /i/{key}/image: a record written before install keys were validated can
carry a key that is not a plain filename. The storage guard rejected it inside
save(), which surfaced as an unhandled ModelImageFileSaveException (500). Ask
the storage service for the path before reading the upload and answer 404.
- Claims use Unicode canonical caseless matching (NFD, casefold, NFD), not bare
casefold: APFS also ignores normalization, so `é` and `é` name one
cover image there.
- install_path() refuses a key that is not a plain filename before creating or
moving anything, so the models_path join no longer relies solely on the
ModelRecordChanges validator. Closes #9596.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* fix(install): skip a legacy install marker whose key no longer validates
`_restore_incomplete_installs` built `ModelRecordChanges` from each marker's
`config_in` outside the per-marker try. Now that the key is validated, a marker
written by an older version with an unsafe key raised out of the loop at every
startup, leaving every later interrupted install unrestored. Build it inside the
try so that marker is skipped like any other unreadable one.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* test(path-safety): cover NTFS parent stream traversal
* fix(api): reject NTFS stream syntax and Windows device names in stored names
Addresses JPPhoto's round-3 blocker and the fallout from fixing it.
`is_plain_filename()` passed `..:stream`. `PureWindowsPath` only reads a
single *letter* followed by a colon as a drive, so it parses `C:foo` but
hands `..:stream` back whole, and `.name == name` held. On NTFS that
names the alternate data stream `stream` on the file `..`, i.e. the
parent directory, so `<store>/..:stream` writes outside the store while
looking like one bare component. Colons are now rejected outright, along
with the rest of the set Windows forbids in a filename. Extends the one
case in 3895394 to the other shapes that leaked the same way.
Windows device names are rejected too. They do not escape upwards but
they do not stay put either: Win32 resolves `NUL`, `CON`, `COM1` and
friends to the device namespace from any directory, so a write to
`<store>/NUL.webp` is silently discarded while `.exists()` still reports
True. Includes the superscript `COM¹`/`LPT¹` forms and `COM0`/`LPT0`,
and strips trailing spaces from the base name after the extension split,
because Win32 trims them before the device lookup.
The control-character set now covers C1, U+2028 and U+2029, which
`str.splitlines()` treats as line breaks.
The `ModelRecordChanges.key` validator is gone, and the check moved to
where a key is actually adopted. That model is the *update* body as well
as the install body, and the model edit form posts the whole record back
with the key riding along as an echo, so validating the field made any
model whose key predates key validation impossible to edit at all: a 422
before the handler runs, with no key field in the UI to fix it. On Linux
and macOS every one of `: ? * | < > "` is a legal filename character and
no released version checked the key, so such records exist. The same
model is re-parsed from install markers written by older versions, where
rejecting one stranded its partial download forever - no job is created
to clean up and `_remove_dangling_install_dirs` keeps any readable
non-terminal marker.
In its place:
- `update_model_record` refuses to *change* a key. `update_model` copies
every set field into the stored config, so a `key` in the body landed
in the blob while the row id kept the old one, leaving a record that
answers to a key nothing can look it up by - `replace_model`, and so
reidentify, raises `UnknownModelException` from then on, and the UI
caches the model under the new key so every later request 404s. The
old validator did not close this: a *valid* but different key poisoned
the row just the same. Gating on the key changing accepts the form's
echo and closes the write.
- `_probe()` checks the key. It is the one place a caller-supplied key
becomes a record's key, and unlike the previous check in
`install_path()` it also covers `register_path()`, i.e. every in-place
install. External registration builds its config directly, so it
carries its own check.
- `install_model` keeps a route-level check so a bad key is a 422 rather
than a job that fails later.
- `migration_24` leaves a model in place rather than `mkdir`+`rename`ing
it into `models_dir / key`, which was the one join in the repo with no
containment check and takes its key from the stored blob.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* test(model-images): cover legacy POSIX keys
* fix(model-images): keep existing covers for keys that are legal on posix
The store's join guard used `is_plain_filename`, which applies Windows' rules on
every platform. That is the right gate for a key we are about to accept, but no
released version checked model keys, and on posix `: ? * | < > " \` and names
like `NUL` are ordinary filename characters. A model keyed `legacy:key` with a
cover on disk lost it on upgrade: `get_url()` returned None and the image
PATCH/DELETE routes answered 404, so it could not be replaced or removed.
Add `is_contained_filename`, which checks containment on the running platform
only: exactly `is_plain_filename` on Windows, and no empty name, `.`, `..`, `/` or
control character on posix. Use it where a join addresses data that may already
exist - the model-images store and migration_24's relocation. New keys still go
through `is_plain_filename` at every entry point (install route, `_probe`,
external registration, `install_path`), so nothing new can be stored under a
name some deployment could not hold.
Store tests now run the Windows payloads with Windows rules forced, so a posix
CI run still covers them, alongside the posix-escaping subset and the legacy
names that must keep working.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* fix(model-images): encode model keys in URLs; cover migration_24's guard
Follow-ups from self-review of the posix-containment change:
- A legacy posix key can hold URL syntax. With `X?y` now served, its cover URL
addressed `GET /i/X` and the delete-image button sent `DELETE /i/X?y/image`,
which routes to `DELETE /i/{key}` for model `X`. Percent-encode the key in
`get_model_image_url` and in every model route the frontend builds.
- `get_url()` returns None when `exists()` raises (ENAMETOOLONG for an
over-long stored key), instead of failing the whole model list.
- Test migration_24's relocation guard: an escaping key stays in place, a
Windows-only key stays in place under Windows rules, and a legacy posix key is
normalized like any other.
- Skip the forced-posix legacy-key store test on Windows, where `get_path`
builds a real WindowsPath that splits `a\b`; drop `trailing.`, which the
strict predicate accepts anyway.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
Co-authored-by: JPPhoto <[email protected]>
387 lines
16 KiB
Python
387 lines
16 KiB
Python
import tempfile
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from threading import Barrier
|
|
|
|
import pytest
|
|
import torch
|
|
|
|
from invokeai.app.services.object_serializer.object_serializer_common import (
|
|
InvalidObjectNameError,
|
|
ObjectNotFoundError,
|
|
)
|
|
from invokeai.app.services.object_serializer.object_serializer_disk import ObjectSerializerDisk
|
|
from invokeai.app.services.object_serializer.object_serializer_forward_cache import ObjectSerializerForwardCache
|
|
|
|
|
|
@dataclass
|
|
class MockDataclass:
|
|
foo: str
|
|
|
|
|
|
def count_files(path: Path):
|
|
return len(list(path.iterdir()))
|
|
|
|
|
|
@pytest.fixture
|
|
def obj_serializer(tmp_path: Path):
|
|
return ObjectSerializerDisk[MockDataclass](tmp_path, safe_globals=[MockDataclass])
|
|
|
|
|
|
@pytest.fixture
|
|
def fwd_cache(tmp_path: Path):
|
|
return ObjectSerializerForwardCache(
|
|
ObjectSerializerDisk[MockDataclass](tmp_path, safe_globals=[MockDataclass]), max_cache_size=2
|
|
)
|
|
|
|
|
|
def test_obj_serializer_disk_initializes(tmp_path: Path):
|
|
obj_serializer = ObjectSerializerDisk[MockDataclass](tmp_path, safe_globals=[MockDataclass])
|
|
assert obj_serializer._output_dir == tmp_path
|
|
|
|
|
|
def test_obj_serializer_disk_saves(obj_serializer: ObjectSerializerDisk[MockDataclass]):
|
|
obj_1 = MockDataclass(foo="bar")
|
|
obj_1_name = obj_serializer.save(obj_1)
|
|
assert Path(obj_serializer._output_dir, obj_1_name).exists()
|
|
|
|
obj_2 = MockDataclass(foo="baz")
|
|
obj_2_name = obj_serializer.save(obj_2)
|
|
assert Path(obj_serializer._output_dir, obj_2_name).exists()
|
|
|
|
|
|
def test_obj_serializer_disk_loads(obj_serializer: ObjectSerializerDisk[MockDataclass]):
|
|
obj_1 = MockDataclass(foo="bar")
|
|
obj_1_name = obj_serializer.save(obj_1)
|
|
assert obj_serializer.load(obj_1_name).foo == "bar"
|
|
|
|
obj_2 = MockDataclass(foo="baz")
|
|
obj_2_name = obj_serializer.save(obj_2)
|
|
assert obj_serializer.load(obj_2_name).foo == "baz"
|
|
|
|
with pytest.raises(ObjectNotFoundError):
|
|
obj_serializer.load("nonexistent_object_name")
|
|
|
|
|
|
def test_obj_serializer_disk_deletes(obj_serializer: ObjectSerializerDisk[MockDataclass]):
|
|
obj_1 = MockDataclass(foo="bar")
|
|
obj_1_name = obj_serializer.save(obj_1)
|
|
|
|
obj_2 = MockDataclass(foo="bar")
|
|
obj_2_name = obj_serializer.save(obj_2)
|
|
|
|
obj_serializer.delete(obj_1_name)
|
|
assert not Path(obj_serializer._output_dir, obj_1_name).exists()
|
|
assert Path(obj_serializer._output_dir, obj_2_name).exists()
|
|
|
|
|
|
def test_obj_serializer_ephemeral_creates_tempdir(tmp_path: Path):
|
|
obj_serializer = ObjectSerializerDisk[MockDataclass](tmp_path, safe_globals=[MockDataclass], ephemeral=True)
|
|
assert isinstance(obj_serializer._tempdir, tempfile.TemporaryDirectory)
|
|
assert obj_serializer._base_output_dir == tmp_path
|
|
assert obj_serializer._output_dir != tmp_path
|
|
assert obj_serializer._output_dir == Path(obj_serializer._tempdir.name)
|
|
|
|
|
|
def test_obj_serializer_ephemeral_deletes_tempdir(tmp_path: Path):
|
|
obj_serializer = ObjectSerializerDisk[MockDataclass](tmp_path, safe_globals=[MockDataclass], ephemeral=True)
|
|
tempdir_path = obj_serializer._output_dir
|
|
del obj_serializer
|
|
assert not tempdir_path.exists()
|
|
|
|
|
|
def test_obj_serializer_ephemeral_deletes_tempdir_on_stop(tmp_path: Path):
|
|
obj_serializer = ObjectSerializerDisk[MockDataclass](tmp_path, safe_globals=[MockDataclass], ephemeral=True)
|
|
tempdir_path = obj_serializer._output_dir
|
|
obj_serializer.stop(None) # pyright: ignore [reportArgumentType]
|
|
assert not tempdir_path.exists()
|
|
|
|
|
|
def test_obj_serializer_ephemeral_writes_to_tempdir(tmp_path: Path):
|
|
obj_serializer = ObjectSerializerDisk[MockDataclass](tmp_path, safe_globals=[MockDataclass], ephemeral=True)
|
|
obj_1 = MockDataclass(foo="bar")
|
|
obj_1_name = obj_serializer.save(obj_1)
|
|
assert Path(obj_serializer._output_dir, obj_1_name).exists()
|
|
assert not Path(tmp_path, obj_1_name).exists()
|
|
|
|
|
|
def test_obj_serializer_ephemeral_deletes_dangling_tempdirs_on_init(tmp_path: Path):
|
|
tempdir = tmp_path / "tmpdir"
|
|
tempdir.mkdir()
|
|
ObjectSerializerDisk[MockDataclass](tmp_path, safe_globals=[MockDataclass], ephemeral=True)
|
|
assert not tempdir.exists()
|
|
|
|
|
|
def test_obj_serializer_does_not_delete_tempdirs_on_init(tmp_path: Path):
|
|
tempdir = tmp_path / "tmpdir"
|
|
tempdir.mkdir()
|
|
ObjectSerializerDisk[MockDataclass](tmp_path, safe_globals=[MockDataclass], ephemeral=False)
|
|
assert tempdir.exists()
|
|
|
|
|
|
def test_obj_serializer_disk_different_types(tmp_path: Path):
|
|
obj_serializer_1 = ObjectSerializerDisk[MockDataclass](tmp_path, safe_globals=[MockDataclass])
|
|
obj_1 = MockDataclass(foo="bar")
|
|
obj_1_name = obj_serializer_1.save(obj_1)
|
|
obj_1_loaded = obj_serializer_1.load(obj_1_name)
|
|
assert obj_serializer_1._obj_class_name == "MockDataclass"
|
|
assert isinstance(obj_1_loaded, MockDataclass)
|
|
assert obj_1_loaded.foo == "bar"
|
|
assert obj_1_name.startswith("MockDataclass_")
|
|
|
|
obj_serializer_2 = ObjectSerializerDisk[int](tmp_path, safe_globals=[int])
|
|
obj_2_name = obj_serializer_2.save(9001)
|
|
assert obj_serializer_2._obj_class_name == "int"
|
|
assert obj_serializer_2.load(obj_2_name) == 9001
|
|
assert obj_2_name.startswith("int_")
|
|
|
|
obj_serializer_3 = ObjectSerializerDisk[str](tmp_path, safe_globals=[str])
|
|
obj_3_name = obj_serializer_3.save("foo")
|
|
assert obj_serializer_3._obj_class_name == "str"
|
|
assert obj_serializer_3.load(obj_3_name) == "foo"
|
|
assert obj_3_name.startswith("str_")
|
|
|
|
obj_serializer_4 = ObjectSerializerDisk[torch.Tensor](tmp_path, safe_globals=[torch.Tensor])
|
|
obj_4_name = obj_serializer_4.save(torch.tensor([1, 2, 3]))
|
|
obj_4_loaded = obj_serializer_4.load(obj_4_name)
|
|
assert obj_serializer_4._obj_class_name == "Tensor"
|
|
assert isinstance(obj_4_loaded, torch.Tensor)
|
|
assert torch.equal(obj_4_loaded, torch.tensor([1, 2, 3]))
|
|
assert obj_4_name.startswith("Tensor_")
|
|
|
|
|
|
def test_obj_serializer_fwd_cache_initializes(obj_serializer: ObjectSerializerDisk[MockDataclass]):
|
|
fwd_cache = ObjectSerializerForwardCache(obj_serializer)
|
|
assert fwd_cache._underlying_storage == obj_serializer
|
|
|
|
|
|
def test_obj_serializer_fwd_cache_saves_and_loads(fwd_cache: ObjectSerializerForwardCache[MockDataclass]):
|
|
obj = MockDataclass(foo="bar")
|
|
obj_name = fwd_cache.save(obj)
|
|
obj_loaded = fwd_cache.load(obj_name)
|
|
obj_underlying = fwd_cache._underlying_storage.load(obj_name)
|
|
assert obj_loaded == obj_underlying
|
|
assert obj_loaded.foo == "bar"
|
|
|
|
|
|
def test_obj_serializer_fwd_cache_respects_cache_size(fwd_cache: ObjectSerializerForwardCache[MockDataclass]):
|
|
obj_1 = MockDataclass(foo="bar")
|
|
obj_1_name = fwd_cache.save(obj_1)
|
|
obj_2 = MockDataclass(foo="baz")
|
|
obj_2_name = fwd_cache.save(obj_2)
|
|
obj_3 = MockDataclass(foo="qux")
|
|
obj_3_name = fwd_cache.save(obj_3)
|
|
assert obj_1_name not in fwd_cache._cache
|
|
assert obj_2_name in fwd_cache._cache
|
|
assert obj_3_name in fwd_cache._cache
|
|
# apparently qsize is "not reliable"?
|
|
assert fwd_cache._cache_ids.qsize() == 2
|
|
|
|
|
|
def test_obj_serializer_fwd_cache_calls_delete_callback(fwd_cache: ObjectSerializerForwardCache[MockDataclass]):
|
|
called_name = None
|
|
obj_1 = MockDataclass(foo="bar")
|
|
|
|
def on_deleted(name: str):
|
|
nonlocal called_name
|
|
called_name = name
|
|
|
|
fwd_cache.on_deleted(on_deleted)
|
|
obj_1_name = fwd_cache.save(obj_1)
|
|
fwd_cache.delete(obj_1_name)
|
|
assert called_name == obj_1_name
|
|
|
|
|
|
def test_obj_serializer_fwd_cache_removes_deleted_ids_from_eviction_queue(
|
|
fwd_cache: ObjectSerializerForwardCache[MockDataclass],
|
|
):
|
|
obj_1_name = fwd_cache.save(MockDataclass(foo="bar"))
|
|
obj_2_name = fwd_cache.save(MockDataclass(foo="baz"))
|
|
fwd_cache.delete(obj_1_name)
|
|
|
|
obj_3_name = fwd_cache.save(MockDataclass(foo="qux"))
|
|
|
|
assert obj_1_name not in fwd_cache._cache
|
|
assert obj_2_name in fwd_cache._cache
|
|
assert obj_3_name in fwd_cache._cache
|
|
assert fwd_cache._cache_ids.qsize() == 2
|
|
|
|
|
|
def test_obj_serializer_fwd_cache_stays_bounded_when_deletes_leave_stale_ids(
|
|
fwd_cache: ObjectSerializerForwardCache[MockDataclass],
|
|
):
|
|
# A stale id consumes one eviction slot without freeing an entry. That must not let the queue or the
|
|
# cache grow past `max_cache_size`, and the slots must drain as new objects arrive.
|
|
for i in range(10):
|
|
fwd_cache.delete(fwd_cache.save(MockDataclass(foo=f"deleted-{i}")))
|
|
assert len(fwd_cache._cache) <= fwd_cache._max_cache_size
|
|
assert fwd_cache._cache_ids.qsize() <= fwd_cache._max_cache_size
|
|
|
|
# Once saves stop being deleted the cache refills to capacity, and eviction still drops the oldest live
|
|
# entries rather than being thrown off by the stale ids ahead of them in the queue.
|
|
names = [fwd_cache.save(MockDataclass(foo=f"live-{i}")) for i in range(4)]
|
|
|
|
assert set(fwd_cache._cache) == set(names[-fwd_cache._max_cache_size :])
|
|
|
|
|
|
def test_obj_serializer_fwd_cache_delete_failure_leaves_cache_and_listeners_untouched(
|
|
fwd_cache: ObjectSerializerForwardCache[MockDataclass], monkeypatch: pytest.MonkeyPatch
|
|
):
|
|
# If the underlying delete fails (the realistic Windows case: the file is still open elsewhere), the
|
|
# object is still there. Listeners must not be told otherwise -- `MemoryInvocationCache._delete_by_match`
|
|
# is registered on `on_deleted` and would purge cached invocation outputs for a live object.
|
|
called_names: list[str] = []
|
|
fwd_cache.on_deleted(called_names.append)
|
|
obj_name = fwd_cache.save(MockDataclass(foo="bar"))
|
|
|
|
def raise_permission_error(name: str) -> None:
|
|
raise PermissionError(name)
|
|
|
|
monkeypatch.setattr(fwd_cache._underlying_storage, "delete", raise_permission_error)
|
|
|
|
with pytest.raises(PermissionError):
|
|
fwd_cache.delete(obj_name)
|
|
|
|
assert called_names == []
|
|
assert obj_name in fwd_cache._cache
|
|
|
|
|
|
def test_obj_serializer_fwd_cache_preserves_fifo_order_after_deletion(tmp_path: Path):
|
|
fwd_cache = ObjectSerializerForwardCache(
|
|
ObjectSerializerDisk[MockDataclass](tmp_path, safe_globals=[MockDataclass]), max_cache_size=3
|
|
)
|
|
obj_1_name = fwd_cache.save(MockDataclass(foo="one"))
|
|
obj_2_name = fwd_cache.save(MockDataclass(foo="two"))
|
|
obj_3_name = fwd_cache.save(MockDataclass(foo="three"))
|
|
fwd_cache.delete(obj_2_name)
|
|
obj_4_name = fwd_cache.save(MockDataclass(foo="four"))
|
|
obj_5_name = fwd_cache.save(MockDataclass(foo="five"))
|
|
|
|
assert obj_1_name not in fwd_cache._cache
|
|
assert obj_2_name not in fwd_cache._cache
|
|
assert obj_3_name in fwd_cache._cache
|
|
assert obj_4_name in fwd_cache._cache
|
|
assert obj_5_name in fwd_cache._cache
|
|
assert fwd_cache._cache_ids.qsize() == 3
|
|
|
|
|
|
def test_obj_serializer_fwd_cache_delete_of_evicted_object_is_noop(
|
|
fwd_cache: ObjectSerializerForwardCache[MockDataclass],
|
|
):
|
|
obj_1_name = fwd_cache.save(MockDataclass(foo="one"))
|
|
obj_2_name = fwd_cache.save(MockDataclass(foo="two"))
|
|
obj_3_name = fwd_cache.save(MockDataclass(foo="three"))
|
|
cache_before_delete = fwd_cache._cache.copy()
|
|
queue_size_before_delete = fwd_cache._cache_ids.qsize()
|
|
|
|
fwd_cache.delete(obj_1_name)
|
|
|
|
assert fwd_cache._cache == cache_before_delete
|
|
assert fwd_cache._cache_ids.qsize() == queue_size_before_delete
|
|
assert obj_2_name in fwd_cache._cache
|
|
assert obj_3_name in fwd_cache._cache
|
|
|
|
|
|
def test_obj_serializer_fwd_cache_concurrent_cache_misses_are_not_serialized(
|
|
fwd_cache: ObjectSerializerForwardCache[MockDataclass], monkeypatch: pytest.MonkeyPatch
|
|
):
|
|
# `load()` must not hold `_cache_lock` across the underlying read. One serializer instance is shared by
|
|
# every session-processor worker (`dependencies.py` builds a single `tensors` and `conditioning` cache),
|
|
# so widening the critical section would make a cache miss on one GPU block the others for a full
|
|
# `torch.load`. The barrier only trips if all four reads are in flight at once, which makes this a
|
|
# deadlock rather than a timing assertion.
|
|
names = [fwd_cache.save(MockDataclass(foo=f"obj-{i}")) for i in range(4)]
|
|
fwd_cache._cache.clear() # force every load below to miss
|
|
barrier = Barrier(len(names), timeout=10)
|
|
underlying_load = fwd_cache._underlying_storage.load
|
|
|
|
def load_through_barrier(name: str) -> MockDataclass:
|
|
barrier.wait()
|
|
return underlying_load(name)
|
|
|
|
monkeypatch.setattr(fwd_cache._underlying_storage, "load", load_through_barrier)
|
|
|
|
with ThreadPoolExecutor(max_workers=len(names)) as executor:
|
|
futures = [executor.submit(fwd_cache.load, name) for name in names]
|
|
loaded = [future.result(timeout=10) for future in futures]
|
|
|
|
assert {obj.foo for obj in loaded} == {f"obj-{i}" for i in range(len(names))}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"name",
|
|
[
|
|
"../escaped",
|
|
"../../../../etc/passwd",
|
|
"sub/nested",
|
|
"/etc/passwd",
|
|
"..",
|
|
".",
|
|
"",
|
|
"foo/../../bar",
|
|
# Windows-shaped payloads must be rejected on posix too, so a posix-only CI run still covers them.
|
|
"..\\escaped",
|
|
"sub\\nested",
|
|
"C:\\Windows\\win.ini",
|
|
"C:relative",
|
|
"nul\x00byte",
|
|
],
|
|
)
|
|
def test_obj_serializer_disk_rejects_traversal_names(obj_serializer: ObjectSerializerDisk[MockDataclass], name: str):
|
|
"""Object names arrive from the client inside node fields (e.g. `TensorField.tensor_name`), so a name that is
|
|
not a plain filename must never be joined onto the output directory."""
|
|
with pytest.raises(InvalidObjectNameError):
|
|
obj_serializer.load(name)
|
|
with pytest.raises(InvalidObjectNameError):
|
|
obj_serializer.delete(name)
|
|
with pytest.raises(InvalidObjectNameError):
|
|
obj_serializer._get_path(name)
|
|
|
|
|
|
def test_obj_serializer_disk_traversal_name_does_not_read_outside_dir(tmp_path: Path):
|
|
"""The end-to-end shape of the reported issue: a file outside the store, named by a `..` traversal, must not
|
|
be deserialized."""
|
|
store = tmp_path / "store"
|
|
store.mkdir()
|
|
secret = tmp_path / "secret.pt"
|
|
torch.save(torch.ones((2, 2), dtype=torch.bool), secret)
|
|
|
|
obj_serializer = ObjectSerializerDisk[torch.Tensor](store, safe_globals=[torch.Tensor])
|
|
with pytest.raises(InvalidObjectNameError):
|
|
obj_serializer.load(f"../{secret.name}")
|
|
|
|
|
|
def test_obj_serializer_disk_accepts_generated_names(obj_serializer: ObjectSerializerDisk[MockDataclass]):
|
|
"""The guard must not reject the names the serializer itself hands out."""
|
|
name = obj_serializer.save(MockDataclass(foo="bar"))
|
|
assert obj_serializer._get_path(name).parent == obj_serializer._output_dir
|
|
assert obj_serializer.load(name).foo == "bar"
|
|
|
|
|
|
def test_obj_serializer_fwd_cache_rejects_traversal_names(fwd_cache: ObjectSerializerForwardCache[MockDataclass]):
|
|
"""The forward cache must not launder a traversal name past the disk serializer's guard."""
|
|
with pytest.raises(InvalidObjectNameError):
|
|
fwd_cache.load("../escaped")
|
|
with pytest.raises(InvalidObjectNameError):
|
|
fwd_cache.delete("../escaped")
|
|
|
|
|
|
class UnregisteredClass:
|
|
"""Deliberately never passed to `add_safe_globals`."""
|
|
|
|
def __init__(self) -> None:
|
|
self.foo = "bar"
|
|
|
|
|
|
def test_obj_serializer_disk_does_not_unpickle_arbitrary_objects(tmp_path: Path):
|
|
"""Object names are untrusted, so even a name that stays inside the store must not reach a full unpickle: a
|
|
file planted in the store (or swapped for one) must not be able to instantiate arbitrary classes."""
|
|
obj_serializer = ObjectSerializerDisk[MockDataclass](tmp_path, safe_globals=[MockDataclass])
|
|
name = "planted"
|
|
torch.save(UnregisteredClass(), tmp_path / name)
|
|
|
|
with pytest.raises(Exception) as exc_info:
|
|
obj_serializer.load(name)
|
|
assert "UnregisteredClass" in str(exc_info.value) or "weights_only" in str(exc_info.value)
|