Skip to content

Commit e2c8b21

Browse files
committed
Add Windows long-path proof
1 parent 7e13164 commit e2c8b21

2 files changed

Lines changed: 267 additions & 0 deletions

File tree

‎.github/scripts/long_path_proof.py‎

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
r"""
2+
Windows long-path proof for python-api-client.
3+
4+
Builds a project whose absolute paths exceed MAX_PATH (260 chars) and exercises it in three layers:
5+
python - mergin's own file handling (open project, metadata, inspect_files, checksum)
6+
geodiff - geodiff operations called with plain paths, exactly as mergin calls them
7+
e2e - get_push_changes(): both layers together must produce a diff for a modified gpkg,
8+
and the payload sent to the server must not contain the \\?\ prefix
9+
10+
Meant to run on Windows with OS long-path support OFF (the Windows default). Elsewhere it runs as a
11+
smoke test of the harness only, since long_path() is a no-op off Windows.
12+
13+
Exit codes: 0 = every layer behaved as expected, 1 = mismatch, 2 = inconclusive (long paths enabled).
14+
"""
15+
16+
import argparse
17+
import ctypes
18+
import json
19+
import os
20+
import shutil
21+
import sys
22+
import tempfile
23+
24+
PREFIX = "\\\\?\\"
25+
26+
parser = argparse.ArgumentParser()
27+
parser.add_argument("--code", required=True, help="checkout of python-api-client to test")
28+
parser.add_argument("--expect-python", choices=["pass", "fail"], default="pass")
29+
parser.add_argument("--expect-geodiff", choices=["pass", "fail"], default="pass")
30+
args = parser.parse_args()
31+
32+
code = os.path.abspath(args.code)
33+
sys.path.insert(0, code)
34+
import pygeodiff # noqa: E402 (site-packages; mergin/deps is never present in a fresh checkout)
35+
from mergin.merginproject import MerginProject # noqa: E402
36+
from mergin.utils import generate_checksum # noqa: E402
37+
38+
ON_WINDOWS = os.name == "nt"
39+
40+
41+
def ext(path):
42+
"""Harness-only prefixing, so fixtures can be created no matter what the code under test does."""
43+
if not ON_WINDOWS or path.startswith(PREFIX):
44+
return path
45+
return PREFIX + os.path.abspath(path)
46+
47+
48+
def long_paths_enabled():
49+
if not ON_WINDOWS:
50+
return None
51+
fn = ctypes.WinDLL("ntdll").RtlAreLongPathsEnabled
52+
fn.restype = ctypes.c_ubyte
53+
return bool(fn())
54+
55+
56+
# fixture: every folder name < 255 chars, full paths > 260 chars
57+
root = tempfile.mkdtemp(prefix="lp_")
58+
proj = os.path.join(root, "a" * 120, "b" * 120)
59+
data = os.path.join(code, "mergin", "test", "test_data")
60+
base_src = os.path.join(data, "base.gpkg")
61+
modified_src = os.path.join(data, "inserted_1_A.gpkg")
62+
live = os.path.join(proj, "survey", "points.gpkg")
63+
basefile = os.path.join(proj, ".mergin", "survey", "points.gpkg")
64+
os.makedirs(ext(os.path.dirname(live)))
65+
shutil.copy(ext(modified_src), ext(live))
66+
67+
print(f"python {sys.version.split()[0]} | pygeodiff {pygeodiff.GeoDiff().version()} | code {code}")
68+
print(f"OS long-path support enabled for this process: {long_paths_enabled()}")
69+
print(f"longest fixture path: {len(basefile)} chars (MAX_PATH is 260)\n")
70+
71+
results = []
72+
state = {}
73+
74+
75+
def step(layer, name, fn):
76+
try:
77+
detail = fn() or ""
78+
results.append((layer, name, True, detail))
79+
except Exception as e: # noqa: BLE001 - every failure is a result here
80+
results.append((layer, name, False, f"{type(e).__name__}: {e}"[:200]))
81+
82+
83+
def need_project():
84+
if "mp" not in state:
85+
raise RuntimeError("skipped: project could not be opened")
86+
return state["mp"]
87+
88+
89+
def open_project():
90+
state["mp"] = MerginProject(proj)
91+
92+
93+
def place_basefile():
94+
# mergin creates .mergin itself (step above); the harness only drops the basefile in
95+
os.makedirs(ext(os.path.dirname(basefile)), exist_ok=True)
96+
shutil.copy(ext(base_src), ext(basefile))
97+
98+
99+
def write_read_metadata():
100+
meta = {
101+
"name": "proof",
102+
"namespace": "test",
103+
"version": "v1",
104+
"files": [
105+
{
106+
"path": "survey/points.gpkg",
107+
"checksum": generate_checksum(ext(base_src)),
108+
"size": os.path.getsize(ext(base_src)),
109+
"mtime": "2026-01-01T00:00:00+00:00",
110+
"version": "v1",
111+
}
112+
],
113+
}
114+
MerginProject.write_metadata(proj, meta)
115+
assert need_project().metadata["name"] == "proof"
116+
117+
118+
def inspect():
119+
files = need_project().inspect_files()
120+
paths = [f["path"] for f in files]
121+
assert paths == ["survey/points.gpkg"], paths
122+
return f"paths={paths}"
123+
124+
125+
def checksum():
126+
assert generate_checksum(live) == generate_checksum(ext(live))
127+
128+
129+
def gd_copy():
130+
copy = os.path.join(proj, ".mergin", "copy.gpkg") # inside .mergin so inspect_files ignores it
131+
pygeodiff.GeoDiff().make_copy_sqlite(live, copy)
132+
assert os.path.exists(ext(copy))
133+
134+
135+
def gd_diff():
136+
diff = basefile + "-diff-proof"
137+
gd = pygeodiff.GeoDiff()
138+
gd.create_changeset(basefile, live, diff)
139+
assert gd.has_changes(diff), "changeset has no changes"
140+
141+
142+
def push_changes():
143+
changes = need_project().get_push_changes()
144+
updated = [f for f in changes["updated"] if f["path"] == "survey/points.gpkg"]
145+
assert updated, f"gpkg not detected as updated: {changes}"
146+
assert "diff" in updated[0], "no diff created - would fall back to full upload"
147+
leaked = [s for s in strings(changes) if PREFIX in s]
148+
assert not leaked, f"prefix leaked into server payload: {leaked}"
149+
return f"diff={updated[0]['diff']['path']}"
150+
151+
152+
def sparse_write():
153+
need_project().write_file_filter({"include": None, "exclude": ["notes/*"]})
154+
155+
156+
def sparse_read():
157+
# harness writes the filter and a decoy, so this checks the read side on its own:
158+
# an unprefixed exists() on Windows returns False instead of raising, silently dropping the filter
159+
flt = {"include": None, "exclude": ["notes/*"]}
160+
with open(ext(os.path.join(proj, ".mergin", "file_filter.json")), "w") as f:
161+
json.dump(flt, f)
162+
decoy = os.path.join(proj, "notes", "skip.txt")
163+
os.makedirs(ext(os.path.dirname(decoy)), exist_ok=True)
164+
with open(ext(decoy), "w") as f:
165+
f.write("out of scope")
166+
mp = need_project()
167+
assert mp.file_filter() == flt, f"filter not read back: {mp.file_filter()}"
168+
paths = [f["path"] for f in mp.inspect_files()]
169+
assert "notes/skip.txt" not in paths, f"out-of-scope file not filtered: {paths}"
170+
assert "survey/points.gpkg" in paths, f"in-scope file missing: {paths}"
171+
172+
173+
def strings(obj):
174+
if isinstance(obj, str):
175+
yield obj
176+
elif isinstance(obj, dict):
177+
for v in obj.values():
178+
yield from strings(v)
179+
elif isinstance(obj, (list, tuple)):
180+
for v in obj:
181+
yield from strings(v)
182+
183+
184+
step("python", "open project (dirs + log file)", open_project)
185+
place_basefile()
186+
step("python", "write + read mergin.json", write_read_metadata)
187+
step("python", "inspect_files (walk, size, mtime)", inspect)
188+
step("python", "generate_checksum", checksum)
189+
step("geodiff", "make_copy_sqlite", gd_copy)
190+
step("geodiff", "create_changeset + has_changes", gd_diff)
191+
step("e2e", "get_push_changes -> diff, no prefix in payload", push_changes)
192+
step("python", "sparse checkout: write_file_filter", sparse_write)
193+
step("python", "sparse checkout: filter read back + honoured", sparse_read)
194+
195+
if "mp" in state:
196+
state["mp"].remove_logging_handler() # release the log file so cleanup works on Windows
197+
shutil.rmtree(ext(root), ignore_errors=True)
198+
199+
layer_ok = {}
200+
for layer, name, ok, detail in results:
201+
layer_ok[layer] = layer_ok.get(layer, True) and ok
202+
print(f" [{'PASS' if ok else 'FAIL'}] {layer:7} {name}" + (f" -- {detail}" if detail else ""))
203+
204+
expected = {"python": args.expect_python == "pass", "geodiff": args.expect_geodiff == "pass"}
205+
expected["e2e"] = expected["python"] and expected["geodiff"]
206+
print()
207+
mismatch = False
208+
for layer in ("python", "geodiff", "e2e"):
209+
got, want = layer_ok[layer], expected[layer]
210+
verdict = "as expected" if (got == want or not ON_WINDOWS) else "UNEXPECTED"
211+
mismatch |= verdict == "UNEXPECTED"
212+
print(f"{layer:7}: {'pass' if got else 'fail'} (expected {'pass' if want else 'fail'}) -> {verdict}")
213+
214+
if not ON_WINDOWS:
215+
ok = all(layer_ok.values())
216+
print("\nNot Windows: harness smoke test only (long_path() is a no-op here).", "OK" if ok else "HARNESS BROKEN")
217+
sys.exit(0 if ok else 1)
218+
if long_paths_enabled() and not all(expected.values()):
219+
print("\nINCONCLUSIVE: OS long-path support is on, so the 'fail' cases cannot reproduce.")
220+
sys.exit(2)
221+
sys.exit(1 if mismatch else 0)
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
name: Windows long-path proof
2+
3+
on:
4+
push:
5+
branches: [test/long-path-windows]
6+
workflow_dispatch:
7+
8+
jobs:
9+
proof:
10+
name: ${{ matrix.code }} + geodiff ${{ matrix.geodiff }}
11+
runs-on: windows-latest
12+
strategy:
13+
fail-fast: false
14+
matrix:
15+
include:
16+
- { code: master, geodiff: "2.3.1", expect_python: fail, expect_geodiff: fail }
17+
- { code: master, geodiff: "2.3.2", expect_python: fail, expect_geodiff: pass }
18+
- { code: pr, geodiff: "2.3.1", expect_python: pass, expect_geodiff: fail }
19+
- { code: pr, geodiff: "2.3.2", expect_python: pass, expect_geodiff: pass }
20+
steps:
21+
- name: Turn OS long-path support off, as on a default Windows install
22+
shell: pwsh
23+
run: Set-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' -Name LongPathsEnabled -Value 0 -Type DWord
24+
25+
- uses: actions/checkout@v4
26+
with:
27+
path: pr
28+
29+
- uses: actions/checkout@v4
30+
with:
31+
ref: master
32+
path: master
33+
34+
- uses: actions/setup-python@v5
35+
with:
36+
python-version: "3.12"
37+
38+
- name: Install dependencies
39+
run: python -m pip install python-dateutil pytz click truststore "pygeodiff==${{ matrix.geodiff }}"
40+
41+
- name: Run proof
42+
run: >
43+
python pr/.github/scripts/long_path_proof.py
44+
--code ${{ matrix.code }}
45+
--expect-python ${{ matrix.expect_python }}
46+
--expect-geodiff ${{ matrix.expect_geodiff }}

0 commit comments

Comments
 (0)