Skip to content

Commit 292963b

Browse files
fix(storage): prevent false cloud placeholder detection on Windows (#878)
1 parent 39cdaac commit 292963b

8 files changed

Lines changed: 296 additions & 12 deletions

File tree

src/main/storage/providers/markdown/runtime/__tests__/cloudPlaceholders.test.ts

Lines changed: 192 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,14 @@ import path from 'node:path'
44
import fs from 'fs-extra'
55
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
66
import { enqueueCloudDownload } from '../../cloudDownloads'
7+
import { createFoldersStorage } from '../../storages/folders'
8+
import { createSnippetsStorage } from '../../storages/snippets'
79
import { runtimeRef } from '../cache'
8-
import { setDatalessProbeForTests } from '../shared/cloudFiles'
10+
import { getPaths } from '../paths'
11+
import {
12+
getFileAvailability,
13+
setDatalessProbeForTests,
14+
} from '../shared/cloudFiles'
915
import { assertNoUnknownDomainFiles } from '../shared/foldersStorage'
1016
import { ensureSnippetContentLoaded, writeSnippetToFile } from '../snippets'
1117
import { saveState } from '../state'
@@ -25,10 +31,13 @@ vi.mock('electron', () => ({
2531
},
2632
}))
2733

34+
const storageMock = vi.hoisted(() => ({ vaultPath: '' }))
35+
2836
vi.mock('../../../../../store', () => ({
2937
store: {
3038
preferences: {
31-
get: () => undefined,
39+
get: (key: string) =>
40+
key === 'storage.vaultPath' ? storageMock.vaultPath : undefined,
3241
},
3342
},
3443
}))
@@ -56,6 +65,16 @@ function createPaths(): Paths {
5665
}
5766
}
5867

68+
function createStoragePaths(): Paths {
69+
const vaultPath = fs.mkdtempSync(
70+
path.join(os.tmpdir(), 'cloud-placeholder-storage-'),
71+
)
72+
tempDirs.push(vaultPath)
73+
storageMock.vaultPath = vaultPath
74+
75+
return getPaths(vaultPath)
76+
}
77+
5978
function writeSnippetFixture(
6079
paths: Paths,
6180
relativePath: string,
@@ -110,6 +129,7 @@ beforeEach(() => {
110129
})
111130

112131
afterEach(() => {
132+
storageMock.vaultPath = ''
113133
setDatalessProbeForTests(null)
114134
resetRuntimeCache()
115135
vi.mocked(enqueueCloudDownload).mockClear()
@@ -120,6 +140,176 @@ afterEach(() => {
120140
})
121141

122142
describe('cloud placeholder handling in code runtime', () => {
143+
it('allows immediate content write and rename of an app-written file', () => {
144+
const paths = createStoragePaths()
145+
const targetPath = path.join(paths.vaultPath, '.masscode/inbox/Renamed.md')
146+
const statSync = fs.statSync.bind(fs)
147+
const statSpy = vi.spyOn(fs, 'statSync').mockImplementation((filePath) => {
148+
const stats = statSync(filePath)
149+
150+
if (
151+
typeof filePath === 'string'
152+
&& filePath.endsWith('.md')
153+
&& stats.size > 0
154+
) {
155+
return Object.assign(stats, { blocks: 0 })
156+
}
157+
158+
return stats
159+
})
160+
161+
try {
162+
const storage = createSnippetsStorage()
163+
const { id } = storage.createSnippet({ name: 'Resident' })
164+
expect(() =>
165+
storage.createSnippetContent(id, {
166+
label: 'Fragment 1',
167+
language: 'plain_text',
168+
value: 'body',
169+
}),
170+
).not.toThrow()
171+
172+
resetRuntimeCache()
173+
syncRuntimeWithDisk(paths)
174+
resetRuntimeCache()
175+
const lazyCache = syncRuntimeWithDisk(paths)
176+
const lazySnippet = lazyCache.snippets.find(
177+
snippet => snippet.id === id,
178+
)
179+
expect(lazySnippet?.contents[0]?.value).toBeNull()
180+
181+
expect(() =>
182+
storage.updateSnippet(id, { name: 'Renamed' }),
183+
).not.toThrow()
184+
185+
expect(fs.readFileSync(targetPath, 'utf8')).toContain('body')
186+
}
187+
finally {
188+
statSpy.mockRestore()
189+
}
190+
})
191+
192+
it('keeps local folder contents available without rewriting placeholders', () => {
193+
const paths = createStoragePaths()
194+
const statSync = fs.statSync.bind(fs)
195+
const statSpy = vi.spyOn(fs, 'statSync').mockImplementation((filePath) => {
196+
const stats = statSync(filePath)
197+
198+
if (
199+
typeof filePath === 'string'
200+
&& filePath.endsWith('.md')
201+
&& stats.size > 0
202+
) {
203+
return Object.assign(stats, { blocks: 0 })
204+
}
205+
206+
return stats
207+
})
208+
const foldersStorage = createFoldersStorage()
209+
const storage = createSnippetsStorage()
210+
const folder = foldersStorage.createFolder({ name: 'Folder' })
211+
let localId = 0
212+
let placeholderId = 0
213+
let placeholderSourcePath = ''
214+
215+
try {
216+
localId = storage.createSnippet({
217+
folderId: folder.id,
218+
name: 'Resident',
219+
}).id
220+
storage.createSnippetContent(localId, {
221+
label: 'Fragment 1',
222+
language: 'plain_text',
223+
value: 'local body',
224+
})
225+
226+
placeholderId = storage.createSnippet({
227+
folderId: folder.id,
228+
name: 'Pending',
229+
}).id
230+
storage.createSnippetContent(placeholderId, {
231+
label: 'Fragment 1',
232+
language: 'plain_text',
233+
value: 'remote body',
234+
})
235+
const placeholder = runtimeRef.cache!.snippets.find(
236+
item => item.id === placeholderId,
237+
)!
238+
placeholderSourcePath = path.join(paths.vaultPath, placeholder.filePath)
239+
240+
makeSparsePlaceholder(placeholderSourcePath)
241+
if (!hasPlaceholderSignature(placeholderSourcePath)) {
242+
return
243+
}
244+
245+
expect(foldersStorage.deleteFolder(folder.id).deleted).toBe(true)
246+
247+
const local = runtimeRef.cache!.snippets.find(
248+
item => item.id === localId,
249+
)!
250+
const pending = runtimeRef.cache!.snippets.find(
251+
item => item.id === placeholderId,
252+
)!
253+
const localTargetPath = path.join(paths.vaultPath, local.filePath)
254+
const placeholderTargetPath = path.join(
255+
paths.vaultPath,
256+
pending.filePath,
257+
)
258+
259+
expect(getFileAvailability(localTargetPath).isCloudPlaceholder).toBe(
260+
false,
261+
)
262+
expect(hasPlaceholderSignature(placeholderTargetPath)).toBe(true)
263+
expect(fs.statSync(placeholderTargetPath).size).toBe(4096)
264+
}
265+
finally {
266+
statSpy.mockRestore()
267+
}
268+
})
269+
270+
it('keeps a committed write successful when its follow-up stat fails', () => {
271+
const paths = createPaths()
272+
const snippetPath = path.join(paths.vaultPath, '.masscode/inbox/saved.md')
273+
const statSync = fs.statSync.bind(fs)
274+
const statSpy = vi.spyOn(fs, 'statSync').mockImplementation((filePath) => {
275+
if (filePath === snippetPath && fs.existsSync(snippetPath)) {
276+
throw new Error('post-write stat failed')
277+
}
278+
279+
return statSync(filePath)
280+
})
281+
282+
try {
283+
expect(() =>
284+
writeSnippetToFile(paths, {
285+
contents: [
286+
{
287+
id: 1,
288+
label: 'Fragment 1',
289+
language: 'plain_text',
290+
value: 'saved body',
291+
},
292+
],
293+
createdAt: 1700000000000,
294+
description: null,
295+
filePath: '.masscode/inbox/saved.md',
296+
folderId: null,
297+
id: 2,
298+
isDeleted: 0,
299+
isFavorites: 0,
300+
name: 'Saved',
301+
tags: [],
302+
updatedAt: 1700000000000,
303+
}),
304+
).not.toThrow()
305+
}
306+
finally {
307+
statSpy.mockRestore()
308+
}
309+
310+
expect(fs.readFileSync(snippetPath, 'utf8')).toContain('saved body')
311+
})
312+
123313
it('keeps known placeholder snippets in the list without reading them', () => {
124314
const paths = createPaths()
125315
const localPath = writeSnippetFixture(

src/main/storage/providers/markdown/runtime/shared/__tests__/cloudFiles.test.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import os from 'node:os'
22
import path from 'node:path'
33
import fs from 'fs-extra'
4-
import { afterEach, describe, expect, it } from 'vitest'
4+
import { afterEach, describe, expect, it, vi } from 'vitest'
55
import {
66
getFileAvailability,
77
isCloudPlaceholderStats,
@@ -114,8 +114,19 @@ describe('getFileAvailability', () => {
114114
expect(getFileAvailability(filePath).isCloudPlaceholder).toBe(true)
115115

116116
// Ридер успешно прочитал файл: с этого момента он считается локальным,
117-
// пока не изменится (size + mtime).
117+
// пока не изменится (size + mtime + ctime).
118118
markFileReadableDespiteZeroBlocks(filePath, stats)
119119
expect(getFileAvailability(filePath).isCloudPlaceholder).toBe(false)
120+
121+
const statSpy = vi
122+
.spyOn(fs, 'statSync')
123+
.mockReturnValue(Object.assign(stats, { ctimeMs: stats.ctimeMs + 1 }))
124+
125+
try {
126+
expect(getFileAvailability(filePath).isCloudPlaceholder).toBe(true)
127+
}
128+
finally {
129+
statSpy.mockRestore()
130+
}
120131
})
121132
})

src/main/storage/providers/markdown/runtime/shared/cloudFiles.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,10 @@ function hasSimulatedCloudStub(absolutePath: string): boolean {
4646
// Файлы, у которых сигнатура плейсхолдера (size > 0, blocks === 0) оказалась
4747
// ложной: их содержимое успешно прочитано (inline-файлы btrfs, resident-файлы
4848
// NTFS, сетевые шары с нулевым AllocationSize). Ключ — путь, значение —
49-
// size и mtime на момент проверки: изменение файла сбрасывает исключение.
49+
// size, mtime и ctime на момент проверки: изменение файла сбрасывает исключение.
5050
const readableZeroBlockFiles = new Map<
5151
string,
52-
{ mtimeMs: number, size: number }
52+
{ ctimeMs: number, mtimeMs: number, size: number }
5353
>()
5454

5555
// Кэш точной проверки SF_DATALESS на macOS: spawn стоит миллисекунды, но
@@ -64,11 +64,26 @@ export function markFileReadableDespiteZeroBlocks(
6464
stats: Stats,
6565
): void {
6666
readableZeroBlockFiles.set(absolutePath, {
67+
ctimeMs: stats.ctimeMs,
6768
mtimeMs: stats.mtimeMs,
6869
size: stats.size,
6970
})
7071
}
7172

73+
export function markAppWrittenFileAsLocal(absolutePath: string): void {
74+
try {
75+
const stats = fs.statSync(absolutePath)
76+
77+
if (hasPlaceholderSignature(stats)) {
78+
markFileReadableDespiteZeroBlocks(absolutePath, stats)
79+
}
80+
}
81+
catch {
82+
// Запись уже завершена: сбой best-effort stat не должен превращать её
83+
// в ошибку. Файл просто останется без исключения до успешного чтения.
84+
}
85+
}
86+
7287
export function resetCloudFileExemptions(): void {
7388
readableZeroBlockFiles.clear()
7489
datalessCheckCache.clear()
@@ -228,6 +243,7 @@ export function getFileAvailability(absolutePath: string): FileAvailability {
228243

229244
if (
230245
exemption
246+
&& exemption.ctimeMs === stats.ctimeMs
231247
&& exemption.mtimeMs === stats.mtimeMs
232248
&& exemption.size === stats.size
233249
) {

0 commit comments

Comments
 (0)