5cbd3f29e3
Fuzz / Run fuzz harnesses (${{ github.event_name == 'schedule' && 'nightly' || 'smoke' }}) (push) Has been cancelled
Create Releases / call-mac (push) Has been cancelled
Create Releases / call-linux (push) Has been cancelled
Create Releases / call-sdist (push) Has been cancelled
Create Releases / call-win (push) Has been cancelled
Create Releases / call-pyodide (push) Has been cancelled
Windows_No_Exception_CI / build (x64, 3.10) (push) Has been cancelled
Check URLs / build (push) Has been cancelled
Create Releases / Attest CI build artifacts (push) Has been cancelled
Create Releases / Check for Publish release build to pypi (push) Has been cancelled
Create Releases / Check for Publish preview build to test.pypi-weekly (push) Has been cancelled
Create Releases / Publish preview build to test.pypi-weekly (push) Has been cancelled
Create Releases / Check for Publish release build to test.pypi (rc-candidates) (push) Has been cancelled
Create Releases / Publish release build to test.pypi (push) Has been cancelled
Create Releases / Check for Publish preview build to pypi-weekly (push) Has been cancelled
Create Releases / Publish preview build to pypi-weekly (push) Has been cancelled
Create Releases / Publish release build to pypi (push) Has been cancelled
Create Releases / test source distribution (push) Has been cancelled
clang-tidy / clang-tidy (push) Has been cancelled
Lint / Validate SBOM (push) Has been cancelled
Lint / Enforce style (push) Has been cancelled
CI / Test windows-2022, 3.14, External, debug=0, unity_build=0, onnx_ml=1, autogen=0 (push) Has been cancelled
CI / Test windows-latest, 3.10, Internal, debug=0, unity_build=0, onnx_ml=1, autogen=0 (push) Has been cancelled
CI / Test windows-latest, 3.14, Internal, debug=0, unity_build=0, onnx_ml=1, autogen=0 (push) Has been cancelled
CI / Test windows-latest, 3.14t, Internal, debug=0, unity_build=0, onnx_ml=1, autogen=0 (push) Has been cancelled
CI / Test ubuntu-24.04, 3.14, Internal, debug=1, unity_build=0, onnx_ml=1, autogen=0 (push) Has been cancelled
CI / Test ubuntu-24.04, 3.14, External, debug=0, unity_build=1, onnx_ml=1, autogen=1 (push) Has been cancelled
CI / Test ubuntu-24.04, 3.14, External, debug=0, unity_build=0, onnx_ml=0, autogen=0 (push) Has been cancelled
CI / Test macos-latest, 3.10, Internal, debug=0, unity_build=0, onnx_ml=1, autogen=0 (push) Has been cancelled
CI / Test macos-latest, 3.14, Internal, debug=0, unity_build=0, onnx_ml=1, autogen=0 (push) Has been cancelled
CI / Test macos-latest, 3.14t, Internal, debug=0, unity_build=0, onnx_ml=1, autogen=0 (push) Has been cancelled
CI / Test ubuntu-24.04, 3.14, External, debug=0, unity_build=0, onnx_ml=1, autogen=0 (push) Has been cancelled
CI / Test ubuntu-24.04, 3.10, Internal, debug=0, unity_build=0, onnx_ml=1, autogen=0 (push) Has been cancelled
CI / Test ubuntu-24.04, 3.14, Internal, debug=0, unity_build=0, onnx_ml=1, autogen=0 (push) Has been cancelled
CI / Test ubuntu-24.04, 3.14t, Internal, debug=0, unity_build=0, onnx_ml=1, autogen=0 (push) Has been cancelled
Pixi CI / Install and lint (ubuntu-24.04-arm) (push) Has been cancelled
Pixi CI / Install and lint (windows-2022) (push) Has been cancelled
Pixi CI / Xcode generator build (push) Has been cancelled
Pixi CI / Install and test (macos-latest, default) (push) Has been cancelled
Pixi CI / Install and test (ubuntu-24.04-arm, default) (push) Has been cancelled
Pixi CI / Install and test (ubuntu-latest, default) (push) Has been cancelled
Pixi CI / Install and test (windows-2022, default) (push) Has been cancelled
Pixi CI / Install and test (macos-latest, oldies) (push) Has been cancelled
Pixi CI / Install and test (ubuntu-24.04-arm, oldies) (push) Has been cancelled
Pixi CI / Install and test (ubuntu-latest, oldies) (push) Has been cancelled
Pixi CI / Install and test (windows-2022, oldies) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (cpp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Copilot Setup Steps / copilot-setup-steps (push) Has been cancelled
Generate and publish ONNX docs / build (push) Has been cancelled
Generate and publish ONNX docs / deploy (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
1290 lines
50 KiB
Python
1290 lines
50 KiB
Python
# Copyright (c) ONNX Project Contributors
|
|
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import pathlib
|
|
import shutil
|
|
import uuid
|
|
import warnings
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
import onnx
|
|
from onnx import (
|
|
ModelProto,
|
|
NodeProto,
|
|
TensorProto,
|
|
checker,
|
|
helper,
|
|
parser,
|
|
shape_inference,
|
|
)
|
|
from onnx.external_data_helper import (
|
|
_ALLOWED_EXTERNAL_DATA_KEYS,
|
|
ExternalDataInfo,
|
|
convert_model_from_external_data,
|
|
convert_model_to_external_data,
|
|
load_external_data_for_model,
|
|
load_external_data_for_tensor,
|
|
save_external_data,
|
|
set_external_data,
|
|
)
|
|
from onnx.numpy_helper import from_array, to_array
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Sequence
|
|
from pathlib import Path
|
|
|
|
|
|
class TestLoadExternalDataBase:
|
|
"""Base class for testing external data related behaviors.
|
|
|
|
Subclasses should be parameterized with a serialization format.
|
|
"""
|
|
|
|
serialization_format: str = "protobuf"
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def setup(self, tmp_path: Path):
|
|
self.temp_dir = str(tmp_path)
|
|
self.initializer_value = np.arange(6).reshape(3, 2).astype(np.float32) + 512
|
|
self.attribute_value = np.arange(6).reshape(2, 3).astype(np.float32) + 256
|
|
self.model_filename = self.create_test_model()
|
|
|
|
def get_temp_model_filename(self) -> str:
|
|
return os.path.join(self.temp_dir, str(uuid.uuid4()) + ".onnx")
|
|
|
|
def create_external_data_tensor(
|
|
self, value: list[Any], tensor_name: str, location: str = ""
|
|
) -> TensorProto:
|
|
tensor = from_array(np.array(value))
|
|
tensor.name = tensor_name
|
|
tensor_filename = location or f"{tensor_name}.bin"
|
|
set_external_data(tensor, location=tensor_filename)
|
|
|
|
with open(os.path.join(self.temp_dir, tensor_filename), "wb") as data_file:
|
|
data_file.write(tensor.raw_data)
|
|
tensor.ClearField("raw_data")
|
|
tensor.data_location = onnx.TensorProto.EXTERNAL
|
|
return tensor
|
|
|
|
def create_test_model(self, location: str = "") -> str:
|
|
constant_node = onnx.helper.make_node(
|
|
"Constant",
|
|
inputs=[],
|
|
outputs=["values"],
|
|
value=self.create_external_data_tensor(
|
|
self.attribute_value,
|
|
"attribute_value",
|
|
),
|
|
)
|
|
|
|
initializers = [
|
|
self.create_external_data_tensor(
|
|
self.initializer_value,
|
|
"input_value",
|
|
location,
|
|
)
|
|
]
|
|
inputs = [
|
|
helper.make_tensor_value_info(
|
|
"input_value", onnx.TensorProto.FLOAT, self.initializer_value.shape
|
|
)
|
|
]
|
|
|
|
graph = helper.make_graph(
|
|
[constant_node],
|
|
"test_graph",
|
|
inputs=inputs,
|
|
outputs=[],
|
|
initializer=initializers,
|
|
)
|
|
model = helper.make_model(graph)
|
|
|
|
model_filename = os.path.join(self.temp_dir, "model.onnx")
|
|
onnx.save_model(model, model_filename, self.serialization_format)
|
|
|
|
return model_filename
|
|
|
|
def test_check_model(self) -> None:
|
|
if self.serialization_format != "protobuf":
|
|
pytest.skip(
|
|
"check_model supports protobuf only as binary when provided as a path"
|
|
)
|
|
checker.check_model(self.model_filename)
|
|
|
|
|
|
class TestLoadExternalData(TestLoadExternalDataBase):
|
|
@pytest.fixture(scope="class", params=["protobuf", "textproto"], autouse=True)
|
|
def override_serialization_format(self, request):
|
|
# Override the class' `serialization_format`.
|
|
# This is not idiomatic pytest code which would structure all
|
|
# these dependencies as explicit fixtures rather than setting
|
|
# state on `self`. The code is as it is because it was
|
|
# inherited from an earlier unittest/parameterized setup.
|
|
self.serialization_format = request.param
|
|
|
|
def test_load_external_data(self) -> None:
|
|
model = onnx.load_model(self.model_filename, self.serialization_format)
|
|
initializer_tensor = model.graph.initializer[0]
|
|
np.testing.assert_allclose(to_array(initializer_tensor), self.initializer_value)
|
|
|
|
attribute_tensor = model.graph.node[0].attribute[0].t
|
|
np.testing.assert_allclose(to_array(attribute_tensor), self.attribute_value)
|
|
|
|
def test_load_external_data_for_model(self) -> None:
|
|
model = onnx.load_model(
|
|
self.model_filename, self.serialization_format, load_external_data=False
|
|
)
|
|
load_external_data_for_model(model, self.temp_dir)
|
|
initializer_tensor = model.graph.initializer[0]
|
|
np.testing.assert_allclose(to_array(initializer_tensor), self.initializer_value)
|
|
|
|
attribute_tensor = model.graph.node[0].attribute[0].t
|
|
np.testing.assert_allclose(to_array(attribute_tensor), self.attribute_value)
|
|
|
|
def test_save_external_data(self) -> None:
|
|
model = onnx.load_model(self.model_filename, self.serialization_format)
|
|
|
|
temp_dir = os.path.join(self.temp_dir, "save_copy")
|
|
os.mkdir(temp_dir)
|
|
new_model_filename = os.path.join(temp_dir, "model.onnx")
|
|
onnx.save_model(model, new_model_filename, self.serialization_format)
|
|
|
|
new_model = onnx.load_model(new_model_filename, self.serialization_format)
|
|
initializer_tensor = new_model.graph.initializer[0]
|
|
np.testing.assert_allclose(to_array(initializer_tensor), self.initializer_value)
|
|
|
|
attribute_tensor = new_model.graph.node[0].attribute[0].t
|
|
np.testing.assert_allclose(to_array(attribute_tensor), self.attribute_value)
|
|
|
|
|
|
class TestLoadExternalDataSingleFile(TestLoadExternalDataBase):
|
|
@pytest.fixture(scope="class", params=["protobuf", "textproto"], autouse=True)
|
|
def override_serialization_format(self, request):
|
|
# Override the class' `serialization_format`.
|
|
# This is not idiomatic pytest code which would structure all
|
|
# these dependencies as explicit fixtures rather than setting
|
|
# state on `self`. The code is as it is because it was
|
|
# inherited from an earlier unittest/parameterized setup.
|
|
self.serialization_format = request.param
|
|
|
|
def create_external_data_tensors(
|
|
self, tensors_data: list[tuple[list[Any], Any]]
|
|
) -> list[TensorProto]:
|
|
tensor_filename = "tensors.bin"
|
|
tensors = []
|
|
|
|
with open(os.path.join(self.temp_dir, tensor_filename), "ab") as data_file:
|
|
for value, tensor_name in tensors_data:
|
|
tensor = from_array(np.array(value))
|
|
offset = data_file.tell()
|
|
if offset % 4096 != 0:
|
|
data_file.write(b"\0" * (4096 - offset % 4096))
|
|
offset = offset + 4096 - offset % 4096
|
|
|
|
data_file.write(tensor.raw_data)
|
|
set_external_data(
|
|
tensor,
|
|
location=tensor_filename,
|
|
offset=offset,
|
|
length=data_file.tell() - offset,
|
|
)
|
|
tensor.name = tensor_name
|
|
tensor.ClearField("raw_data")
|
|
tensor.data_location = onnx.TensorProto.EXTERNAL
|
|
tensors.append(tensor)
|
|
|
|
return tensors
|
|
|
|
def test_load_external_single_file_data(self) -> None:
|
|
model = onnx.load_model(self.model_filename, self.serialization_format)
|
|
|
|
initializer_tensor = model.graph.initializer[0]
|
|
np.testing.assert_allclose(to_array(initializer_tensor), self.initializer_value)
|
|
|
|
attribute_tensor = model.graph.node[0].attribute[0].t
|
|
np.testing.assert_allclose(to_array(attribute_tensor), self.attribute_value)
|
|
|
|
def test_save_external_single_file_data(self) -> None:
|
|
model = onnx.load_model(self.model_filename, self.serialization_format)
|
|
|
|
temp_dir = os.path.join(self.temp_dir, "save_copy")
|
|
os.mkdir(temp_dir)
|
|
new_model_filename = os.path.join(temp_dir, "model.onnx")
|
|
onnx.save_model(model, new_model_filename, self.serialization_format)
|
|
|
|
new_model = onnx.load_model(new_model_filename, self.serialization_format)
|
|
initializer_tensor = new_model.graph.initializer[0]
|
|
np.testing.assert_allclose(to_array(initializer_tensor), self.initializer_value)
|
|
|
|
attribute_tensor = new_model.graph.node[0].attribute[0].t
|
|
np.testing.assert_allclose(to_array(attribute_tensor), self.attribute_value)
|
|
|
|
@pytest.mark.parametrize("use_absolute_path", (True, False))
|
|
def test_save_external_invalid_single_file_data_and_check(
|
|
self, use_absolute_path: bool
|
|
) -> None:
|
|
model = onnx.load_model(self.model_filename, self.serialization_format)
|
|
|
|
model_dir = os.path.join(self.temp_dir, "save_copy")
|
|
os.mkdir(model_dir)
|
|
|
|
traversal_external_data_dir = os.path.join(
|
|
self.temp_dir, "invalid_external_data"
|
|
)
|
|
os.mkdir(traversal_external_data_dir)
|
|
|
|
if use_absolute_path:
|
|
traversal_external_data_location = os.path.join(
|
|
traversal_external_data_dir, "tensors.bin"
|
|
)
|
|
else:
|
|
traversal_external_data_location = "../invalid_external_data/tensors.bin"
|
|
|
|
external_data_dir = os.path.join(self.temp_dir, "external_data")
|
|
os.mkdir(external_data_dir)
|
|
new_model_filepath = os.path.join(model_dir, "model.onnx")
|
|
|
|
def convert_model_to_external_data_no_check(model: ModelProto, location: str):
|
|
for tensor in model.graph.initializer:
|
|
if tensor.HasField("raw_data"):
|
|
set_external_data(tensor, location)
|
|
|
|
convert_model_to_external_data_no_check(
|
|
model,
|
|
location=traversal_external_data_location,
|
|
)
|
|
|
|
with pytest.raises(onnx.checker.ValidationError):
|
|
onnx.save_model(model, new_model_filepath, self.serialization_format)
|
|
|
|
|
|
@pytest.mark.parametrize("serialization_format", ["protobuf", "textproto"])
|
|
class TestSaveAllTensorsAsExternalData:
|
|
@pytest.fixture(autouse=True)
|
|
def setup(self, tmp_path, serialization_format: str):
|
|
self.serialization_format = serialization_format
|
|
self.temp_dir: str = str(tmp_path)
|
|
self.initializer_value = np.arange(6).reshape(3, 2).astype(np.float32) + 512
|
|
self.attribute_value = np.arange(6).reshape(2, 3).astype(np.float32) + 256
|
|
self.model = self.create_test_model_proto()
|
|
|
|
def get_temp_model_filename(self):
|
|
return os.path.join(self.temp_dir, str(uuid.uuid4()) + ".onnx")
|
|
|
|
def create_data_tensors(
|
|
self, tensors_data: list[tuple[list[Any], Any]]
|
|
) -> list[TensorProto]:
|
|
tensors = []
|
|
for value, tensor_name in tensors_data:
|
|
tensor = from_array(np.array(value))
|
|
tensor.name = tensor_name
|
|
tensors.append(tensor)
|
|
|
|
return tensors
|
|
|
|
def create_test_model_proto(self) -> ModelProto:
|
|
tensors = self.create_data_tensors(
|
|
[
|
|
(self.attribute_value, "attribute_value"),
|
|
(self.initializer_value, "input_value"),
|
|
]
|
|
)
|
|
|
|
constant_node = onnx.helper.make_node(
|
|
"Constant", inputs=[], outputs=["values"], value=tensors[0]
|
|
)
|
|
|
|
inputs = [
|
|
helper.make_tensor_value_info(
|
|
"input_value", onnx.TensorProto.FLOAT, self.initializer_value.shape
|
|
)
|
|
]
|
|
|
|
graph = helper.make_graph(
|
|
[constant_node],
|
|
"test_graph",
|
|
inputs=inputs,
|
|
outputs=[],
|
|
initializer=[tensors[1]],
|
|
)
|
|
return helper.make_model(graph)
|
|
|
|
def test_check_model(self) -> None:
|
|
if self.serialization_format != "protobuf":
|
|
pytest.skip("check_model supports protobuf only when provided as a path")
|
|
checker.check_model(self.model)
|
|
|
|
def test_convert_model_to_external_data_with_size_threshold(self) -> None:
|
|
model_file_path = self.get_temp_model_filename()
|
|
|
|
convert_model_to_external_data(self.model, size_threshold=1024)
|
|
onnx.save_model(self.model, model_file_path, self.serialization_format)
|
|
|
|
model = onnx.load_model(model_file_path, self.serialization_format)
|
|
initializer_tensor = model.graph.initializer[0]
|
|
assert not initializer_tensor.HasField("data_location")
|
|
|
|
def test_convert_model_to_external_data_without_size_threshold(self) -> None:
|
|
model_file_path = self.get_temp_model_filename()
|
|
convert_model_to_external_data(self.model, size_threshold=0)
|
|
onnx.save_model(self.model, model_file_path, self.serialization_format)
|
|
|
|
model = onnx.load_model(model_file_path, self.serialization_format)
|
|
initializer_tensor = model.graph.initializer[0]
|
|
assert initializer_tensor.HasField("data_location")
|
|
np.testing.assert_allclose(to_array(initializer_tensor), self.initializer_value)
|
|
|
|
def test_convert_model_to_external_data_from_one_file_with_location(self) -> None:
|
|
model_file_path = self.get_temp_model_filename()
|
|
external_data_file = str(uuid.uuid4())
|
|
|
|
convert_model_to_external_data(
|
|
self.model,
|
|
size_threshold=0,
|
|
all_tensors_to_one_file=True,
|
|
location=external_data_file,
|
|
)
|
|
onnx.save_model(self.model, model_file_path, self.serialization_format)
|
|
|
|
assert os.path.isfile(os.path.join(self.temp_dir, external_data_file))
|
|
|
|
model = onnx.load_model(model_file_path, self.serialization_format)
|
|
|
|
# test convert model from external data
|
|
convert_model_from_external_data(model)
|
|
model_file_path = self.get_temp_model_filename()
|
|
onnx.save_model(model, model_file_path, self.serialization_format)
|
|
model = onnx.load_model(model_file_path, self.serialization_format)
|
|
initializer_tensor = model.graph.initializer[0]
|
|
assert not len(initializer_tensor.external_data)
|
|
assert initializer_tensor.data_location == TensorProto.DEFAULT
|
|
np.testing.assert_allclose(to_array(initializer_tensor), self.initializer_value)
|
|
|
|
attribute_tensor = model.graph.node[0].attribute[0].t
|
|
assert not len(attribute_tensor.external_data)
|
|
assert attribute_tensor.data_location == TensorProto.DEFAULT
|
|
np.testing.assert_allclose(to_array(attribute_tensor), self.attribute_value)
|
|
|
|
def test_convert_model_to_external_data_from_one_file_without_location_uses_model_name(
|
|
self,
|
|
) -> None:
|
|
model_file_path = self.get_temp_model_filename()
|
|
|
|
convert_model_to_external_data(
|
|
self.model, size_threshold=0, all_tensors_to_one_file=True
|
|
)
|
|
onnx.save_model(self.model, model_file_path, self.serialization_format)
|
|
|
|
assert os.path.isfile(model_file_path)
|
|
assert os.path.isfile(os.path.join(self.temp_dir, model_file_path))
|
|
|
|
def test_convert_model_to_external_data_one_file_per_tensor_without_attribute(
|
|
self,
|
|
) -> None:
|
|
model_file_path = self.get_temp_model_filename()
|
|
|
|
convert_model_to_external_data(
|
|
self.model,
|
|
size_threshold=0,
|
|
all_tensors_to_one_file=False,
|
|
convert_attribute=False,
|
|
)
|
|
onnx.save_model(self.model, model_file_path, self.serialization_format)
|
|
|
|
assert os.path.isfile(model_file_path)
|
|
assert os.path.isfile(os.path.join(self.temp_dir, "input_value"))
|
|
assert not os.path.isfile(os.path.join(self.temp_dir, "attribute_value"))
|
|
|
|
def test_convert_model_to_external_data_one_file_per_tensor_with_attribute(
|
|
self,
|
|
) -> None:
|
|
model_file_path = self.get_temp_model_filename()
|
|
|
|
convert_model_to_external_data(
|
|
self.model,
|
|
size_threshold=0,
|
|
all_tensors_to_one_file=False,
|
|
convert_attribute=True,
|
|
)
|
|
onnx.save_model(self.model, model_file_path, self.serialization_format)
|
|
|
|
assert os.path.isfile(model_file_path)
|
|
assert os.path.isfile(os.path.join(self.temp_dir, "input_value"))
|
|
assert os.path.isfile(os.path.join(self.temp_dir, "attribute_value"))
|
|
|
|
def test_convert_model_to_external_data_does_not_convert_attribute_values(
|
|
self,
|
|
) -> None:
|
|
model_file_path = self.get_temp_model_filename()
|
|
|
|
convert_model_to_external_data(
|
|
self.model,
|
|
size_threshold=0,
|
|
convert_attribute=False,
|
|
all_tensors_to_one_file=False,
|
|
)
|
|
onnx.save_model(self.model, model_file_path, self.serialization_format)
|
|
|
|
assert os.path.isfile(os.path.join(self.temp_dir, "input_value"))
|
|
assert not os.path.isfile(os.path.join(self.temp_dir, "attribute_value"))
|
|
|
|
model = onnx.load_model(model_file_path, self.serialization_format)
|
|
initializer_tensor = model.graph.initializer[0]
|
|
assert initializer_tensor.HasField("data_location")
|
|
|
|
attribute_tensor = model.graph.node[0].attribute[0].t
|
|
assert not attribute_tensor.HasField("data_location")
|
|
|
|
def test_convert_model_to_external_data_converts_attribute_values(self) -> None:
|
|
model_file_path = self.get_temp_model_filename()
|
|
|
|
convert_model_to_external_data(
|
|
self.model, size_threshold=0, convert_attribute=True
|
|
)
|
|
onnx.save_model(self.model, model_file_path, self.serialization_format)
|
|
|
|
model = onnx.load_model(model_file_path, self.serialization_format)
|
|
|
|
initializer_tensor = model.graph.initializer[0]
|
|
np.testing.assert_allclose(to_array(initializer_tensor), self.initializer_value)
|
|
assert initializer_tensor.HasField("data_location")
|
|
|
|
attribute_tensor = model.graph.node[0].attribute[0].t
|
|
np.testing.assert_allclose(to_array(attribute_tensor), self.attribute_value)
|
|
assert attribute_tensor.HasField("data_location")
|
|
|
|
def test_save_model_does_not_convert_to_external_data_and_saves_the_model(
|
|
self,
|
|
) -> None:
|
|
model_file_path = self.get_temp_model_filename()
|
|
onnx.save_model(
|
|
self.model,
|
|
model_file_path,
|
|
self.serialization_format,
|
|
save_as_external_data=False,
|
|
)
|
|
assert os.path.isfile(model_file_path)
|
|
|
|
model = onnx.load_model(model_file_path, self.serialization_format)
|
|
initializer_tensor = model.graph.initializer[0]
|
|
assert not initializer_tensor.HasField("data_location")
|
|
|
|
attribute_tensor = model.graph.node[0].attribute[0].t
|
|
assert not attribute_tensor.HasField("data_location")
|
|
|
|
def test_save_model_does_convert_and_saves_the_model(self) -> None:
|
|
model_file_path = self.get_temp_model_filename()
|
|
onnx.save_model(
|
|
self.model,
|
|
model_file_path,
|
|
self.serialization_format,
|
|
save_as_external_data=True,
|
|
all_tensors_to_one_file=True,
|
|
location=None,
|
|
size_threshold=0,
|
|
convert_attribute=False,
|
|
)
|
|
|
|
model = onnx.load_model(model_file_path, self.serialization_format)
|
|
|
|
initializer_tensor = model.graph.initializer[0]
|
|
assert initializer_tensor.HasField("data_location")
|
|
np.testing.assert_allclose(to_array(initializer_tensor), self.initializer_value)
|
|
|
|
attribute_tensor = model.graph.node[0].attribute[0].t
|
|
assert not attribute_tensor.HasField("data_location")
|
|
np.testing.assert_allclose(to_array(attribute_tensor), self.attribute_value)
|
|
|
|
def test_save_model_without_loading_external_data(self) -> None:
|
|
model_file_path = self.get_temp_model_filename()
|
|
onnx.save_model(
|
|
self.model,
|
|
model_file_path,
|
|
self.serialization_format,
|
|
save_as_external_data=True,
|
|
location=None,
|
|
size_threshold=0,
|
|
convert_attribute=False,
|
|
)
|
|
# Save without load_external_data
|
|
model = onnx.load_model(
|
|
model_file_path, self.serialization_format, load_external_data=False
|
|
)
|
|
onnx.save_model(
|
|
model,
|
|
model_file_path,
|
|
self.serialization_format,
|
|
save_as_external_data=True,
|
|
location=None,
|
|
size_threshold=0,
|
|
convert_attribute=False,
|
|
)
|
|
# Load the saved model again; Only works if the saved path is under the same directory
|
|
model = onnx.load_model(model_file_path, self.serialization_format)
|
|
|
|
initializer_tensor = model.graph.initializer[0]
|
|
assert initializer_tensor.HasField("data_location")
|
|
np.testing.assert_allclose(to_array(initializer_tensor), self.initializer_value)
|
|
|
|
attribute_tensor = model.graph.node[0].attribute[0].t
|
|
assert not attribute_tensor.HasField("data_location")
|
|
np.testing.assert_allclose(to_array(attribute_tensor), self.attribute_value)
|
|
|
|
def test_save_model_with_existing_raw_data_should_override(self) -> None:
|
|
model_file_path = self.get_temp_model_filename()
|
|
original_raw_data = self.model.graph.initializer[0].raw_data
|
|
onnx.save_model(
|
|
self.model,
|
|
model_file_path,
|
|
self.serialization_format,
|
|
save_as_external_data=True,
|
|
size_threshold=0,
|
|
)
|
|
assert os.path.isfile(model_file_path)
|
|
|
|
model = onnx.load_model(
|
|
model_file_path, self.serialization_format, load_external_data=False
|
|
)
|
|
initializer_tensor = model.graph.initializer[0]
|
|
initializer_tensor.raw_data = b"dummpy_raw_data"
|
|
# If raw_data and external tensor exist at the same time, override existing raw_data
|
|
load_external_data_for_tensor(initializer_tensor, self.temp_dir)
|
|
assert initializer_tensor.raw_data == original_raw_data
|
|
|
|
|
|
@pytest.mark.parametrize("serialization_format", ["protobuf", "textproto"])
|
|
class TestExternalDataToArray:
|
|
@pytest.fixture(autouse=True)
|
|
def setup(self, tmp_path, serialization_format: str) -> None:
|
|
self.serialization_format = serialization_format
|
|
self.temp_dir = str(tmp_path)
|
|
self._model_file_path: str = os.path.join(self.temp_dir, "model.onnx")
|
|
self.large_data = np.random.rand(10, 60, 100).astype(np.float32)
|
|
self.small_data = (200, 300)
|
|
self.model = self.create_test_model()
|
|
|
|
@property
|
|
def model_file_path(self):
|
|
return self._model_file_path
|
|
|
|
def create_test_model(self) -> ModelProto:
|
|
X = helper.make_tensor_value_info("X", TensorProto.FLOAT, self.large_data.shape)
|
|
input_init = helper.make_tensor(
|
|
name="X",
|
|
data_type=TensorProto.FLOAT,
|
|
dims=self.large_data.shape,
|
|
vals=onnx.numpy_helper.tobytes_little_endian(self.large_data),
|
|
raw=True,
|
|
)
|
|
|
|
shape_data = np.array(self.small_data, np.int64)
|
|
shape_init = helper.make_tensor(
|
|
name="Shape",
|
|
data_type=TensorProto.INT64,
|
|
dims=shape_data.shape,
|
|
vals=onnx.numpy_helper.tobytes_little_endian(shape_data),
|
|
raw=True,
|
|
)
|
|
C = helper.make_tensor_value_info("C", TensorProto.INT64, self.small_data)
|
|
|
|
reshape = onnx.helper.make_node(
|
|
"Reshape",
|
|
inputs=["X", "Shape"],
|
|
outputs=["Y"],
|
|
)
|
|
cast = onnx.helper.make_node(
|
|
"Cast", inputs=["Y"], outputs=["C"], to=TensorProto.INT64
|
|
)
|
|
|
|
graph_def = helper.make_graph(
|
|
[reshape, cast],
|
|
"test-model",
|
|
[X],
|
|
[C],
|
|
initializer=[input_init, shape_init],
|
|
)
|
|
return helper.make_model(graph_def, producer_name="onnx-example")
|
|
|
|
def test_check_model(self) -> None:
|
|
if self.serialization_format != "protobuf":
|
|
pytest.skip("check_model supports protobuf only when provided as a path")
|
|
|
|
checker.check_model(self.model)
|
|
|
|
def test_reshape_inference_with_external_data_fail(self) -> None:
|
|
onnx.save_model(
|
|
self.model,
|
|
self.model_file_path,
|
|
self.serialization_format,
|
|
save_as_external_data=True,
|
|
all_tensors_to_one_file=False,
|
|
size_threshold=0,
|
|
)
|
|
model_without_external_data = onnx.load(
|
|
self.model_file_path, self.serialization_format, load_external_data=False
|
|
)
|
|
# Shape inference of Reshape uses ParseData
|
|
# ParseData cannot handle external data and should throw the error as follows:
|
|
# Cannot parse data from external tensors. Please load external data into raw data for tensor: Shape
|
|
with pytest.raises(shape_inference.InferenceError):
|
|
shape_inference.infer_shapes(
|
|
model_without_external_data,
|
|
strict_mode=True,
|
|
)
|
|
|
|
def test_to_array_with_external_data(self) -> None:
|
|
onnx.save_model(
|
|
self.model,
|
|
self.model_file_path,
|
|
self.serialization_format,
|
|
save_as_external_data=True,
|
|
all_tensors_to_one_file=False,
|
|
size_threshold=0,
|
|
)
|
|
# raw_data of external tensor is not loaded
|
|
model = onnx.load(
|
|
self.model_file_path, self.serialization_format, load_external_data=False
|
|
)
|
|
# Specify self.temp_dir to load external tensor
|
|
loaded_large_data = to_array(model.graph.initializer[0], self.temp_dir)
|
|
np.testing.assert_allclose(loaded_large_data, self.large_data)
|
|
|
|
def test_save_model_with_external_data_multiple_times(self) -> None:
|
|
# Test onnx.save should respectively handle typical tensor and external tensor properly
|
|
# 1st save: save two tensors which have raw_data
|
|
# Only w_large will be stored as external tensors since it's larger than 1024
|
|
onnx.save_model(
|
|
self.model,
|
|
self.model_file_path,
|
|
self.serialization_format,
|
|
save_as_external_data=True,
|
|
all_tensors_to_one_file=False,
|
|
location=None,
|
|
size_threshold=1024,
|
|
convert_attribute=True,
|
|
)
|
|
model_without_loading_external = onnx.load(
|
|
self.model_file_path, self.serialization_format, load_external_data=False
|
|
)
|
|
large_input_tensor = model_without_loading_external.graph.initializer[0]
|
|
assert large_input_tensor.HasField("data_location")
|
|
np.testing.assert_allclose(
|
|
to_array(large_input_tensor, self.temp_dir), self.large_data
|
|
)
|
|
|
|
small_shape_tensor = model_without_loading_external.graph.initializer[1]
|
|
assert not small_shape_tensor.HasField("data_location")
|
|
np.testing.assert_allclose(to_array(small_shape_tensor), self.small_data)
|
|
|
|
# 2nd save: one tensor has raw_data (small); one external tensor (large)
|
|
# Save them both as external tensors this time
|
|
onnx.save_model(
|
|
model_without_loading_external,
|
|
self.model_file_path,
|
|
self.serialization_format,
|
|
save_as_external_data=True,
|
|
all_tensors_to_one_file=False,
|
|
location=None,
|
|
size_threshold=0,
|
|
convert_attribute=True,
|
|
)
|
|
|
|
model_without_loading_external = onnx.load(
|
|
self.model_file_path, self.serialization_format, load_external_data=False
|
|
)
|
|
large_input_tensor = model_without_loading_external.graph.initializer[0]
|
|
assert large_input_tensor.HasField("data_location")
|
|
np.testing.assert_allclose(
|
|
to_array(large_input_tensor, self.temp_dir), self.large_data
|
|
)
|
|
|
|
small_shape_tensor = model_without_loading_external.graph.initializer[1]
|
|
assert small_shape_tensor.HasField("data_location")
|
|
np.testing.assert_allclose(
|
|
to_array(small_shape_tensor, self.temp_dir), self.small_data
|
|
)
|
|
|
|
|
|
class TestNotAllowToLoadExternalDataOutsideModelDirectory(TestLoadExternalDataBase):
|
|
"""Essential test to check that onnx (validate) C++ code will not allow to load external_data outside the model
|
|
directory.
|
|
"""
|
|
|
|
def create_external_data_tensor(
|
|
self, value: list[Any], tensor_name: str, location: str = ""
|
|
) -> TensorProto:
|
|
tensor = from_array(np.array(value))
|
|
tensor.name = tensor_name
|
|
tensor_filename = location or f"{tensor_name}.bin"
|
|
|
|
set_external_data(tensor, location=tensor_filename)
|
|
|
|
tensor.ClearField("raw_data")
|
|
tensor.data_location = onnx.TensorProto.EXTERNAL
|
|
return tensor
|
|
|
|
def test_check_model(self) -> None:
|
|
"""We only test the model validation as onnxruntime uses this to load the model."""
|
|
self.model_filename = self.create_test_model("../../file.bin")
|
|
with pytest.raises(onnx.checker.ValidationError):
|
|
checker.check_model(self.model_filename)
|
|
|
|
def test_check_model_relative(self) -> None:
|
|
"""More relative path test."""
|
|
self.model_filename = self.create_test_model("../test/../file.bin")
|
|
with pytest.raises(onnx.checker.ValidationError):
|
|
checker.check_model(self.model_filename)
|
|
|
|
def test_check_model_absolute(self) -> None:
|
|
"""ONNX checker disallows using absolute path as location in external tensor."""
|
|
self.model_filename = self.create_test_model("//file.bin")
|
|
with pytest.raises(onnx.checker.ValidationError):
|
|
checker.check_model(self.model_filename)
|
|
|
|
|
|
@pytest.mark.skipif(os.name != "nt", reason="Skip Windows test")
|
|
class TestNotAllowToLoadExternalDataOutsideModelDirectoryOnWindows(
|
|
TestNotAllowToLoadExternalDataOutsideModelDirectory
|
|
):
|
|
"""Essential test to check that onnx (validate) C++ code will not allow to load external_data outside the model
|
|
directory.
|
|
"""
|
|
|
|
def test_check_model(self) -> None:
|
|
"""We only test the model validation as onnxruntime uses this to load the model."""
|
|
self.model_filename = self.create_test_model("..\\..\\file.bin")
|
|
with pytest.raises(onnx.checker.ValidationError):
|
|
checker.check_model(self.model_filename)
|
|
|
|
def test_check_model_relative(self) -> None:
|
|
"""More relative path test."""
|
|
self.model_filename = self.create_test_model("..\\test\\..\\file.bin")
|
|
with pytest.raises(onnx.checker.ValidationError):
|
|
checker.check_model(self.model_filename)
|
|
|
|
def test_check_model_absolute(self) -> None:
|
|
"""ONNX checker disallows using absolute path as location in external tensor."""
|
|
self.model_filename = self.create_test_model("C:/file.bin")
|
|
with pytest.raises(onnx.checker.ValidationError):
|
|
checker.check_model(self.model_filename)
|
|
|
|
|
|
class TestSaveAllTensorsAsExternalDataWithPath(TestSaveAllTensorsAsExternalData):
|
|
def get_temp_model_filename(self) -> pathlib.Path:
|
|
return pathlib.Path(super().get_temp_model_filename())
|
|
|
|
|
|
class TestExternalDataToArrayWithPath(TestExternalDataToArray):
|
|
@property
|
|
def model_file_path(self) -> pathlib.Path:
|
|
return pathlib.Path(self._model_file_path)
|
|
|
|
|
|
class TestFunctionsAndSubGraphs:
|
|
@pytest.fixture(autouse=True)
|
|
def setup(self, tmp_path) -> None:
|
|
temp_dir = str(tmp_path)
|
|
self._model_file_path: str = os.path.join(temp_dir, "model.onnx")
|
|
array = np.arange(4096).astype(np.float32)
|
|
self._tensor = from_array(array, "tensor")
|
|
|
|
def _check_is_internal(self, tensor: TensorProto) -> None:
|
|
assert tensor.data_location == TensorProto.DEFAULT
|
|
|
|
def _check_is_external(self, tensor: TensorProto) -> None:
|
|
assert tensor.data_location == TensorProto.EXTERNAL
|
|
|
|
def _check(self, model: ModelProto, nodes: Sequence[NodeProto]) -> None:
|
|
"""Check that the tensors in the model are externalized.
|
|
|
|
The tensors in the specified sequence of Constant nodes are set to self._tensor,
|
|
an internal tensor. The model is then converted to external data format.
|
|
The tensors are then checked to ensure that they are externalized.
|
|
|
|
Arguments:
|
|
model: The model to check.
|
|
nodes: A sequence of Constant nodes.
|
|
|
|
"""
|
|
for node in nodes:
|
|
assert node.op_type == "Constant"
|
|
tensor = node.attribute[0].t
|
|
tensor.CopyFrom(self._tensor)
|
|
self._check_is_internal(tensor)
|
|
|
|
convert_model_to_external_data(model, size_threshold=0, convert_attribute=True)
|
|
|
|
for node in nodes:
|
|
tensor = node.attribute[0].t
|
|
self._check_is_external(tensor)
|
|
|
|
def test_function(self) -> None:
|
|
model_text = """
|
|
<ir_version: 7, opset_import: ["": 15, "local": 1]>
|
|
agraph (float[N] X) => (float[N] Y)
|
|
{
|
|
Y = local.add(X)
|
|
}
|
|
|
|
<opset_import: ["" : 15], domain: "local">
|
|
add (float[N] X) => (float[N] Y) {
|
|
C = Constant <value = float[1] {1.0}> ()
|
|
Y = Add (X, C)
|
|
}
|
|
"""
|
|
model = parser.parse_model(model_text)
|
|
self._check(model, [model.functions[0].node[0]])
|
|
|
|
def test_subgraph(self) -> None:
|
|
model_text = """
|
|
<ir_version: 7, opset_import: ["": 15, "local": 1]>
|
|
agraph (bool flag, float[N] X) => (float[N] Y)
|
|
{
|
|
Y = if (flag) <
|
|
then_branch = g1 () => (float[N] Y_then) {
|
|
B = Constant <value = float[1] {0.0}> ()
|
|
Y_then = Add (X, C)
|
|
},
|
|
else_branch = g2 () => (float[N] Y_else) {
|
|
C = Constant <value = float[1] {1.0}> ()
|
|
Y_else = Add (X, C)
|
|
}
|
|
>
|
|
}
|
|
"""
|
|
model = parser.parse_model(model_text)
|
|
if_node = model.graph.node[0]
|
|
constant_nodes = [attr.g.node[0] for attr in if_node.attribute]
|
|
self._check(model, constant_nodes)
|
|
|
|
|
|
def _make_external_data_test_model() -> tuple[ModelProto, np.ndarray]:
|
|
"""Create a simple model with a large initializer suitable for external data tests."""
|
|
model = parser.parse_model(
|
|
"""
|
|
<ir_version: 7, opset_import: ["": 17]>
|
|
agraph (float[100, 100] input) => (float[100, 100] output) {
|
|
output = Identity(input)
|
|
}
|
|
"""
|
|
)
|
|
array = np.ones((100, 100), dtype=np.float32)
|
|
model.graph.initializer.append(from_array(array, name="weight"))
|
|
return model, array
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
os.name == "nt", reason="Symlinks require elevated privileges on Windows"
|
|
)
|
|
class TestSaveExternalDataSymlinkProtection(TestLoadExternalDataBase):
|
|
"""Test that save_external_data rejects symlinks to prevent arbitrary file overwrites."""
|
|
|
|
def test_save_rejects_symlink_target(self) -> None:
|
|
"""Saving external data must refuse to follow symlinks."""
|
|
sensitive_file = os.path.join(self.temp_dir, "sensitive.txt")
|
|
with open(sensitive_file, "w") as f:
|
|
f.write("SENSITIVE DATA")
|
|
|
|
model, array = _make_external_data_test_model()
|
|
model_path = os.path.join(self.temp_dir, "model.onnx")
|
|
ext_data = "data.bin"
|
|
onnx.save_model(
|
|
model,
|
|
model_path,
|
|
save_as_external_data=True,
|
|
all_tensors_to_one_file=True,
|
|
location=ext_data,
|
|
size_threshold=1024,
|
|
)
|
|
|
|
# Replace external data file with a symlink to the sensitive file
|
|
ext_data_path = os.path.join(self.temp_dir, ext_data)
|
|
os.remove(ext_data_path)
|
|
os.symlink(sensitive_file, ext_data_path)
|
|
|
|
loaded_model = onnx.load(model_path, load_external_data=False)
|
|
loaded_model.graph.initializer[0].raw_data = array.tobytes()
|
|
|
|
with pytest.raises(checker.ValidationError):
|
|
onnx.save_model(
|
|
loaded_model,
|
|
model_path,
|
|
save_as_external_data=True,
|
|
all_tensors_to_one_file=True,
|
|
location=ext_data,
|
|
size_threshold=1024,
|
|
)
|
|
|
|
# Sensitive file must not be modified
|
|
with open(sensitive_file) as f:
|
|
assert f.read() == "SENSITIVE DATA"
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
os.name == "nt", reason="Symlinks require elevated privileges on Windows"
|
|
)
|
|
class TestLoadExternalDataSymlinkProtection(TestLoadExternalDataBase):
|
|
"""Test that loading external data rejects symlinks to prevent arbitrary file reads."""
|
|
|
|
def test_load_rejects_symlink_external_data(self) -> None:
|
|
"""Loading a model whose external data is a symlink must raise ValidationError."""
|
|
model, _ = _make_external_data_test_model()
|
|
model_path = os.path.join(self.temp_dir, "model.onnx")
|
|
ext_data = "data.bin"
|
|
onnx.save_model(
|
|
model,
|
|
model_path,
|
|
save_as_external_data=True,
|
|
all_tensors_to_one_file=True,
|
|
location=ext_data,
|
|
size_threshold=1024,
|
|
)
|
|
|
|
# Create a target file and replace external data with a symlink to it
|
|
target_file = os.path.join(self.temp_dir, "target.txt")
|
|
with open(target_file, "w") as f:
|
|
f.write("SENSITIVE DATA")
|
|
|
|
ext_data_path = os.path.join(self.temp_dir, ext_data)
|
|
os.remove(ext_data_path)
|
|
os.symlink(target_file, ext_data_path)
|
|
|
|
# Loading with onnx.load (which loads external data) must fail
|
|
with pytest.raises(checker.ValidationError):
|
|
onnx.load(model_path)
|
|
|
|
def test_load_external_data_for_model_rejects_symlink(self) -> None:
|
|
"""load_external_data_for_model must reject symlinked external data."""
|
|
model, _ = _make_external_data_test_model()
|
|
model_path = os.path.join(self.temp_dir, "model.onnx")
|
|
ext_data = "data.bin"
|
|
onnx.save_model(
|
|
model,
|
|
model_path,
|
|
save_as_external_data=True,
|
|
all_tensors_to_one_file=True,
|
|
location=ext_data,
|
|
size_threshold=1024,
|
|
)
|
|
|
|
# Replace external data with a symlink
|
|
target_file = os.path.join(self.temp_dir, "target.txt")
|
|
with open(target_file, "w") as f:
|
|
f.write("SENSITIVE DATA")
|
|
|
|
ext_data_path = os.path.join(self.temp_dir, ext_data)
|
|
os.remove(ext_data_path)
|
|
os.symlink(target_file, ext_data_path)
|
|
|
|
# Load model without external data, then try to load external data explicitly
|
|
loaded_model = onnx.load(model_path, load_external_data=False)
|
|
with pytest.raises(checker.ValidationError):
|
|
load_external_data_for_model(loaded_model, self.temp_dir)
|
|
|
|
def test_load_rejects_parent_directory_symlink(self) -> None:
|
|
"""A symlink in the parent directory must be caught by realpath containment."""
|
|
# Create a "sensitive" directory outside the model directory with a data file
|
|
sensitive_dir = os.path.join(self.temp_dir, "sensitive")
|
|
os.makedirs(sensitive_dir)
|
|
secret_file = os.path.join(sensitive_dir, "secret.bin")
|
|
with open(secret_file, "wb") as f:
|
|
f.write(b"SENSITIVE DATA" * 100)
|
|
|
|
# Create a model directory with a real subdir for saving
|
|
model_dir = os.path.join(self.temp_dir, "model_dir")
|
|
os.makedirs(model_dir)
|
|
subdir_path = os.path.join(model_dir, "subdir")
|
|
os.makedirs(subdir_path)
|
|
|
|
# Create model with external data location "subdir/secret.bin"
|
|
model, _ = _make_external_data_test_model()
|
|
model_path = os.path.join(model_dir, "model.onnx")
|
|
onnx.save_model(
|
|
model,
|
|
model_path,
|
|
save_as_external_data=True,
|
|
all_tensors_to_one_file=True,
|
|
location="subdir/secret.bin",
|
|
size_threshold=1024,
|
|
)
|
|
|
|
# Replace the real subdir with a symlink to the sensitive directory
|
|
shutil.rmtree(subdir_path)
|
|
os.symlink(sensitive_dir, subdir_path)
|
|
|
|
# Loading must fail because realpath resolves outside model_dir.
|
|
loaded_model = onnx.load(model_path, load_external_data=False)
|
|
with pytest.raises(checker.ValidationError):
|
|
load_external_data_for_model(loaded_model, model_dir)
|
|
|
|
|
|
@pytest.mark.skipif(os.name == "nt", reason="Hardlinks behave differently on Windows")
|
|
class TestLoadExternalDataHardlinkProtection(TestLoadExternalDataBase):
|
|
"""Test that loading external data rejects files with multiple hardlinks."""
|
|
|
|
def test_load_rejects_hardlinked_external_data(self) -> None:
|
|
"""Loading a model whose external data has multiple hardlinks must raise ValidationError."""
|
|
model, _ = _make_external_data_test_model()
|
|
model_path = os.path.join(self.temp_dir, "model.onnx")
|
|
ext_data = "data.bin"
|
|
onnx.save_model(
|
|
model,
|
|
model_path,
|
|
save_as_external_data=True,
|
|
all_tensors_to_one_file=True,
|
|
location=ext_data,
|
|
size_threshold=1024,
|
|
)
|
|
|
|
# Create a hardlink to the external data file
|
|
ext_data_path = os.path.join(self.temp_dir, ext_data)
|
|
hardlink_path = os.path.join(self.temp_dir, "hardlink_data.bin")
|
|
os.link(ext_data_path, hardlink_path)
|
|
|
|
# Loading must fail because the external data file has multiple hardlinks.
|
|
# Either the C++ checker or Python code catches this as ValidationError.
|
|
with pytest.raises(checker.ValidationError):
|
|
onnx.load(model_path)
|
|
|
|
|
|
class TestSaveExternalDataAbsolutePathValidation(TestLoadExternalDataBase):
|
|
"""Test that save_external_data rejects absolute paths."""
|
|
|
|
def test_save_rejects_absolute_path(self) -> None:
|
|
"""Absolute paths must be rejected as external data locations."""
|
|
array = np.ones((100,), dtype=np.float32)
|
|
tensor = from_array(array, name="weight")
|
|
set_external_data(tensor, location="/etc/passwd")
|
|
with pytest.raises(checker.ValidationError):
|
|
save_external_data(tensor, self.temp_dir)
|
|
|
|
|
|
class TestExternalDataInfoSecurity:
|
|
"""Tests for ExternalDataInfo hardening against attribute injection and bounds.
|
|
|
|
Covers all attack vectors from the security advisory: unknown key injection,
|
|
dunder attribute injection, negative offset/length bypass, and validates
|
|
that legitimate keys still work correctly.
|
|
"""
|
|
|
|
@staticmethod
|
|
def _make_tensor_with_external_data(
|
|
entries: dict[str, str],
|
|
tensor_name: str = "test_tensor",
|
|
) -> TensorProto:
|
|
"""Create a TensorProto with given external_data key-value entries."""
|
|
tensor = TensorProto()
|
|
tensor.name = tensor_name
|
|
tensor.data_type = TensorProto.FLOAT
|
|
tensor.dims.extend([4])
|
|
tensor.data_location = TensorProto.EXTERNAL
|
|
for key, value in entries.items():
|
|
entry = tensor.external_data.add()
|
|
entry.key = key
|
|
entry.value = value
|
|
return tensor
|
|
|
|
def test_valid_external_data_accepted(self) -> None:
|
|
"""All valid external_data keys must be accepted and correctly parsed."""
|
|
tensor = self._make_tensor_with_external_data(
|
|
{
|
|
"location": "weights.bin",
|
|
"offset": "16",
|
|
"length": "1024",
|
|
"checksum": "sha256:abc123",
|
|
}
|
|
)
|
|
info = ExternalDataInfo(tensor)
|
|
assert info.location == "weights.bin"
|
|
assert info.offset == 16
|
|
assert isinstance(info.offset, int)
|
|
assert info.length == 1024
|
|
assert isinstance(info.length, int)
|
|
assert info.checksum == "sha256:abc123"
|
|
|
|
def test_unknown_key_rejected(self) -> None:
|
|
"""Unknown external_data keys must not be set as object attributes (CWE-915)."""
|
|
tensor = self._make_tensor_with_external_data(
|
|
{"location": "weights.bin", "malicious_attr": "evil_value"}
|
|
)
|
|
with warnings.catch_warnings(record=True) as caught:
|
|
warnings.simplefilter("always")
|
|
info = ExternalDataInfo(tensor)
|
|
# Unknown attribute must NOT be set on the object
|
|
assert not hasattr(info, "malicious_attr"), (
|
|
"Unknown key 'malicious_attr' should not become an attribute"
|
|
)
|
|
# Valid key must still work
|
|
assert info.location == "weights.bin"
|
|
# A warning must have been emitted for the unknown key
|
|
assert any("malicious_attr" in str(w.message) for w in caught), (
|
|
"Expected warning about unknown key 'malicious_attr'"
|
|
)
|
|
|
|
def test_dunder_key_rejected(self) -> None:
|
|
"""Dunder keys like '__class__' must not be injected via external_data (CWE-915).
|
|
|
|
Without the whitelist, setattr(self, '__class__', ...) would corrupt
|
|
the object type, enabling type confusion attacks.
|
|
"""
|
|
tensor = self._make_tensor_with_external_data({"location": "weights.bin"})
|
|
# Add __class__ key via protobuf add() to mimic direct protobuf injection
|
|
dunder_entry = tensor.external_data.add()
|
|
dunder_entry.key = "__class__"
|
|
dunder_entry.value = "builtins.dict"
|
|
|
|
original_class = ExternalDataInfo
|
|
with warnings.catch_warnings(record=True) as caught:
|
|
warnings.simplefilter("always")
|
|
info = ExternalDataInfo(tensor)
|
|
# Object type must not have been corrupted
|
|
assert isinstance(info, original_class)
|
|
assert type(info).__name__ == "ExternalDataInfo"
|
|
assert info.location == "weights.bin"
|
|
# A warning must have been emitted for the dunder key
|
|
assert any("__class__" in str(w.message) for w in caught), (
|
|
"Expected warning about dunder key '__class__'"
|
|
)
|
|
|
|
def test_negative_offset_rejected(self) -> None:
|
|
"""Negative offset must raise ValueError to prevent seek(-1) attacks."""
|
|
tensor = self._make_tensor_with_external_data(
|
|
{"location": "weights.bin", "offset": "-1"}
|
|
)
|
|
with pytest.raises(ValueError, match="non-negative"):
|
|
ExternalDataInfo(tensor)
|
|
|
|
def test_negative_length_rejected(self) -> None:
|
|
"""Negative length must raise ValueError to prevent underflow attacks."""
|
|
tensor = self._make_tensor_with_external_data(
|
|
{"location": "weights.bin", "length": "-100"}
|
|
)
|
|
with pytest.raises(ValueError, match="non-negative"):
|
|
ExternalDataInfo(tensor)
|
|
|
|
def test_zero_offset_and_length_accepted(self) -> None:
|
|
"""Zero values for offset/length should be accepted (edge case for bounds check)."""
|
|
tensor = self._make_tensor_with_external_data(
|
|
{"location": "weights.bin", "offset": "0", "length": "0"}
|
|
)
|
|
# Should not raise — zero is a valid non-negative value
|
|
info = ExternalDataInfo(tensor)
|
|
assert info.location == "weights.bin"
|
|
assert info.offset == 0
|
|
assert info.length == 0
|
|
|
|
def test_multiple_unknown_keys_all_rejected(self) -> None:
|
|
"""Multiple unknown keys in a single tensor must all be rejected."""
|
|
tensor = self._make_tensor_with_external_data(
|
|
{
|
|
"location": "weights.bin",
|
|
"evil_one": "a",
|
|
"evil_two": "b",
|
|
"__dict__": "c",
|
|
}
|
|
)
|
|
with warnings.catch_warnings(record=True) as caught:
|
|
warnings.simplefilter("always")
|
|
info = ExternalDataInfo(tensor)
|
|
assert not hasattr(info, "evil_one")
|
|
assert not hasattr(info, "evil_two")
|
|
assert info.location == "weights.bin"
|
|
unknown_key_warnings = [
|
|
str(w.message)
|
|
for w in caught
|
|
if "unknown external data key" in str(w.message).lower()
|
|
]
|
|
assert len(unknown_key_warnings) == 1, (
|
|
"Expected 1 aggregated warning for unknown keys"
|
|
)
|
|
# All unknown keys should be mentioned in the single warning
|
|
assert "evil_one" in unknown_key_warnings[0]
|
|
assert "evil_two" in unknown_key_warnings[0]
|
|
assert "__dict__" in unknown_key_warnings[0]
|
|
|
|
def test_allowed_keys_constant_is_frozen(self) -> None:
|
|
"""The whitelist must be a frozenset to prevent runtime mutation."""
|
|
assert isinstance(_ALLOWED_EXTERNAL_DATA_KEYS, frozenset)
|
|
assert (
|
|
frozenset({"location", "offset", "length", "checksum", "basepath"})
|
|
== _ALLOWED_EXTERNAL_DATA_KEYS
|
|
)
|
|
|
|
def test_non_numeric_offset_raises(self) -> None:
|
|
"""Non-numeric offset string must raise ValueError from int() conversion."""
|
|
tensor = self._make_tensor_with_external_data(
|
|
{"location": "weights.bin", "offset": "abc"}
|
|
)
|
|
with pytest.raises(ValueError):
|
|
ExternalDataInfo(tensor)
|
|
|
|
def test_non_numeric_length_raises(self) -> None:
|
|
"""Non-numeric length string must raise ValueError from int() conversion."""
|
|
tensor = self._make_tensor_with_external_data(
|
|
{"location": "weights.bin", "length": "not_a_number"}
|
|
)
|
|
with pytest.raises(ValueError):
|
|
ExternalDataInfo(tensor)
|
|
|
|
|
|
class TestLoadExternalDataFileSizeValidation(TestLoadExternalDataBase):
|
|
"""Tests for defense-in-depth file-size validation in load_external_data_for_tensor."""
|
|
|
|
def test_offset_exceeds_file_size_raises(self) -> None:
|
|
"""Offset beyond file size must raise ValueError."""
|
|
array = np.ones((4,), dtype=np.float32)
|
|
tensor = from_array(array, name="weight")
|
|
set_external_data(tensor, location="data.bin")
|
|
|
|
data_path = os.path.join(self.temp_dir, "data.bin")
|
|
with open(data_path, "wb") as f:
|
|
f.write(tensor.raw_data)
|
|
|
|
file_size = os.path.getsize(data_path)
|
|
# Set offset beyond file size
|
|
set_external_data(tensor, location="data.bin", offset=file_size + 100)
|
|
tensor.ClearField("raw_data")
|
|
|
|
with pytest.raises(ValueError, match=r"offset.*exceeds file size"):
|
|
load_external_data_for_tensor(tensor, self.temp_dir)
|
|
|
|
def test_length_exceeds_available_data_raises(self) -> None:
|
|
"""Length that overflows available data must raise ValueError."""
|
|
array = np.ones((4,), dtype=np.float32)
|
|
tensor = from_array(array, name="weight")
|
|
set_external_data(tensor, location="data.bin")
|
|
|
|
data_path = os.path.join(self.temp_dir, "data.bin")
|
|
with open(data_path, "wb") as f:
|
|
f.write(tensor.raw_data)
|
|
|
|
file_size = os.path.getsize(data_path)
|
|
# Set length much larger than file
|
|
set_external_data(tensor, location="data.bin", length=file_size * 1000)
|
|
tensor.ClearField("raw_data")
|
|
|
|
with pytest.raises(ValueError, match=r"length.*exceeds available data"):
|
|
load_external_data_for_tensor(tensor, self.temp_dir)
|
|
|
|
def test_valid_offset_and_length_load_correctly(self) -> None:
|
|
"""Valid offset+length within file size should load correctly."""
|
|
array = np.array([1.0, 2.0, 3.0, 4.0], dtype=np.float32)
|
|
tensor = from_array(array, name="weight")
|
|
raw = tensor.raw_data
|
|
|
|
data_path = os.path.join(self.temp_dir, "data.bin")
|
|
with open(data_path, "wb") as f:
|
|
f.write(raw)
|
|
|
|
set_external_data(tensor, location="data.bin", offset=0, length=len(raw))
|
|
tensor.ClearField("raw_data")
|
|
|
|
load_external_data_for_tensor(tensor, self.temp_dir)
|
|
assert tensor.raw_data == raw
|