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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions