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
- 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
Assessment
This issue has not been assessed yet.