DEV Community

xiaoxu
xiaoxu

Posted on

Preventing Silent File Loss from Duplicate ZIP Entry Names

Preventing Silent File Loss from Duplicate ZIP Entry Names

Why this matters

A batch converter can report that two files finished, generate a valid ZIP, and still deliver only one file.

The failure appears when distinct inputs converge on the same output name. Convert photo.jpg and photo.png to WebP and both become photo.webp. If the archive builder uses that name as a key twice, the second payload replaces the first. There is no malformed archive and no required exception—just silent data loss at the final download boundary.

I reproduced that behavior with the same filename rule and JSZip insertion pattern used by a browser image converter, then tested a deterministic naming strategy that preserves both payloads.

What I built or tested

The converter constructs an output filename by removing the source extension, sanitizing the basename, and appending the selected output extension:

function buildFileName(originalName: string, outputFormat: OutputFormat) {
  const base = originalName
    .replace(/\.[^.]+$/, "")
    .replace(/[^a-zA-Z0-9-_]+/g, "-") || "image";
  const extension = outputFormat === "jpeg" ? "jpg" : outputFormat;
  return `${base}.${extension}`;
}
Enter fullscreen mode Exit fullscreen mode

That is reasonable for individual downloads. It is not sufficient as a unique archive key.

The batch path fetches each result Blob and calls archive.file(result.filename, blob). The same pattern appears in both the general image workspace and the dedicated batch converter. Neither path allocates a collision-safe entry name before insertion.

Setup

The minimal failure needs only two synthetic source names and two distinguishable payloads:

const inputs = ["photo.jpg", "photo.png"];
const outputNames = inputs.map((name) => buildFileName(name, "webp"));

// ["photo.webp", "photo.webp"]
Enter fullscreen mode Exit fullscreen mode

I used the source repository's installed JSZip version and text payloads named from-jpg and from-png. Images are unnecessary for this experiment because the bug is in archive identity, after processing has already produced valid Blobs.

Step-by-step walkthrough

The important distinction is between a display filename and a unique archive entry name:

Mermaid diagram 1

Name allocation happens before insertion so every successful result owns one archive entry.

Reproduce the collision

Adding the same path twice is syntactically valid:

const archive = new JSZip();
archive.file("photo.webp", "from-jpg");
archive.file("photo.webp", "from-png");
Enter fullscreen mode Exit fullscreen mode

After generating and reopening the ZIP, the experiment found one entry named photo.webp. Its content was from-png, proving that the later write replaced the earlier payload.

This is why checking only that ZIP generation succeeds is weak. The archive is readable and its surviving file is valid.

Allocate a unique name

Track normalized entry names and add a suffix before the extension:

function allocateUniqueName(requested: string, used: Set<string>) {
  const dot = requested.lastIndexOf(".");
  const base = dot > 0 ? requested.slice(0, dot) : requested;
  const extension = dot > 0 ? requested.slice(dot) : "";

  for (let suffix = 1; ; suffix += 1) {
    const candidate = suffix === 1
      ? requested
      : `${base} (${suffix})${extension}`;
    const key = candidate.toLowerCase();

    if (!used.has(key)) {
      used.add(key);
      return candidate;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

With that allocator, the inputs become photo.webp and photo (2).webp. Reopening the generated ZIP showed two entries, with each original payload attached to the expected name.

Case normalization matters because archive consumers can differ in case sensitivity. Treating Photo.webp and photo.webp as a collision avoids creating an archive that behaves differently after extraction on another filesystem.

What went wrong

The existing integration coverage tests a mixed compression batch with one JPEG and one PNG. Compression preserves their formats, so the resulting names still have different extensions. The test correctly expects two entries, but it does not exercise name convergence.

A separate batch conversion test uses mixed inputs and confirms that two conversions complete, yet it does not download and inspect the ZIP. Even an entry-count assertion would need collision-producing fixtures: two arbitrary input files can accidentally keep unique basenames.

The mistaken assumption is that a successful processing result already has a filename safe for every container. A filename can be individually valid while remaining non-unique inside a batch.

Fix or mitigation

Make archive naming an explicit stage with these properties:

  1. Allocate names in stable result order.
  2. Compare names using a documented normalization rule.
  3. Put the suffix before the extension.
  4. Reserve each name before asynchronous Blob fetching or ZIP insertion.
  5. Keep the original source-to-entry mapping for tests and diagnostics.

Reserve names synchronously before Promise.all. Otherwise, concurrent tasks can observe the same unused name before either records it.

Then strengthen the integration test with photo.jpg and photo.png, convert both to the same format, download the archive, reopen it, and assert:

  • the archive has two files;
  • the names are deterministic and unique;
  • each entry decodes to the expected image dimensions or known payload;
  • the UI's successful-result count equals the archive entry count.

Trade-offs

Numeric suffixes are predictable and preserve familiar filenames, but they do not reveal the original format. A policy such as photo-from-jpg.webp is more descriptive but can expose source-format details and produce longer names.

Stable ordering matters. Parallel processing completion order can vary, so assigning the unsuffixed name to whichever task finishes first would make archives nondeterministic. Allocate from the selected-file or result order instead.

Unicode normalization and platform-specific reserved names add complexity if arbitrary international filenames must round-trip exactly. A conservative sanitizer plus case-insensitive uniqueness is simpler, but document that it changes names.

Finally, a correct entry count does not prove correct association. Tests should inspect content or decoded metadata, not only keys.

How I verified it

The private experiment ran the production-equivalent basename rule against photo.jpg and photo.png, producing photo.webp twice. JSZip generated a readable archive containing one entry; reopening that entry returned only the second payload.

The mitigation reserved collision-safe names before insertion. The generated archive contained exactly photo.webp and photo (2).webp, and both payload assertions passed.

Source inspection confirmed that both browser batch workspaces currently pass result.filename directly to JSZip. The existing end-to-end ZIP test proves the ordinary two-entry path works, while the controlled experiment isolates the missing collision case.

Conclusion

Batch success is not complete until every successful result has a distinct place in the downloaded archive.

Treat output filenames as requests, not unique identifiers. Allocate deterministic archive names before concurrent work, generate the ZIP, reopen it in a test, and verify both entry identity and content association.

That turns a valid-looking archive with silent replacement into a boundary the test suite can actually defend.

Top comments (0)