Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/pythontest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ jobs:
- name: Property-based tests
run: |
pip install --upgrade ".[hypothesis]"
python -m unittest property_tests.test_config
python -m unittest discover property_tests
env:
HYPOTHESIS_PROFILE: ci
- name: Run contrib tests
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.rst
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ them explicitly. Their default Hypothesis profile is deterministic.
.. code:: console

$ pip install -e ".[hypothesis]"
$ python -m unittest property_tests.test_config
$ python -m unittest discover property_tests

testr and tox configuration is also present.

Expand Down
10 changes: 10 additions & 0 deletions crates/pack/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,9 @@ fn apply_delta(py: Python, py_src_buf: Py<PyAny>, py_delta: Py<PyAny>) -> PyResu

for i in 0..4 {
if cmd & (1 << i) != 0 {
if index >= delta_len {
return Err(ApplyDeltaError::new_err("delta not empty"));
}
let x = delta[index] as usize;
index += 1;
cp_off |= x << (i * 8);
Expand All @@ -178,6 +181,9 @@ fn apply_delta(py: Python, py_src_buf: Py<PyAny>, py_delta: Py<PyAny>) -> PyResu

for i in 0..3 {
if cmd & (1 << (4 + i)) != 0 {
if index >= delta_len {
return Err(ApplyDeltaError::new_err("delta not empty"));
}
let x = delta[index] as usize;
index += 1;
cp_size |= x << (i * 8);
Expand All @@ -193,6 +199,7 @@ fn apply_delta(py: Python, py_src_buf: Py<PyAny>, py_delta: Py<PyAny>) -> PyResu
|| cp_off > src_size
|| cp_off > src_size - cp_size
|| cp_size > dest_size
|| outindex > dest_size - cp_size
{
break;
}
Expand All @@ -208,6 +215,9 @@ fn apply_delta(py: Python, py_src_buf: Py<PyAny>, py_delta: Py<PyAny>) -> PyResu
if outindex + cmd as usize > dest_size {
return Err(ApplyDeltaError::new_err("Not enough space to copy"));
}
if index + cmd as usize > delta_len {
return Err(ApplyDeltaError::new_err("delta not empty"));
}

out[outindex..outindex + cmd as usize]
.copy_from_slice(&delta[index..index + cmd as usize]);
Expand Down
111 changes: 111 additions & 0 deletions property_tests/test_pack.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# test_pack.py -- property tests for pack.py
# Copyright (C) 2026 The Dulwich contributors
#
# SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later
# Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
# General Public License as published by the Free Software Foundation; version 2.0
# or (at your option) any later version. You can redistribute it and/or
# modify it under the terms of either of these two licenses.
#
# 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.
#
# You should have received a copy of the licenses; if not, see
# <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
# and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
# License, Version 2.0.
#

"""Property tests for pack helpers."""

import os
from collections.abc import Iterable

from hypothesis import example, given, settings
from hypothesis import strategies as st

from dulwich.errors import ApplyDeltaError
from dulwich.pack import _create_delta_py, _delta_encode_size, apply_delta, create_delta
from tests import TestCase


def _delta_to_bytes(delta: bytes | Iterable[bytes]) -> bytes:
if isinstance(delta, bytes):
return delta
return b"".join(delta)


settings.register_profile(
"deterministic", max_examples=50, deadline=None, derandomize=True
)
settings.register_profile("ci", max_examples=50, deadline=None, derandomize=True)
settings.register_profile(
"local-deep", max_examples=1000, deadline=None, derandomize=True
)
settings.load_profile(os.environ.get("HYPOTHESIS_PROFILE", "deterministic"))

byte_strings = st.binary(max_size=256)


@st.composite
def delta_pairs(draw) -> tuple[bytes, bytes]:
"""Generate byte pairs with some shared content for delta copies."""
prefix = draw(st.binary(max_size=128))
suffix = draw(st.binary(max_size=128))
base_middle = draw(st.binary(max_size=128))
target_middle = draw(st.binary(max_size=128))
return prefix + base_middle + suffix, prefix + target_middle + suffix


@st.composite
def bounded_delta_inputs(draw) -> tuple[bytes, bytes]:
"""Generate arbitrary delta op streams with bounded output sizes."""
base = draw(byte_strings)
dest_size = draw(st.integers(min_value=0, max_value=512))
ops = draw(st.binary(max_size=256))
delta = _delta_encode_size(len(base)) + _delta_encode_size(dest_size) + ops
return base, delta


byte_pairs = st.one_of(st.tuples(byte_strings, byte_strings), delta_pairs())


class PackPropertyTests(TestCase):
"""Property tests for pack helpers."""

@given(byte_pairs)
@example((b"", b""))
@example((b"", b"Z" * 8192))
@example((b"Z" * 8192, b"Z" * 8192))
@example((b"Z" * 70000 + b"a", b"Z" * 70000 + b"b"))
def test_create_delta_roundtrip(self, pair: tuple[bytes, bytes]) -> None:
"""Check that generated deltas apply back to the target."""
base, target = pair
delta = _delta_to_bytes(create_delta(base, target))
self.assertEqual(target, b"".join(apply_delta(base, delta)))

@given(byte_pairs)
@example((b"", b""))
@example((b"", b"Z" * 8192))
@example((b"Z" * 8192, b"Z" * 8192))
@example((b"Z" * 70000 + b"a", b"Z" * 70000 + b"b"))
def test_create_delta_py_roundtrip(self, pair: tuple[bytes, bytes]) -> None:
"""Check that pure Python generated deltas apply to the target."""
base, target = pair
delta = _delta_to_bytes(_create_delta_py(base, target))
self.assertEqual(target, b"".join(apply_delta(base, delta)))

@given(bounded_delta_inputs())
@example((b"", b"\x00\x01\x01"))
def test_apply_delta_only_raises_apply_delta_error(
self, base_and_delta: tuple[bytes, bytes]
) -> None:
"""Check that malformed deltas use the delta error type."""
base, delta = base_and_delta
try:
apply_delta(base, delta)
except ApplyDeltaError:
pass
4 changes: 4 additions & 0 deletions tests/test_pack.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,10 @@ def test_apply_delta_invalid_opcode(self) -> None:
# Should raise ApplyDeltaError
self.assertRaises(ApplyDeltaError, apply_delta, base, invalid_delta)

def test_apply_delta_truncated_insert(self) -> None:
"""Test apply_delta with a truncated insert operation."""
self.assertRaises(ApplyDeltaError, apply_delta, b"", b"\x00\x01\x01")

def test_create_delta_insert_only(self) -> None:
"""Test create_delta when only insertions are required."""
base = b""
Expand Down
Loading