Skip to content

Commit 1832605

Browse files
committed
fix(FileStore): add case sensitivity detection for file operations
1 parent e0b935e commit 1832605

2 files changed

Lines changed: 54 additions & 1 deletion

File tree

mockServer/src/store/FileStore.js

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ class FileStore extends StoreAdapter {
3434

3535
// Ensure collection directory exists
3636
this.ensureDirectory()
37+
38+
// Detect whether the collection directory lives on a case-sensitive
39+
// filesystem, so a file rename that only changes letter case (e.g.
40+
// Login -> login) is handled correctly. On a case-insensitive FS (macOS
41+
// APFS and Windows NTFS defaults) such a rename points at the same physical
42+
// file, so writeAtomic + unlink would otherwise delete the just-written file.
43+
this.caseSensitive = this.detectCaseSensitive()
3744
}
3845

3946
ensureDirectory() {
@@ -45,6 +52,33 @@ class FileStore extends StoreAdapter {
4552
}
4653
}
4754

55+
/**
56+
* Probe whether the collection directory lives on a case-sensitive filesystem
57+
* by writing a marker file and checking whether a differently-cased name
58+
* resolves to it. Returns true (case-sensitive) or false (case-insensitive),
59+
* defaulting to false if probing fails so we never unlink on a case-only rename.
60+
*/
61+
detectCaseSensitive() {
62+
const probeName = `.casesens-${crypto.randomBytes(4).toString('hex')}`
63+
const probePath = path.join(this.collectionPath, probeName)
64+
try {
65+
fs.writeFileSync(probePath, '')
66+
// On a case-insensitive FS the upper-cased name resolves to the same file.
67+
return !fs.existsSync(path.join(this.collectionPath, probeName.toUpperCase()))
68+
} catch {
69+
// Conservative default: treat as case-insensitive to avoid data loss.
70+
return false
71+
} finally {
72+
try {
73+
if (fs.existsSync(probePath)) {
74+
fs.unlinkSync(probePath)
75+
}
76+
} catch {
77+
/* best-effort cleanup of the probe file */
78+
}
79+
}
80+
}
81+
4882
/**
4983
* Generate a unique ID similar to NeDB's format
5084
*/
@@ -343,7 +377,13 @@ class FileStore extends StoreAdapter {
343377
reservedNames.add(fileBaseName.toLowerCase())
344378

345379
this.writeAtomic(targetPath, updatedDoc)
346-
if (targetPath !== entry.filePath && fs.existsSync(entry.filePath)) {
380+
// Only unlink the previous file when it is a genuinely different physical
381+
// file. On a case-insensitive FS a case-only rename (Login -> login) points
382+
// at the same file, so unlinking would delete the document we just wrote.
383+
const pathsDiffer = this.caseSensitive
384+
? targetPath !== entry.filePath
385+
: targetPath.toLowerCase() !== entry.filePath.toLowerCase()
386+
if (pathsDiffer && fs.existsSync(entry.filePath)) {
347387
fs.unlinkSync(entry.filePath)
348388
}
349389
}

mockServer/test/store/adapter.contract.test.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,4 +155,17 @@ describe.each(adapterFactories)('%s honours the StoreAdapter contract', (_name,
155155

156156
expect(await store.find({ tags: ['purple'] })).toHaveLength(0)
157157
})
158+
159+
test('updating a naming field to a case-only variant keeps the document', async () => {
160+
await store.insert({ _id: 'login', name: 'Login' })
161+
const affected = await store.update({ _id: 'login' }, { $set: { name: 'login' } })
162+
expect(affected).toBe(1)
163+
// The document must still exist and read back with the updated value.
164+
// Regression guard: on a case-insensitive filesystem (macOS/Windows default)
165+
// a case-only rename used to make FileStore unlink the just-written file.
166+
const doc = await store.findOne({ _id: 'login' })
167+
expect(doc).not.toBeNull()
168+
expect(doc.name).toBe('login')
169+
expect(await store.find({})).toHaveLength(1)
170+
})
158171
})

0 commit comments

Comments
 (0)