Skip to content

bug: vouch export produces self-invalid bundles on Windows (manifest uses backslash separator, tar uses forward slash) #72

Description

@galuis116

What happened

On Windows, vouch export writes bundle paths into manifest.json
using the native \ separator while tarfile records member names
with /. Every bundle vouch produces on Windows is therefore
self-invalid:

  • manifest.counts is {claims: 0, pages: 0, sources: 0, ...} for
    every KB regardless of content — the per-subdir counter at
    src/vouch/bundle.py:90 uses f["path"].startswith(f"{sub}/"),
    which never matches sources\....
  • vouch export-check on the bundle vouch just produced returns
    ok: false with file in bundle but not in manifest for every
    tar member and manifest lists missing file for every manifest
    entry.
  • vouch import-apply is a silent no-op: the manifest-paths set
    lookup at src/vouch/bundle.py:264 is keyed on the \-flavoured
    strings, so every tar member is treated as "not in manifest" and
    skipped.

Portable bundles are a load-bearing feature of vouch (README:
"Portable bundles"), so this corrupts the only mechanism for moving
a KB between repos on any Windows host.

What you expected

vouch export produces a bundle whose manifest.json matches the
member names inside the tarball, vouch export-check reports
ok: true, and vouch import-apply restores every artifact —
behaviour identical to Linux/macOS.

Reproduction

Minimum sequence on Windows (PowerShell, Python 3.12, vouch 0.0.1
at commit c3accb6, the merge of #46):

PS> $env:PYTHONUTF8 = '1'            # separate Windows bug, needed for `vouch --help`
PS> $env:VOUCH_AGENT = 'repro-bot'
PS> mkdir $env:TEMP\vouch-repro; cd $env:TEMP\vouch-repro

PS> vouch init
Initialised KB at C:\Users\Administrator\AppData\Local\Temp\vouch-repro\.vouch

PS> 'doc body' | Out-File -Encoding utf8 doc.txt
PS> vouch source add doc.txt --title 'Repro doc'
f87b4431a0ad0a4bb9f002bd56cfa9ab07791783684ca3df80be249d434134f4

PS> vouch export --out kb.tar.gz
{
  "bundle_id": "bee1189d498a3539b4c695ac7b81b1a386e4d2bff6c68edf481e26e9a409f7c4",
  "files": 3,
  "out": "kb.tar.gz"
}

PS> vouch export-check kb.tar.gz
{
  "bundle_id": "bee1189d...",
  "files_checked": 3,
  "issues": [
    "file in bundle but not in manifest: sources/f87b4431.../content",
    "file in bundle but not in manifest: sources/f87b4431.../meta.yaml",
    "manifest lists missing file: sources\\f87b4431...\\content",
    "manifest lists missing file: sources\\f87b4431...\\meta.yaml"
  ],
  "ok": false
}

The asymmetry between sources/... (tar member) and sources\...
(manifest path) is the root cause.

A round-trip with vouch import-apply into a fresh .vouch/
writes nothing — every member is silently skipped because its
/-shaped name isn't in the manifest's \-shaped path set.

The pytest suite hits the same bug via
tests/test_bundle.py::test_export_import_round_trip and
tests/test_bundle.py::test_import_apply_skips_conflicts_by_default,
both of which fail on Windows but pass on the Linux-only GitHub
Actions matrix (.github/workflows/ci.yml:11runs-on: ubuntu-latest).

Environment

.vouch/ state

$ vouch doctor

Not run on the repro KB — the failure is observable from export
/ export-check alone and is independent of any .vouch/
content.

Anything else

Suggested fix

build_manifest and export() should normalise paths to POSIX
when emitting the manifest and the tar arcname. Two small changes
in src/vouch/bundle.py:

@@ src/vouch/bundle.py:73
     for rel, abs_path in _iter_export_files(kb_dir):
         data = abs_path.read_bytes()
         files.append({
-            "path": str(rel),
+            "path": rel.as_posix(),
             "size": len(data),
             "sha256": sha256_hex(data),
         })

@@ src/vouch/bundle.py:105
     with tarfile.open(dest, "w:gz") as tar:
         for rel, abs_path in _iter_export_files(kb_dir):
-            tar.add(abs_path, arcname=str(rel))
+            tar.add(abs_path, arcname=rel.as_posix())

POSIX separators are the on-the-wire format anyway: tarfile uses
/, _unsafe_name_reason already checks startswith("/"), and
the startswith(f"{sub}/") counter at line 90 assumes the same.
No existing Linux/macOS bundles change shape; Windows bundles
become valid.

Why it's worth fixing

  • Silent data-shape corruption (no exception, just ok: false on
    re-check and zero rows on import) is the highest-risk class of
    bug for a tool whose marketing surface includes "portable
    bundles" and per-file sha256 manifests.
  • Root cause is one method, two lines, one Path API call.
  • Adding windows-latest to the runs-on matrix in
    .github/workflows/ci.yml would catch this and several adjacent
    Windows bugs (e.g. os.O_NOFOLLOW use in
    src/vouch/storage.py:137, and the UnicodeEncodeError on
    vouch --help under cp1252 — separate reports).

Checked for duplicates

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions