From 92c826b012e6c9a0bdb2fed8068251bbb6be4adb Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 4 Aug 2026 13:29:27 -0400 Subject: [PATCH 1/8] Unpack only the .lift member for streaming zip reads The stats and export commands stream entries and resolve neither companion ranges nor media, so extracting a whole package wrote its audio and writing-system files to a temporary directory for nothing. lift_source() now narrows the write to the single .lift member; load(), validate, and check-media still unpack the full folder, which they need to resolve companions and check media presence. The aggregate uncompressed-size cap guards bytes written to disk, so it now applies to the full extraction only; the per-file cap and the entry count and path-traversal checks apply to both paths, the latter two over the whole listing however much of it gets written. Selecting the .lift moves from globbing the extracted tree to matching archive member names, letting both paths share one rule rather than growing a second near-duplicate. That makes the suffix match case-insensitive on every platform, where globbing resolved a .LIFT member on Windows but not on Linux or macOS. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 13 ++++ docs/en/guides/cli.md | 2 +- docs/en/guides/lift-export-interop.md | 2 +- src/sil_lift/_zip.py | 95 +++++++++++++++------------ tests/test_zip.py | 54 +++++++++++++++ 5 files changed, 123 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 395cec5..8627fb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,19 @@ releases may contain breaking changes. ## [Unreleased] +### Changed + +- The streaming CLI commands (`stats`, `export`) now unpack only the `.lift` + member of a zipped package instead of the whole archive, so they no longer + write a media-heavy package's audio to a temporary directory just to read the + lexicon. The aggregate uncompressed-size cap still applies to the full + extraction that `load()`, `validate`, and `check-media` need; each file + written is capped in both paths. +- The `.lift` member of a zipped package is located by archive member name + rather than by globbing the extracted tree, making the suffix match + case-insensitive on every platform (previously a `.LIFT` member resolved on + Windows but not on Linux or macOS). + ## [0.1.0] - 2026-07-TBD ### Added diff --git a/docs/en/guides/cli.md b/docs/en/guides/cli.md index ae33f0b..8f8401e 100644 --- a/docs/en/guides/cli.md +++ b/docs/en/guides/cli.md @@ -21,7 +21,7 @@ sil-lift export PATH [-o OUT] [--langs L] [--tsv] `sort` rewrites only the `.lift` file; companion `.lift-ranges` files are left untouched (sort those separately with the `RangesFile` API). -`validate`, `stats`, `check-media`, and `export` also accept a zipped LIFT package (a `.zip` in either layout — files at the archive root, or nested under one top-level folder); it is extracted to a temporary directory and discarded when the command finishes. +`validate`, `stats`, `check-media`, and `export` also accept a zipped LIFT package (a `.zip` in either layout — files at the archive root, or nested under one top-level folder); it is extracted to a temporary directory and discarded when the command finishes. The streaming commands `stats` and `export` extract only the `.lift` itself, so they stay cheap on media-heavy packages; `validate` and `check-media` need the whole folder and unpack it. Examples: diff --git a/docs/en/guides/lift-export-interop.md b/docs/en/guides/lift-export-interop.md index 1edf3eb..fc237c5 100644 --- a/docs/en/guides/lift-export-interop.md +++ b/docs/en/guides/lift-export-interop.md @@ -8,7 +8,7 @@ Writing LIFT is much easier than parsing it: an exporter only emits the subset o LIFT is usually moved around as a single `.zip` — FieldWorks and The Combine both import and export that way — so `sil-lift` reads and writes zipped packages directly, in either layout the ecosystem uses: the files at the archive root, or nested under one top-level folder. -- **Read:** `sil_lift.load("package.zip")` extracts to a temp directory, locates the single `.lift`, and loads it (companions and media resolve as usual). The `validate`, `stats`, `check-media`, and `export` CLI commands accept a `.zip` path too, so the gate below runs against a package as-is. Extraction is hardened against hostile archives — path-traversal members are refused, and the entry count and total uncompressed size (10 GiB) are capped against zip bombs. +- **Read:** `sil_lift.load("package.zip")` extracts to a temp directory, locates the single `.lift`, and loads it (companions and media resolve as usual). The `validate`, `stats`, `check-media`, and `export` CLI commands accept a `.zip` path too, so the gate below runs against a package as-is; `stats` and `export` stream, and unpack only the `.lift` rather than the whole package. Extraction is hardened against hostile archives — path-traversal members are refused, the entry count is capped, and every file written is capped at 10 GiB against zip bombs (a full extraction also checks the package's total against that limit up front). - **Write:** `Lexicon.save_zip("out.zip", wrap_folder="MyDict")` packages the `.lift`, its `.lift-ranges`, and every other file in the source folder (media, `WritingSystems/`, `consent/`, ...) into a zip. `wrap_folder` defaults to a top-level folder named after the zip (the FieldWorks/Combine import convention); pass `False` for a flat archive. The `.lift` and `.lift-ranges` keep their byte-fidelity inside the package; the zip container itself is not byte-reproducible. diff --git a/src/sil_lift/_zip.py b/src/sil_lift/_zip.py index 7ed90c4..d57bceb 100644 --- a/src/sil_lift/_zip.py +++ b/src/sil_lift/_zip.py @@ -4,9 +4,14 @@ (both produced and accepted by FieldWorks and The Combine): the files at the archive root (``foo.lift`` beside ``WritingSystems/``, ``audio/``, ...), or nested one level under a single folder (``Foo/foo.lift`` ...). This module -extracts to a temporary directory, locates the single ``.lift`` (its parent is -the package root), and hands off to the ordinary path-based reader/writer — so -media resolution, companion discovery, and byte-fidelity all work unchanged. +locates the single ``.lift`` member (its parent is the package root), extracts +to a temporary directory, and hands off to the ordinary path-based +reader/writer — so media resolution, companion discovery, and byte-fidelity all +work unchanged. + +Streaming reads (:func:`lift_source`) extract that one member and nothing else: +they resolve neither companions nor media, so writing the rest of an +audio-heavy package to disk would cost gigabytes that nothing goes on to read. The archive *container* is not byte-reproducible (zip carries timestamps, compression, and ordering); the guarantee is at the file level — the ``.lift`` @@ -20,14 +25,14 @@ import tempfile import zipfile from contextlib import contextmanager -from pathlib import Path +from pathlib import Path, PurePosixPath from typing import TYPE_CHECKING from ._errors import LiftParseError from ._model import Lexicon if TYPE_CHECKING: - from collections.abc import Iterator + from collections.abc import Iterable, Iterator __all__ = ["load_zip", "save_zip"] @@ -45,14 +50,40 @@ def _size_limit_message(zip_path: Path) -> str: return f"{zip_path.name}: uncompressed size exceeds the {limit_gib:.0f} GiB limit" -def _safe_extract(zip_path: Path, dest: Path) -> None: - """Extract ``zip_path`` into ``dest``, defending against malicious archives. +def _select_lift_member(names: Iterable[str]) -> str: + """The single ``.lift`` member of an archive listing; its parent is the root. + + Handles both the flat and folder-wrapped layouts, and ignores junk such as + ``__MACOSX`` and dotfile entries that some zip tools add. The suffix match + is case-insensitive, so a ``.LIFT`` member written by a Windows tool + resolves the same way on every platform. + """ + lifts = [ + name + for name in names + if name.lower().endswith(".lift") + and not any( + part == "__MACOSX" or part.startswith(".") for part in PurePosixPath(name).parts + ) + ] + if not lifts: + raise LiftParseError("no .lift file found in the archive") + if len(lifts) > 1: + found = ", ".join(sorted(PurePosixPath(name).name for name in lifts)) + raise LiftParseError(f"multiple .lift files found in the archive: {found}") + return lifts[0] + + +def _safe_extract(zip_path: Path, dest: Path, *, only_lift: bool = False) -> str: + """Extract ``zip_path`` into ``dest``; returns the ``.lift`` member's name. Path-traversal members (``..`` or absolute, resolved against ``dest``) are - rejected, the entry count is capped, and the total uncompressed size is - capped at ``_MAX_UNCOMPRESSED_BYTES`` — checked against the declared sizes - up front, then again while streaming each member to disk, since a crafted - archive's declared size can lie. + rejected and the entry count is capped, both over the whole listing however + much of it gets written. ``only_lift`` narrows the write to the ``.lift`` + itself, which is all a streaming read needs; the aggregate uncompressed + size cap then has nothing to guard and is left to full extraction. Either + way every member written is capped as it streams, since a crafted archive's + declared size can lie. """ dest_root = dest.resolve() try: @@ -65,7 +96,10 @@ def _safe_extract(zip_path: Path, dest: Path) -> None: raise LiftParseError( f"{zip_path.name}: unsafe path in archive: {info.filename!r}" ) - if sum(info.file_size for info in infos) > _MAX_UNCOMPRESSED_BYTES: + member = _select_lift_member(info.filename for info in infos) + if only_lift: + infos = [info for info in infos if info.filename == member] + elif sum(info.file_size for info in infos) > _MAX_UNCOMPRESSED_BYTES: raise LiftParseError(_size_limit_message(zip_path)) written = 0 for info in infos: @@ -82,43 +116,23 @@ def _safe_extract(zip_path: Path, dest: Path) -> None: sink.write(chunk) except zipfile.BadZipFile as exc: raise LiftParseError(f"{zip_path.name}: not a valid zip archive: {exc}") from exc - - -def _find_lift_root(tree: Path) -> Path: - """The single ``.lift`` file in an extracted tree; its parent is the root. - - Handles both the flat and folder-wrapped layouts, and ignores junk such as - ``__MACOSX`` and dotfile entries that some zip tools add. - """ - lifts = [ - p - for p in tree.rglob("*.lift") - if p.is_file() - and not any( - part == "__MACOSX" or part.startswith(".") for part in p.relative_to(tree).parts - ) - ] - if not lifts: - raise LiftParseError("no .lift file found in the archive") - if len(lifts) > 1: - names = ", ".join(sorted(p.name for p in lifts)) - raise LiftParseError(f"multiple .lift files found in the archive: {names}") - return lifts[0] + return member @contextmanager def lift_source(path: Path) -> Iterator[Path]: """Yield a ``.lift`` path for ``path``, extracting a ``.zip`` to a temp dir. - A non-zip path is yielded unchanged. A zip is extracted for the duration of - the ``with`` block, then removed — for streaming callers that only read. + A non-zip path is yielded unchanged. From a zip only the ``.lift`` member + is extracted, for the duration of the ``with`` block — all a streaming + caller reads, since it resolves neither companions nor media. """ if path.suffix.lower() != ".zip": yield path return with tempfile.TemporaryDirectory(prefix="sil-lift-") as tmp: - _safe_extract(path, Path(tmp)) - yield _find_lift_root(Path(tmp)) + member = _safe_extract(path, Path(tmp), only_lift=True) + yield Path(tmp) / member def load_zip(path: Path, *, resolve_ranges: bool = True) -> Lexicon: @@ -130,9 +144,8 @@ def load_zip(path: Path, *, resolve_ranges: bool = True) -> Lexicon: """ tmp = tempfile.TemporaryDirectory(prefix="sil-lift-") try: - _safe_extract(path, Path(tmp.name)) - lift_path = _find_lift_root(Path(tmp.name)) - lexicon = Lexicon.load(lift_path, resolve_ranges=resolve_ranges) + member = _safe_extract(path, Path(tmp.name)) + lexicon = Lexicon.load(Path(tmp.name) / member, resolve_ranges=resolve_ranges) except BaseException: tmp.cleanup() raise diff --git a/tests/test_zip.py b/tests/test_zip.py index 0bbb053..64f112e 100644 --- a/tests/test_zip.py +++ b/tests/test_zip.py @@ -6,6 +6,7 @@ import sil_lift from sil_lift._cli import main +from sil_lift._zip import lift_source CORPUS_DIR = Path(__file__).parent / "corpus" PAIR_DIR = CORPUS_DIR / "ranges" # test20080407.lift + companion, fully clean @@ -131,6 +132,59 @@ def test_cli_export_accepts_zip(tmp_path: Path) -> None: assert any("abat" in row for row in rows[1:]) # the entry id in full-entry.lift +def _package_with_media(dst: Path, *, media: bytes = b"\0" * 8192) -> Path: + with zipfile.ZipFile(dst, "w") as archive: + for arcname, src in PAIR.items(): + archive.write(src, f"Pkg/{arcname}") + archive.writestr("Pkg/audio/big.wav", media) + return dst + + +def test_lift_source_extracts_only_the_lift(tmp_path: Path) -> None: + package = _package_with_media(tmp_path / "pkg.zip") + with lift_source(package) as lift_path: + root = lift_path.parents[1] # the temp dir; the .lift sits under Pkg/ + written = sorted(p.relative_to(root).as_posix() for p in root.rglob("*") if p.is_file()) + assert written == ["Pkg/test20080407.lift"] # no media, no companion + + +def test_lift_source_skips_the_aggregate_size_cap( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # The cap guards bytes written to disk, and a streaming read writes one + # member — so a package whose media dwarfs the limit still streams, while + # the full extraction behind load() refuses it. + package = _package_with_media(tmp_path / "pkg.zip") + monkeypatch.setattr("sil_lift._zip._MAX_UNCOMPRESSED_BYTES", 4000) + with lift_source(package) as lift_path: + assert lift_path.is_file() + with pytest.raises(sil_lift.LiftParseError, match="exceeds"): + sil_lift.load(package) + + +def test_lift_source_caps_an_oversized_lift_member( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + package = _package_with_media(tmp_path / "pkg.zip") + monkeypatch.setattr("sil_lift._zip._MAX_UNCOMPRESSED_BYTES", 100) # under the .lift's size + with pytest.raises(sil_lift.LiftParseError, match="exceeds"), lift_source(package): + pass + + +def test_lift_source_rejects_path_traversal(tmp_path: Path) -> None: + package = tmp_path / "evil.zip" + with zipfile.ZipFile(package, "w") as archive: + archive.write(PAIR_DIR / "test20080407.lift", "Pkg/test20080407.lift") + archive.writestr("../evil.txt", b"x") # never extracted, still refused + with pytest.raises(sil_lift.LiftParseError, match="unsafe path"), lift_source(package): + pass + + +def test_lift_source_passes_a_plain_lift_through() -> None: + with lift_source(PAIR_DIR / "test20080407.lift") as lift_path: + assert lift_path == PAIR_DIR / "test20080407.lift" + + def test_save_zip_roundtrip_wrapped(tmp_path: Path) -> None: lex = sil_lift.load(_make_zip(tmp_path / "src.zip", PAIR, wrap="Src")) out = tmp_path / "out.zip" From a3c4a5c2822c681f72ab6fcdf7eee53b8c8e530c Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 4 Aug 2026 14:39:35 -0400 Subject: [PATCH 2/8] Tolerate a duplicated .lift record and name the oversized member MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit De-duplicate archive member names before the "multiple .lift files" count, so a package storing one path twice — some writers append a record rather than replace it — still resolves to the single file that extraction, which overwrites, leaves on disk. Report which member overflowed when the streaming path's running cap fires: there the limit bounds one file rather than the package, so the package-wide wording misdescribes it. Document two consequences of matching member names: a .LIFT member's conventional sibling companion does not resolve on a case-sensitive filesystem, since that candidate is derived from the suffix, and narrowing the write leaves one member able to reach the full cap before refusal. Derive the extraction-scope test's temp root from the member's own depth and assert it is the temp directory, so the check cannot silently walk above it if the fixture layout changes. Co-Authored-By: Claude Opus 5 (1M context) --- src/sil_lift/_zip.py | 54 +++++++++++++++++++++++++++++--------------- tests/test_zip.py | 28 +++++++++++++++++++---- 2 files changed, 59 insertions(+), 23 deletions(-) diff --git a/src/sil_lift/_zip.py b/src/sil_lift/_zip.py index d57bceb..3b4dd26 100644 --- a/src/sil_lift/_zip.py +++ b/src/sil_lift/_zip.py @@ -45,27 +45,39 @@ _EXTRACT_CHUNK = 1 << 20 # 1 MiB -def _size_limit_message(zip_path: Path) -> str: +def _size_limit_message(zip_path: Path, member: str | None = None) -> str: + """The size-cap refusal, for the package as a whole or for one member.""" limit_gib = _MAX_UNCOMPRESSED_BYTES / 1024**3 - return f"{zip_path.name}: uncompressed size exceeds the {limit_gib:.0f} GiB limit" + if member is None: + return f"{zip_path.name}: uncompressed size exceeds the {limit_gib:.0f} GiB limit" + return f"{zip_path.name}: {member!r} alone exceeds the {limit_gib:.0f} GiB limit" def _select_lift_member(names: Iterable[str]) -> str: """The single ``.lift`` member of an archive listing; its parent is the root. Handles both the flat and folder-wrapped layouts, and ignores junk such as - ``__MACOSX`` and dotfile entries that some zip tools add. The suffix match - is case-insensitive, so a ``.LIFT`` member written by a Windows tool - resolves the same way on every platform. + ``__MACOSX`` and dotfile entries that some zip tools add. One path stored + twice counts once — some writers append a record rather than replace it, + and extraction overwrites, so what lands on disk is a single file. + + The suffix match is case-insensitive, so a ``.LIFT`` member resolves the + same way on every platform rather than only where the filesystem is + case-folding. Such a package loads, but on a case-sensitive filesystem its + conventional sibling companion does not resolve (that candidate is derived + from the suffix, giving ``.LIFT-ranges``); a header ``range/@href`` still + resolves normally, which is how real exports reference the companion. """ - lifts = [ - name - for name in names - if name.lower().endswith(".lift") - and not any( - part == "__MACOSX" or part.startswith(".") for part in PurePosixPath(name).parts + lifts = list( + dict.fromkeys( # de-duplicate, preserving listing order + name + for name in names + if name.lower().endswith(".lift") + and not any( + part == "__MACOSX" or part.startswith(".") for part in PurePosixPath(name).parts + ) ) - ] + ) if not lifts: raise LiftParseError("no .lift file found in the archive") if len(lifts) > 1: @@ -79,11 +91,15 @@ def _safe_extract(zip_path: Path, dest: Path, *, only_lift: bool = False) -> str Path-traversal members (``..`` or absolute, resolved against ``dest``) are rejected and the entry count is capped, both over the whole listing however - much of it gets written. ``only_lift`` narrows the write to the ``.lift`` - itself, which is all a streaming read needs; the aggregate uncompressed - size cap then has nothing to guard and is left to full extraction. Either - way every member written is capped as it streams, since a crafted archive's - declared size can lie. + much of it gets written. Bytes written are capped as they stream, since a + crafted archive's declared size can lie. + + ``only_lift`` narrows the write to the ``.lift`` itself, which is all a + streaming read needs. The aggregate declared-size check then has nothing to + guard and is left to full extraction, so a decompression bomb of a ``.lift`` + is refused only once it has written ``_MAX_UNCOMPRESSED_BYTES`` — the same + worst-case temp usage as a full extraction, reached by one member instead of + the whole package. """ dest_root = dest.resolve() try: @@ -112,7 +128,9 @@ def _safe_extract(zip_path: Path, dest: Path, *, only_lift: bool = False) -> str while chunk := source.read(_EXTRACT_CHUNK): written += len(chunk) if written > _MAX_UNCOMPRESSED_BYTES: - raise LiftParseError(_size_limit_message(zip_path)) + raise LiftParseError( + _size_limit_message(zip_path, info.filename if only_lift else None) + ) sink.write(chunk) except zipfile.BadZipFile as exc: raise LiftParseError(f"{zip_path.name}: not a valid zip archive: {exc}") from exc diff --git a/tests/test_zip.py b/tests/test_zip.py index 64f112e..ec9ee3a 100644 --- a/tests/test_zip.py +++ b/tests/test_zip.py @@ -1,6 +1,6 @@ import json import zipfile -from pathlib import Path +from pathlib import Path, PurePosixPath import pytest @@ -69,6 +69,19 @@ def test_zip_with_no_lift_errors(tmp_path: Path) -> None: sil_lift.load(path) +def test_zip_tolerates_one_lift_stored_twice(tmp_path: Path) -> None: + # Some writers append a record rather than replace it, leaving the same + # path in the listing twice; extraction overwrites, so it is one file. + path = tmp_path / "dup.zip" + with zipfile.ZipFile(path, "w") as archive: + for arcname, src in PAIR.items(): + archive.write(src, arcname) + archive.write(PAIR_DIR / "test20080407.lift", "test20080407.lift") + assert len(sil_lift.load(path).entries) == 1 + with lift_source(path) as lift_path: + assert lift_path.is_file() + + def test_zip_with_multiple_lift_errors(tmp_path: Path) -> None: path = tmp_path / "two.zip" with zipfile.ZipFile(path, "w") as archive: @@ -132,20 +145,25 @@ def test_cli_export_accepts_zip(tmp_path: Path) -> None: assert any("abat" in row for row in rows[1:]) # the entry id in full-entry.lift +_PKG = PurePosixPath("Pkg") # the package's wrapper folder inside the archive +_PKG_LIFT = _PKG / "test20080407.lift" + + def _package_with_media(dst: Path, *, media: bytes = b"\0" * 8192) -> Path: with zipfile.ZipFile(dst, "w") as archive: for arcname, src in PAIR.items(): - archive.write(src, f"Pkg/{arcname}") - archive.writestr("Pkg/audio/big.wav", media) + archive.write(src, (_PKG / arcname).as_posix()) + archive.writestr((_PKG / "audio" / "big.wav").as_posix(), media) return dst def test_lift_source_extracts_only_the_lift(tmp_path: Path) -> None: package = _package_with_media(tmp_path / "pkg.zip") with lift_source(package) as lift_path: - root = lift_path.parents[1] # the temp dir; the .lift sits under Pkg/ + root = lift_path.parents[len(_PKG_LIFT.parts) - 1] # up out of the member path + assert root.name.startswith("sil-lift-") # the temp dir, not somewhere above it written = sorted(p.relative_to(root).as_posix() for p in root.rglob("*") if p.is_file()) - assert written == ["Pkg/test20080407.lift"] # no media, no companion + assert written == [_PKG_LIFT.as_posix()] # no media, no companion def test_lift_source_skips_the_aggregate_size_cap( From 3d427740ade223cf0917e209298b423574c39e75 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 4 Aug 2026 16:30:15 -0400 Subject: [PATCH 3/8] Place companion casing where it is decided, not in the zip layer The note read as though matching member names case-insensitively created the companion-resolution question. It does not: a case-variant .lift filename raises the same question loaded from a plain folder, and _resolve_ranges is what answers it. Point there instead of describing an outcome this function does not control. Co-Authored-By: Claude Opus 5 (1M context) --- src/sil_lift/_zip.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/sil_lift/_zip.py b/src/sil_lift/_zip.py index 3b4dd26..8707b8e 100644 --- a/src/sil_lift/_zip.py +++ b/src/sil_lift/_zip.py @@ -62,11 +62,10 @@ def _select_lift_member(names: Iterable[str]) -> str: and extraction overwrites, so what lands on disk is a single file. The suffix match is case-insensitive, so a ``.LIFT`` member resolves the - same way on every platform rather than only where the filesystem is - case-folding. Such a package loads, but on a case-sensitive filesystem its - conventional sibling companion does not resolve (that candidate is derived - from the suffix, giving ``.LIFT-ranges``); a header ``range/@href`` still - resolves normally, which is how real exports reference the companion. + same way on every platform rather than only where the filesystem happens to + case-fold. How such a name then finds its companion is not this layer's + concern: it is the same question a case-variant ``.lift`` in a plain folder + raises, and ``Lexicon._resolve_ranges`` is where it is answered. """ lifts = list( dict.fromkeys( # de-duplicate, preserving listing order From 1600940c71fa6066c01c6ae6c29bb3817ee14aa4 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 4 Aug 2026 16:44:49 -0400 Subject: [PATCH 4/8] Describe streaming zip extraction under 0.1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.1.0 has not shipped, so there is no released behavior for an Unreleased entry to be changing — unpacking only the .lift for streaming reads is simply what the first release does. Fold it into the zipped-packages bullet and drop the note about how the member is located, an implementation detail with no released counterpart. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8627fb6..7296769 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,19 +18,6 @@ releases may contain breaking changes. ## [Unreleased] -### Changed - -- The streaming CLI commands (`stats`, `export`) now unpack only the `.lift` - member of a zipped package instead of the whole archive, so they no longer - write a media-heavy package's audio to a temporary directory just to read the - lexicon. The aggregate uncompressed-size cap still applies to the full - extraction that `load()`, `validate`, and `check-media` need; each file - written is capped in both paths. -- The `.lift` member of a zipped package is located by archive member name - rather than by globbing the extracted tree, making the suffix match - case-insensitive on every platform (previously a `.LIFT` member resolved on - Windows but not on Linux or macOS). - ## [0.1.0] - 2026-07-TBD ### Added @@ -72,8 +59,10 @@ releases may contain breaking changes. folder-wrapped layouts, junk entries like `__MACOSX` ignored), `Lexicon.save_zip()` writes one (carrying media, `WritingSystems/`, and other package files through verbatim); `validate`, `stats`, `check-media`, and - `export` accept a `.zip` path on the CLI. Extraction rejects path-traversal - members and is capped (entry count and a 10 GiB uncompressed total) against + `export` accept a `.zip` path on the CLI, and the streaming two (`stats`, + `export`) unpack only the `.lift` rather than the whole package. Extraction + rejects path-traversal members and is capped (entry count, a 10 GiB + uncompressed total for a full extraction, and every file written) against zip bombs. - Validation: `validate_file()` / `iter_problems()` / `Lexicon.iter_problems()` returning a `Problem` stream, each carrying the From be718c835a3133ae25f705a367ced7d3263381ea Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Wed, 12 Aug 2026 14:35:23 -0400 Subject: [PATCH 5/8] Say extract, zip bomb, and listing entry in the zip streaming text The prose around the streaming read used a second word for concepts the package already names: "unpack" beside "extract", "decompression bomb" beside the "zip bomb" the changelog and interop guide use, and "record" for a zip listing entry, which collides with the writer's own records. Settle on one term for each. Replace the shorthand where it stands in for the mechanism: the "aggregate declared-size check" is the up-front check of the whole listing's declared sizes, "the streaming two" are the streaming commands, and media that "dwarfs" the limit is media far larger than it. The test named after the aggregate cap follows. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 10 +++++----- docs/en/guides/cli.md | 2 +- docs/en/guides/lift-export-interop.md | 2 +- src/sil_lift/_zip.py | 15 ++++++++------- tests/test_zip.py | 11 ++++++----- 5 files changed, 21 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7296769..38be39d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,11 +59,11 @@ releases may contain breaking changes. folder-wrapped layouts, junk entries like `__MACOSX` ignored), `Lexicon.save_zip()` writes one (carrying media, `WritingSystems/`, and other package files through verbatim); `validate`, `stats`, `check-media`, and - `export` accept a `.zip` path on the CLI, and the streaming two (`stats`, - `export`) unpack only the `.lift` rather than the whole package. Extraction - rejects path-traversal members and is capped (entry count, a 10 GiB - uncompressed total for a full extraction, and every file written) against - zip bombs. + `export` accept a `.zip` path on the CLI, and the streaming commands + (`stats`, `export`) extract only the `.lift` rather than the whole package. + Extraction rejects path-traversal members and is capped (entry count, + a 10 GiB uncompressed total for a full extraction, and every file written) + against zip bombs. - Validation: `validate_file()` / `iter_problems()` / `Lexicon.iter_problems()` returning a `Problem` stream, each carrying the file, entry, and line it concerns. RELAX NG layer with two documented diff --git a/docs/en/guides/cli.md b/docs/en/guides/cli.md index 8f8401e..7953a37 100644 --- a/docs/en/guides/cli.md +++ b/docs/en/guides/cli.md @@ -21,7 +21,7 @@ sil-lift export PATH [-o OUT] [--langs L] [--tsv] `sort` rewrites only the `.lift` file; companion `.lift-ranges` files are left untouched (sort those separately with the `RangesFile` API). -`validate`, `stats`, `check-media`, and `export` also accept a zipped LIFT package (a `.zip` in either layout — files at the archive root, or nested under one top-level folder); it is extracted to a temporary directory and discarded when the command finishes. The streaming commands `stats` and `export` extract only the `.lift` itself, so they stay cheap on media-heavy packages; `validate` and `check-media` need the whole folder and unpack it. +`validate`, `stats`, `check-media`, and `export` also accept a zipped LIFT package (a `.zip` in either layout — files at the archive root, or nested under one top-level folder); it is extracted to a temporary directory and discarded when the command finishes. The streaming commands `stats` and `export` extract only the `.lift` itself, so they stay cheap on media-heavy packages; `validate` and `check-media` need the whole folder and extract all of it. Examples: diff --git a/docs/en/guides/lift-export-interop.md b/docs/en/guides/lift-export-interop.md index fc237c5..08aad04 100644 --- a/docs/en/guides/lift-export-interop.md +++ b/docs/en/guides/lift-export-interop.md @@ -8,7 +8,7 @@ Writing LIFT is much easier than parsing it: an exporter only emits the subset o LIFT is usually moved around as a single `.zip` — FieldWorks and The Combine both import and export that way — so `sil-lift` reads and writes zipped packages directly, in either layout the ecosystem uses: the files at the archive root, or nested under one top-level folder. -- **Read:** `sil_lift.load("package.zip")` extracts to a temp directory, locates the single `.lift`, and loads it (companions and media resolve as usual). The `validate`, `stats`, `check-media`, and `export` CLI commands accept a `.zip` path too, so the gate below runs against a package as-is; `stats` and `export` stream, and unpack only the `.lift` rather than the whole package. Extraction is hardened against hostile archives — path-traversal members are refused, the entry count is capped, and every file written is capped at 10 GiB against zip bombs (a full extraction also checks the package's total against that limit up front). +- **Read:** `sil_lift.load("package.zip")` extracts to a temp directory, locates the single `.lift`, and loads it (companions and media resolve as usual). The `validate`, `stats`, `check-media`, and `export` CLI commands accept a `.zip` path too, so the gate below runs against a package as-is; `stats` and `export` stream, and extract only the `.lift` rather than the whole package. Extraction is hardened against hostile archives — path-traversal members are refused, the entry count is capped, and every file written is capped at 10 GiB against zip bombs (a full extraction also checks the package's total against that limit up front). - **Write:** `Lexicon.save_zip("out.zip", wrap_folder="MyDict")` packages the `.lift`, its `.lift-ranges`, and every other file in the source folder (media, `WritingSystems/`, `consent/`, ...) into a zip. `wrap_folder` defaults to a top-level folder named after the zip (the FieldWorks/Combine import convention); pass `False` for a flat archive. The `.lift` and `.lift-ranges` keep their byte-fidelity inside the package; the zip container itself is not byte-reproducible. diff --git a/src/sil_lift/_zip.py b/src/sil_lift/_zip.py index 8707b8e..47f3036 100644 --- a/src/sil_lift/_zip.py +++ b/src/sil_lift/_zip.py @@ -58,8 +58,9 @@ def _select_lift_member(names: Iterable[str]) -> str: Handles both the flat and folder-wrapped layouts, and ignores junk such as ``__MACOSX`` and dotfile entries that some zip tools add. One path stored - twice counts once — some writers append a record rather than replace it, - and extraction overwrites, so what lands on disk is a single file. + twice counts once — some writers add a second listing entry rather than + replacing the first, and extraction overwrites, so what lands on disk is a + single file. The suffix match is case-insensitive, so a ``.LIFT`` member resolves the same way on every platform rather than only where the filesystem happens to @@ -94,11 +95,11 @@ def _safe_extract(zip_path: Path, dest: Path, *, only_lift: bool = False) -> str crafted archive's declared size can lie. ``only_lift`` narrows the write to the ``.lift`` itself, which is all a - streaming read needs. The aggregate declared-size check then has nothing to - guard and is left to full extraction, so a decompression bomb of a ``.lift`` - is refused only once it has written ``_MAX_UNCOMPRESSED_BYTES`` — the same - worst-case temp usage as a full extraction, reached by one member instead of - the whole package. + streaming read needs. The up-front check of the whole listing's declared + sizes then has nothing to guard and is left to full extraction, so a zip + bomb hidden in the ``.lift`` is refused only once it has written + ``_MAX_UNCOMPRESSED_BYTES`` — the same worst-case temp usage as a full + extraction, reached by one member instead of the whole package. """ dest_root = dest.resolve() try: diff --git a/tests/test_zip.py b/tests/test_zip.py index ec9ee3a..4c4108f 100644 --- a/tests/test_zip.py +++ b/tests/test_zip.py @@ -70,8 +70,9 @@ def test_zip_with_no_lift_errors(tmp_path: Path) -> None: def test_zip_tolerates_one_lift_stored_twice(tmp_path: Path) -> None: - # Some writers append a record rather than replace it, leaving the same - # path in the listing twice; extraction overwrites, so it is one file. + # Some writers add a second listing entry rather than replacing the first, + # leaving the same path in the listing twice; extraction overwrites, so it + # is one file. path = tmp_path / "dup.zip" with zipfile.ZipFile(path, "w") as archive: for arcname, src in PAIR.items(): @@ -166,12 +167,12 @@ def test_lift_source_extracts_only_the_lift(tmp_path: Path) -> None: assert written == [_PKG_LIFT.as_posix()] # no media, no companion -def test_lift_source_skips_the_aggregate_size_cap( +def test_lift_source_skips_the_whole_package_size_cap( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: # The cap guards bytes written to disk, and a streaming read writes one - # member — so a package whose media dwarfs the limit still streams, while - # the full extraction behind load() refuses it. + # member — so a package whose media is far larger than the limit still + # streams, while the full extraction behind load() refuses it. package = _package_with_media(tmp_path / "pkg.zip") monkeypatch.setattr("sil_lift._zip._MAX_UNCOMPRESSED_BYTES", 4000) with lift_source(package) as lift_path: From d1928d836a162a4ad20525470b49cb84f88dfbbf Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Wed, 12 Aug 2026 15:36:40 -0400 Subject: [PATCH 6/8] Check the streamed .lift's declared size before extracting it A streaming read skips the whole-listing size pre-check, since it writes one member rather than the package. That left an honestly declared zip bomb in the .lift to stream up to the 10 GiB cap into the temp directory before being refused; checking that one member's declared size instead refuses it with nothing written. The count as bytes stream still guards a declared size that lies. Resolving the member through getinfo() also collapses a path stored twice in the listing to the single entry extraction would leave on disk, so it is no longer written -- and counted against the cap -- twice. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 7 ++++--- docs/en/guides/lift-export-interop.md | 9 +++++++-- src/sil_lift/_zip.py | 22 ++++++++++++--------- tests/test_zip.py | 28 +++++++++++++++++++++++++-- 4 files changed, 50 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38be39d..3f55088 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,9 +61,10 @@ releases may contain breaking changes. package files through verbatim); `validate`, `stats`, `check-media`, and `export` accept a `.zip` path on the CLI, and the streaming commands (`stats`, `export`) extract only the `.lift` rather than the whole package. - Extraction rejects path-traversal members and is capped (entry count, - a 10 GiB uncompressed total for a full extraction, and every file written) - against zip bombs. + Extraction rejects path-traversal members and is capped against zip bombs: + entry count; a 10 GiB declared uncompressed size (of the whole listing for + a full extraction, of the `.lift` alone for a streaming one); and the bytes + written as they stream (since a declared size can lie). - Validation: `validate_file()` / `iter_problems()` / `Lexicon.iter_problems()` returning a `Problem` stream, each carrying the file, entry, and line it concerns. RELAX NG layer with two documented diff --git a/docs/en/guides/lift-export-interop.md b/docs/en/guides/lift-export-interop.md index 08aad04..1a65d2f 100644 --- a/docs/en/guides/lift-export-interop.md +++ b/docs/en/guides/lift-export-interop.md @@ -8,8 +8,13 @@ Writing LIFT is much easier than parsing it: an exporter only emits the subset o LIFT is usually moved around as a single `.zip` — FieldWorks and The Combine both import and export that way — so `sil-lift` reads and writes zipped packages directly, in either layout the ecosystem uses: the files at the archive root, or nested under one top-level folder. -- **Read:** `sil_lift.load("package.zip")` extracts to a temp directory, locates the single `.lift`, and loads it (companions and media resolve as usual). The `validate`, `stats`, `check-media`, and `export` CLI commands accept a `.zip` path too, so the gate below runs against a package as-is; `stats` and `export` stream, and extract only the `.lift` rather than the whole package. Extraction is hardened against hostile archives — path-traversal members are refused, the entry count is capped, and every file written is capped at 10 GiB against zip bombs (a full extraction also checks the package's total against that limit up front). -- **Write:** `Lexicon.save_zip("out.zip", wrap_folder="MyDict")` packages the `.lift`, its `.lift-ranges`, and every other file in the source folder (media, `WritingSystems/`, `consent/`, ...) into a zip. `wrap_folder` defaults to a top-level folder named after the zip (the FieldWorks/Combine import convention); pass `False` for a flat archive. +- **Read:** `sil_lift.load("package.zip")` extracts to a temp directory, locates the single `.lift`, and loads it (companions and media resolve as usual). + - The `validate`, `stats`, `check-media`, and `export` CLI commands accept a `.zip` path too, so the gate below runs against a package as-is. + - `stats` and `export` stream, and extract only the `.lift` rather than the whole package. + - Extraction is hardened against hostile archives: path-traversal members are refused, and the entry count is capped. + - 10 GiB caps the bytes written, against zip bombs. The declared sizes are checked against that limit up front — the whole package for a full extraction, the `.lift` alone for a streaming one — and the bytes are counted again as they stream, since a declared size can lie. +- **Write:** `Lexicon.save_zip("out.zip", wrap_folder="MyDict")` packages the `.lift`, its `.lift-ranges`, and every other file in the source folder (media, `WritingSystems/`, `consent/`, ...) into a zip. + - `wrap_folder` defaults to a top-level folder named after the zip (the FieldWorks/Combine import convention); pass `False` for a flat archive. The `.lift` and `.lift-ranges` keep their byte-fidelity inside the package; the zip container itself is not byte-reproducible. diff --git a/src/sil_lift/_zip.py b/src/sil_lift/_zip.py index 47f3036..c986fab 100644 --- a/src/sil_lift/_zip.py +++ b/src/sil_lift/_zip.py @@ -58,9 +58,9 @@ def _select_lift_member(names: Iterable[str]) -> str: Handles both the flat and folder-wrapped layouts, and ignores junk such as ``__MACOSX`` and dotfile entries that some zip tools add. One path stored - twice counts once — some writers add a second listing entry rather than - replacing the first, and extraction overwrites, so what lands on disk is a - single file. + multiple times counts once — some writers add a second listing entry rather + than replacing the first, and extraction overwrites, so what lands on disk + is a single file. The suffix match is case-insensitive, so a ``.LIFT`` member resolves the same way on every platform rather than only where the filesystem happens to @@ -95,11 +95,10 @@ def _safe_extract(zip_path: Path, dest: Path, *, only_lift: bool = False) -> str crafted archive's declared size can lie. ``only_lift`` narrows the write to the ``.lift`` itself, which is all a - streaming read needs. The up-front check of the whole listing's declared - sizes then has nothing to guard and is left to full extraction, so a zip - bomb hidden in the ``.lift`` is refused only once it has written - ``_MAX_UNCOMPRESSED_BYTES`` — the same worst-case temp usage as a full - extraction, reached by one member instead of the whole package. + streaming read needs. The whole listing's declared sizes then have nothing + to guard, so the up-front check covers that one member instead: a zip bomb + that declares itself is refused with nothing written, and one that lies + about its size is still caught as it streams. """ dest_root = dest.resolve() try: @@ -114,7 +113,12 @@ def _safe_extract(zip_path: Path, dest: Path, *, only_lift: bool = False) -> str ) member = _select_lift_member(info.filename for info in infos) if only_lift: - infos = [info for info in infos if info.filename == member] + # getinfo() takes the last listing entry for a path stored + # multiple times, the one that's left after full extraction. + lift_info = archive.getinfo(member) + if lift_info.file_size > _MAX_UNCOMPRESSED_BYTES: + raise LiftParseError(_size_limit_message(zip_path, member)) + infos = [lift_info] elif sum(info.file_size for info in infos) > _MAX_UNCOMPRESSED_BYTES: raise LiftParseError(_size_limit_message(zip_path)) written = 0 diff --git a/tests/test_zip.py b/tests/test_zip.py index 4c4108f..d599f01 100644 --- a/tests/test_zip.py +++ b/tests/test_zip.py @@ -71,8 +71,7 @@ def test_zip_with_no_lift_errors(tmp_path: Path) -> None: def test_zip_tolerates_one_lift_stored_twice(tmp_path: Path) -> None: # Some writers add a second listing entry rather than replacing the first, - # leaving the same path in the listing twice; extraction overwrites, so it - # is one file. + # so the same path appears twice; extraction overwrites, leaving one file. path = tmp_path / "dup.zip" with zipfile.ZipFile(path, "w") as archive: for arcname, src in PAIR.items(): @@ -190,6 +189,31 @@ def test_lift_source_caps_an_oversized_lift_member( pass +def test_lift_source_refuses_a_declared_oversized_lift_unextracted( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + package = tmp_path / "declared.zip" + with zipfile.ZipFile(package, "w", zipfile.ZIP_DEFLATED) as archive: + archive.writestr("Pkg/big.lift", b"" * 10) + archive.getinfo("Pkg/big.lift").file_size = 10**9 + monkeypatch.setattr("sil_lift._zip._MAX_UNCOMPRESSED_BYTES", 4000) + with pytest.raises(sil_lift.LiftParseError, match=r"big\.lift' alone"), lift_source(package): + pass + + +def test_lift_source_counts_a_lift_stored_twice_once( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + package = tmp_path / "dup.zip" + with zipfile.ZipFile(package, "w") as archive: + for _ in range(2): + archive.write(PAIR_DIR / "test20080407.lift", "Pkg/test20080407.lift") + size = (PAIR_DIR / "test20080407.lift").stat().st_size + monkeypatch.setattr("sil_lift._zip._MAX_UNCOMPRESSED_BYTES", 2 * size - 1) + with lift_source(package) as lift_path: # one copy written, not two + assert lift_path.stat().st_size == size + + def test_lift_source_rejects_path_traversal(tmp_path: Path) -> None: package = tmp_path / "evil.zip" with zipfile.ZipFile(package, "w") as archive: From 0b27a7e6209cfd25d7e66dac804bf426c0d57210 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 13 Aug 2026 15:30:46 -0400 Subject: [PATCH 7/8] State the zip extraction limits rather than the threat model The changelog and interop guide explained why the guards exist -- hostile archives, zip bombs, a declared size that can lie -- and how the check is split between the declared sizes and the bytes as they stream. None of that changes what a reader does: the limits and the refusal do. Give the two numbers, say a package over either is refused, and say the same of one whose member paths escape the extraction directory. The reasoning stays in _zip.py, beside the constants and the extraction it governs. Keep the streaming narrowing's user-visible consequence, which the cap text carried: extracting only the .lift is what makes stats and export cheap on a media-heavy package, and it is why their limit applies to that one file rather than to the media beside it. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 7 +++---- docs/en/guides/lift-export-interop.md | 5 ++--- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f55088..3d1dd95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,10 +61,9 @@ releases may contain breaking changes. package files through verbatim); `validate`, `stats`, `check-media`, and `export` accept a `.zip` path on the CLI, and the streaming commands (`stats`, `export`) extract only the `.lift` rather than the whole package. - Extraction rejects path-traversal members and is capped against zip bombs: - entry count; a 10 GiB declared uncompressed size (of the whole listing for - a full extraction, of the `.lift` alone for a streaming one); and the bytes - written as they stream (since a declared size can lie). + Extraction is capped at 100,000 members and 10 GiB (the whole package for a + full extraction, the `.lift` alone for a streaming one), and refuses members + whose paths escape the extraction directory. - Validation: `validate_file()` / `iter_problems()` / `Lexicon.iter_problems()` returning a `Problem` stream, each carrying the file, entry, and line it concerns. RELAX NG layer with two documented diff --git a/docs/en/guides/lift-export-interop.md b/docs/en/guides/lift-export-interop.md index 1a65d2f..1777e35 100644 --- a/docs/en/guides/lift-export-interop.md +++ b/docs/en/guides/lift-export-interop.md @@ -10,9 +10,8 @@ LIFT is usually moved around as a single `.zip` — FieldWorks and The Combine b - **Read:** `sil_lift.load("package.zip")` extracts to a temp directory, locates the single `.lift`, and loads it (companions and media resolve as usual). - The `validate`, `stats`, `check-media`, and `export` CLI commands accept a `.zip` path too, so the gate below runs against a package as-is. - - `stats` and `export` stream, and extract only the `.lift` rather than the whole package. - - Extraction is hardened against hostile archives: path-traversal members are refused, and the entry count is capped. - - 10 GiB caps the bytes written, against zip bombs. The declared sizes are checked against that limit up front — the whole package for a full extraction, the `.lift` alone for a streaming one — and the bytes are counted again as they stream, since a declared size can lie. + - `stats` and `export` stream, and extract only the `.lift` rather than the whole package — so they stay cheap on a media-heavy one, and the extraction limit applies to the `.lift` alone rather than to everything beside it. + - Extraction is capped at 10 GiB and 100,000 members; a package over either limit is refused with a `LiftParseError`, as is one whose member paths escape the extraction directory. - **Write:** `Lexicon.save_zip("out.zip", wrap_folder="MyDict")` packages the `.lift`, its `.lift-ranges`, and every other file in the source folder (media, `WritingSystems/`, `consent/`, ...) into a zip. - `wrap_folder` defaults to a top-level folder named after the zip (the FieldWorks/Combine import convention); pass `False` for a flat archive. From bf34666fa3084232414b556e5c596f76c14c6370 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 13 Aug 2026 16:09:44 -0400 Subject: [PATCH 8/8] Pin the yielded .lift path to the one extraction wrote The path a zip read hands back is built from the archive member name rather than found on disk, so the write and the yield share a derivation that nothing checks still agrees. A backslash separator is where the two would come apart: POSIX takes it as one filename and Windows as a directory split, so normalizing it on either side alone leaves the yield pointing at a path nothing wrote. Extract a member stored with that separator and read the .lift's bytes back through the yielded path. Co-Authored-By: Claude Opus 5 (1M context) --- tests/test_zip.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_zip.py b/tests/test_zip.py index d599f01..2a35ccf 100644 --- a/tests/test_zip.py +++ b/tests/test_zip.py @@ -223,6 +223,18 @@ def test_lift_source_rejects_path_traversal(tmp_path: Path) -> None: pass +def test_lift_source_yields_the_path_extraction_wrote(tmp_path: Path) -> None: + # The yielded path is built from the member name, not found on disk, so + # normalizing a separator on one side alone breaks it. POSIX only: zipfile + # rewrites os.sep to "/", so a backslash never reaches the code on Windows. + package = tmp_path / "backslash.zip" + source = PAIR_DIR / "test20080407.lift" + with zipfile.ZipFile(package, "w") as archive: + archive.write(source, "Pkg\\test20080407.lift") + with lift_source(package) as lift_path: + assert lift_path.read_bytes() == source.read_bytes() + + def test_lift_source_passes_a_plain_lift_through() -> None: with lift_source(PAIR_DIR / "test20080407.lift") as lift_path: assert lift_path == PAIR_DIR / "test20080407.lift"