blob: 384fb9649827fab0a8909bd4bf3202c62e541d56 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Lance exports preserve authorized values after masking changes Arrow types."""
import json
import os
import subprocess
import sys
import textwrap
from unittest.mock import patch
import pyarrow as pa
import pytest
from pypaimon.catalog.table_query_auth import TableQueryAuthResult
from pypaimon.table.row.blob import BlobDescriptor, VideoFrameDescriptor
from pypaimon.tests import multimodal_lance_test as lance_tests
pytest.importorskip("lance", minversion="13.0.0", reason="pylance >= 13.0.0 is required for Lance exports")
conn = lance_tests.conn
_AUTH_EXPORT_PROBE = textwrap.dedent("""
import json
import pathlib
import sys
import lance
import pyarrow as pa
import pypaimon.multimodal as pmm
from pypaimon.catalog.table_query_auth import TableQueryAuthResult
from pypaimon.table.row.blob import BlobDescriptor
root, kind, mode = pathlib.Path(sys.argv[1]), sys.argv[2], sys.argv[3]
conn = pmm.connect(options={"warehouse": str(root / "warehouse")})
source = root / "external.bin"
source.write_bytes(b"secret")
blob = kind in ("blob", "copy_blob", "copy_legacy_blob", "copy_binary")
source_is_blob = kind in ("copy_blob", "copy_legacy_blob")
value_type = pa.large_binary() if blob else pa.string()
source_type = (pa.large_binary() if source_is_blob else
(pa.binary() if kind == "copy_binary" else pa.string()))
initial = (b"original" if kind == "copy_legacy_blob" else
(BlobDescriptor(str(source), 0, 6).serialize() if blob else "original"))
options = {"file.format": "parquet", "read.batch-size": "1", "query-auth.enabled": "true"}
if blob:
options["blob-descriptor-field"] = "value"
if kind == "copy_legacy_blob":
hidden = root / "hidden.bin"
hidden.write_bytes(b"masked")
wire = BlobDescriptor(str(hidden), 0, 6).serialize()
source_value = bytes([1]) + wire[9:]
options["blob-descriptor-field"] = "source"
elif kind == "copy_binary":
source_value = BlobDescriptor(str(root / "must-not-read.bin"), 0, 5).serialize()
elif source_is_blob:
source_value = b"masked"
else:
source_value = "not-int" if kind == "failure" else "masked"
table = conn.create_table(
"masked", schema=pa.schema([("id", pa.int32()), ("value", value_type), ("source", source_type)]),
data=[{"id": 1, "value": initial, "source": source_value},
{"id": 2, "value": initial, "source": source_value}],
options=options)
field = {"index": 2, "name": "source", "type": "BLOB" if source_is_blob else
("BYTES" if kind == "copy_binary" else "STRING")}
if blob:
mask = ({"name": "FIELD_REF", "fieldRef": field} if kind.startswith("copy_") else
{"name": "CONCAT", "inputs": [field]})
expected = [{"value": source_value if kind == "copy_binary" else b"masked"}]
elif kind == "string":
mask = {"name": "LENGTH", "inputs": [field]}
expected = [{"value": "6"}]
else:
mask = {"name": "CAST", "fieldRef": field, "type": "INT"}
row_filter = json.dumps({
"kind": "LEAF", "function": "EQUAL", "literals": [1],
"transform": {"name": "FIELD_REF", "fieldRef": {"index": 0, "name": "id", "type": "INT"}},
})
auth = TableQueryAuthResult([row_filter], {"value": json.dumps(mask)})
table.raw_table.catalog_environment.table_query_auth = lambda options, identifier: lambda select: auth
query = table.scan().select("value")
path = root / "masked.lance"
if mode == "overwrite":
lance.write_dataset(pa.table({"id": [99]}), str(path))
if kind == "failure":
for read in (query.to_arrow, lambda: query.export_lance(str(path), mode=mode)):
try:
read()
except Exception as error:
assert "not-int" in str(error), str(error)
else:
raise AssertionError("The authorization mask must fail")
if mode == "overwrite":
assert lance.dataset(str(path)).to_table().to_pylist() == [{"id": 99}]
else:
try:
lance.dataset(str(path))
except ValueError:
pass
else:
raise AssertionError("The failed export published a dataset")
else:
logical = table.copy({"blob-as-descriptor": "false"}).scan().select("value") if blob else query
assert logical.to_list() == expected
exported = query.export_lance(str(path), mode=mode)
if blob:
with exported.take_blobs("value", indices=[0])[0] as handle:
actual = [{"value": handle.read()}]
else:
actual = exported.to_table().to_pylist()
assert actual == expected, actual
print("authorized export verified")
""")
def _run_auth_export_probe(tmp_path, kind, mode="create"):
# Keep Arrow schema/array mismatches in a subprocess: affected versions
# can abort while consuming the stream instead of raising a Python error.
environment = dict(os.environ)
environment["PYTHONPATH"] = os.pathsep.join(path for path in sys.path if path)
result = subprocess.run(
[sys.executable, "-u", "-c", _AUTH_EXPORT_PROBE, str(tmp_path), kind, mode],
env=environment, capture_output=True, text=True, timeout=30)
assert result.returncode == 0, result.stdout + result.stderr
assert "authorized export verified" in result.stdout
@pytest.mark.parametrize("kind", ["string", "blob", "copy_blob", "copy_legacy_blob", "copy_binary"])
def test_export_lance_preserves_authorized_mask_values(tmp_path, kind):
_run_auth_export_probe(tmp_path, kind)
@pytest.mark.parametrize("mode", ["create", "overwrite"])
def test_export_lance_auth_mask_failure_does_not_publish(tmp_path, mode):
_run_auth_export_probe(tmp_path, "failure", mode)
@pytest.mark.parametrize("mode", ["create", "overwrite"])
def test_export_lance_rejects_computed_blob_masks_before_writing(conn, tmp_path, mode):
external = tmp_path / "external.bin"
external.write_bytes(b"body")
descriptor = BlobDescriptor(str(external), 0, 4).serialize()
table = lance_tests._table(conn, "computed_mask", {"id": pa.int32(), "payload": pa.large_binary()}, [
{"id": 1, "payload": descriptor},
], options={"blob-descriptor-field": "payload"})
auth = TableQueryAuthResult(None, {"payload": json.dumps({
"name": "CAST", "fieldRef": {"index": 1, "name": "payload", "type": "BYTES"}, "type": "BYTES",
})})
table.raw_table.catalog_environment.table_query_auth = lambda options, identifier: lambda select: auth
query = table.scan()
assert query.to_list() == [{"id": 1, "payload": descriptor}]
path = tmp_path / "computed_mask.lance"
if mode == "overwrite":
lance_tests.lance.write_dataset(pa.table({"id": [99]}), str(path))
with patch.object(lance_tests.lance, "write_dataset", side_effect=AssertionError("must reject before writing")):
with pytest.raises(ValueError, match="(?i)mask"):
query.export_lance(str(path), mode=mode)
if mode == "overwrite":
assert lance_tests._rows(path) == [{"id": 99}]
else:
with pytest.raises(ValueError):
lance_tests.lance.dataset(str(path))
@pytest.mark.parametrize("descriptor_mode", ["true", "false"])
@pytest.mark.parametrize("mode", ["create", "overwrite"])
def test_export_lance_rejects_hidden_video_mask_source_before_writing(conn, tmp_path, descriptor_mode, mode):
video = tmp_path / "video.bin"
video.write_bytes(b"encoded-video")
frame = VideoFrameDescriptor(str(video), 0, video.stat().st_size, 3)
table = lance_tests._table(conn, "hidden_video", {
"id": pa.int32(), "out": pa.large_binary(), "frame": pa.large_binary(),
}, [{"id": 1, "out": b"ordinary", "frame": frame}], options={"video-frame-field": "frame"})
auth = TableQueryAuthResult(None, {"out": json.dumps({
"name": "FIELD_REF", "fieldRef": {"index": 2, "name": "frame", "type": "BLOB"},
})})
table.raw_table.catalog_environment.table_query_auth = lambda options, identifier: lambda select: auth
query = table.copy({"blob-as-descriptor": descriptor_mode}).scan().select(["out", "id"])
path = tmp_path / "hidden_video.lance"
if mode == "overwrite":
lance_tests.lance.write_dataset(pa.table({"id": [99]}), str(path))
with patch.object(lance_tests.lance, "write_dataset", side_effect=AssertionError("must reject before writing")):
with pytest.raises(ValueError, match="(?i)video"):
query.export_lance(str(path), mode=mode)
if mode == "overwrite":
assert lance_tests._rows(path) == [{"id": 99}]
else:
with pytest.raises(ValueError):
lance_tests.lance.dataset(str(path))
@pytest.mark.parametrize("descriptor_mode", ["true", "false"])
def test_export_lance_allows_masks_from_row_tracking_fields(conn, tmp_path, descriptor_mode):
table = lance_tests._table(conn, "row_id_mask", {"id": pa.int32(), "payload": pa.large_binary()}, [
{"id": 1, "payload": b"original"},
])
auth = TableQueryAuthResult(None, {"payload": json.dumps({
"name": "CAST", "fieldRef": {"index": 2, "name": "_ROW_ID", "type": "BIGINT"}, "type": "STRING",
})})
table.raw_table.catalog_environment.table_query_auth = lambda options, identifier: lambda select: auth
query = table.copy({"blob-as-descriptor": descriptor_mode}).scan().select("payload").with_row_id()
expected = [{"payload": b"0", "_ROW_ID": 0}]
assert query.to_list() == expected
path = tmp_path / "row_id_mask.lance"
query.export_lance(str(path))
assert lance_tests._rows(path) == expected