microsoft / microsoft/vscode

Local history folders are keyed by a 32-bit path hash with no collision handling: Timeline shows another file's entries and Restore overwrites with them

Open
#334,832 1 comment 0 reactions 1 assignee Claimed by @dmitrivMS View on GitHub
timeline-local-history workbench-history
Dominant language
TypeScript
Stars
193k
Forks
42.4k
PR merge metrics
PR metrics pending

Description

Local history stores entries in a folder named by a **32-bit** hash of the resource, and nothing validates that a folder actually belongs to the file being viewed. Two files whose paths collide therefore share one history store: the Timeline for either file lists the other's entries, and **Restore writes the other file's content over the open file**, with no editor undo.

The same hash keys hot-exit backups, where a collision silently discards one file's unsaved changes (details below).

### Version

- VS Code: 1.136.1 (a44adf7f53e00964ab890f9f8758a334f1fc15bc, x64)
- OS: Arch Linux, kernel 7.2.3, Wayland / KDE
- `workbench.localHistory.enabled`: default (true)
- Extensions: not relevant — reproduces with `--disable-extensions`

### Steps to Reproduce

1. In an empty folder, create two files named **`0b.txt`** and **`1C.txt`**.
2. Open `0b.txt`, type something, save. Repeat a few times so it accumulates history.
3. Open `1C.txt`, type something clearly different, save a few times.
4. Open the Timeline view for **`0b.txt`**.

**Expected:** only `0b.txt`'s own saves.

**Actual:** the entries of whichever file wrote last are listed under both files. Selecting an entry diffs the *other* file's content against the current one, and **Local History: Restore Contents** overwrites the open file with the other file's content.

Both files map to the same folder under `~/.config/Code/User/History/`:

```
file:///…/0b.txt -> -54b963d2
file:///…/1C.txt -> -54b963d2
```

`entries.json` in that folder carries a single `resource` field naming only one of the two files, while the entry blobs belong to both.

### Cause

`src/vs/workbench/services/workingCopy/common/workingCopyHistoryService.ts:118`

```ts
private toHistoryEntriesFolder(historyHome: URI, workingCopyResource: URI): URI {
return joinPath(historyHome, hash(workingCopyResource.toString()).toString(16));
}
```

`hash()` from `vs/base/common/hash` is a 32-bit `h = h * 31 + charCode` string hash (folder names such as `-4f249aa0` show it is a signed int32). Two issues compound:

1. **32-bit key.** Collisions are reachable.
2. **No validation on read.** `resolveEntriesFromDisk()` reads `entries.json` and the folder's children and returns them, never comparing the stored `resource` against the `workingCopyResource` being resolved. Entries written for one file are served verbatim for another.

Because the hash is base-31, a collision needs only that one character rise by 1 (weight 31) while the next falls by 31 (weight 1) — the two cancel exactly. ASCII places lowercase 32 above uppercase, so `…0b` / `…1C`, `…5b` / `…6C`, `…5n` / `…6O` and so on all collide, and it is not restricted to case (`…JW` / `…K8` collides too). Any common prefix and suffix are preserved by the cancellation, so `note0b.txt` / `note1C.txt` collide as well.

This makes the failure far more likely than a birthday estimate suggests for any project using short, structured, sequential identifiers as filenames (note-taking systems, content-addressed stores, generated IDs). In one real directory of 427 such files, **53 pairs shared a history folder** — against ~0.00002 expected for random names of that count.

### Knock-on: hot-exit backups

`src/vs/workbench/services/workingCopy/common/workingCopyBackupService.ts:616` keys backups with the same function:

```ts
function hashPath(resource: URI): string {
const str = resource.scheme === Schemas.file || resource.scheme === Schemas.untitled ? resource.fsPath : resource.toString();
return hashString(str); // hash(str).toString(16)
}
```

Content is not misattributed here, because each backup carries a preamble holding its identifier and `getBackups()` resolves from that preamble. But the backup **path** is the hash, so two colliding dirty files write to the same backup file and the second overwrites the first. `getBackups()` then reports one identifier, and the other file's unsaved changes are gone after a hot exit. (`/tmp/demo/0b.txt` and `/tmp/demo/1C.txt` collide under `hashPath` as well.)

Other uses of this hash that I checked are telemetry-only (`basenameHash`, `promptNameHash`) and harmless.

### Suggested fix

Minimal and backward-compatible: on read, compare the `resource` recorded in `entries.json` with the resource being resolved, and treat a mismatch as a different store — e.g. fall back to a disambiguated folder (`-1`, …) rather than serving the entries. That alone removes the misattribution and the destructive Restore without migrating existing folders. Widening the key to a stronger/longer digest would address the collisions themselves, at the cost of a migration.

Guarding `hashPath` for backups similarly would prevent the unsaved-changes loss.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.