Files
tauri-plugins-workspace/packages/api-e2e/test/specs/fs.spec.ts
T
Lucas Fernandes Nogueira b46a88ff55 fix(fs): reject malformed write_file options instead of ignoring them (#3622)
In plugins/fs/src/commands.rs write_file_inner, a malformed or non-ASCII
`options` header was dropped with .ok(), so baseDir/append/createNew were
silently ignored and the call became a truncating write to the raw path;
the JSON array body fallback truncated values above 255 and dropped
non-numbers.

The header is parsed with parse_write_file_options: empty, `undefined`
(what fetch sends for JSON.stringify(undefined)) and `null` mean no
options, anything else must parse. Array bodies must only contain bytes.
Unit test for the header parser; e2e spec for writes without options,
which is the case the `undefined` handling keeps working.
2026-09-23 12:57:49 -03:00

397 lines
14 KiB
TypeScript

// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import { expect } from '@wdio/globals'
import {
tauri,
tauriError,
describePlugin,
itDesktop,
scratchDir
} from '../helpers/index.js'
// Every path below is relative to `BaseDirectory.AppData`, which the example's
// fs scope allows recursively (`fs:scope-appdata-recursive`).
const dir = scratchDir('fs')
describePlugin('fs', () => {
before(async () => {
await tauri(async (api, dir) => {
const baseDir = api.fs.BaseDirectory.AppData
if (await api.fs.exists(dir, { baseDir })) {
await api.fs.remove(dir, { baseDir, recursive: true })
}
await api.fs.mkdir(dir, { baseDir, recursive: true })
}, dir)
})
after(async () => {
await tauri(async (api, dir) => {
const baseDir = api.fs.BaseDirectory.AppData
if (await api.fs.exists(dir, { baseDir })) {
await api.fs.remove(dir, { baseDir, recursive: true })
}
}, dir)
})
it('mkdir and exists report the scratch directory', async () => {
const result = await tauri(async (api, dir) => {
const baseDir = api.fs.BaseDirectory.AppData
return {
dir: await api.fs.exists(dir, { baseDir }),
missing: await api.fs.exists(`${dir}/does-not-exist`, { baseDir })
}
}, dir)
expect(result).toEqual({ dir: true, missing: false })
})
it('writeTextFile and readTextFile round-trip utf-8 text', async () => {
const text = 'Hello from the e2e suite — olá, 世界! 🎉\nsecond line\n'
const read = await tauri(
async (api, path, text) => {
const baseDir = api.fs.BaseDirectory.AppData
await api.fs.writeTextFile(path, text, { baseDir })
return api.fs.readTextFile(path, { baseDir })
},
`${dir}/text.txt`,
text
)
expect(read).toBe(text)
})
it('writeTextFile appends when asked to', async () => {
const read = await tauri(async (api, path) => {
const baseDir = api.fs.BaseDirectory.AppData
await api.fs.writeTextFile(path, 'first', { baseDir })
await api.fs.writeTextFile(path, ' second', { baseDir, append: true })
return api.fs.readTextFile(path, { baseDir })
}, `${dir}/append.txt`)
expect(read).toBe('first second')
})
it('writeFile and writeTextFile work without options', async () => {
const result = await tauri(async (api, dir) => {
const path = await api.path.join(
await api.path.appDataDir(),
dir,
'no-options.txt'
)
await api.fs.writeTextFile(path, 'text')
const text = await api.fs.readTextFile(path)
await api.fs.writeFile(path, new Uint8Array([98, 121, 116, 101, 115]))
return { text, bytes: await api.fs.readTextFile(path) }
}, dir)
expect(result).toEqual({ text: 'text', bytes: 'bytes' })
})
it('writeFile and readFile round-trip binary data', async () => {
const bytes = [0, 1, 2, 3, 250, 251, 252, 253, 254, 255]
const read = await tauri(
async (api, path, bytes) => {
const baseDir = api.fs.BaseDirectory.AppData
await api.fs.writeFile(path, new Uint8Array(bytes), { baseDir })
return Array.from(await api.fs.readFile(path, { baseDir }))
},
`${dir}/binary.bin`,
bytes
)
expect(read).toEqual(bytes)
})
it('stat, lstat and size describe files and directories', async () => {
const result = await tauri(async (api, dir) => {
const baseDir = api.fs.BaseDirectory.AppData
const file = `${dir}/stat.txt`
await api.fs.writeTextFile(file, '0123456789', { baseDir })
const fileStat = await api.fs.stat(file, { baseDir })
const fileLstat = await api.fs.lstat(file, { baseDir })
const dirStat = await api.fs.stat(dir, { baseDir })
return {
file: {
isFile: fileStat.isFile,
isDirectory: fileStat.isDirectory,
isSymlink: fileStat.isSymlink,
size: fileStat.size,
hasMtime: fileStat.mtime instanceof Date
},
lstatSize: fileLstat.size,
// `size` only takes absolute paths
size: await api.fs.size(
await api.path.join(await api.path.appDataDir(), file)
),
dir: { isFile: dirStat.isFile, isDirectory: dirStat.isDirectory }
}
}, dir)
expect(result.file).toEqual({
isFile: true,
isDirectory: false,
isSymlink: false,
size: 10,
hasMtime: true
})
expect(result.lstatSize).toBe(10)
expect(result.size).toBe(10)
expect(result.dir).toEqual({ isFile: false, isDirectory: true })
})
it('copyFile, rename and readDir', async () => {
const result = await tauri(async (api, dir) => {
const baseDir = api.fs.BaseDirectory.AppData
const sub = `${dir}/tree`
await api.fs.mkdir(`${sub}/nested`, { baseDir, recursive: true })
await api.fs.writeTextFile(`${sub}/a.txt`, 'a', { baseDir })
await api.fs.copyFile(`${sub}/a.txt`, `${sub}/b.txt`, {
fromPathBaseDir: baseDir,
toPathBaseDir: baseDir
})
await api.fs.rename(`${sub}/b.txt`, `${sub}/c.txt`, {
oldPathBaseDir: baseDir,
newPathBaseDir: baseDir
})
const entries = await api.fs.readDir(sub, { baseDir })
return {
entries: entries
.map((e) => ({
name: e.name,
isFile: e.isFile,
isDirectory: e.isDirectory
}))
.sort((x, y) => x.name.localeCompare(y.name)),
copied: await api.fs.readTextFile(`${sub}/c.txt`, { baseDir }),
renamedAway: await api.fs.exists(`${sub}/b.txt`, { baseDir })
}
}, dir)
expect(result.entries).toEqual([
{ name: 'a.txt', isFile: true, isDirectory: false },
{ name: 'c.txt', isFile: true, isDirectory: false },
{ name: 'nested', isFile: false, isDirectory: true }
])
expect(result.copied).toBe('a')
expect(result.renamedAway).toBe(false)
})
it('remove deletes files and (recursively) directories', async () => {
const result = await tauri(async (api, dir) => {
const baseDir = api.fs.BaseDirectory.AppData
const sub = `${dir}/to-remove`
await api.fs.mkdir(`${sub}/nested`, { baseDir, recursive: true })
await api.fs.writeTextFile(`${sub}/nested/file.txt`, 'x', { baseDir })
await api.fs.remove(`${sub}/nested/file.txt`, { baseDir })
const fileGone = !(await api.fs.exists(`${sub}/nested/file.txt`, {
baseDir
}))
await api.fs.writeTextFile(`${sub}/nested/other.txt`, 'x', { baseDir })
let nonRecursiveError: string | null = null
try {
await api.fs.remove(sub, { baseDir })
} catch (error) {
nonRecursiveError = String(error)
}
await api.fs.remove(sub, { baseDir, recursive: true })
return {
fileGone,
nonRecursiveError,
dirGone: !(await api.fs.exists(sub, { baseDir }))
}
}, dir)
expect(result.fileGone).toBe(true)
// a non-empty directory cannot be removed without `recursive`
expect(result.nonRecursiveError).not.toBeNull()
expect(result.dirGone).toBe(true)
})
it('truncate shortens a file', async () => {
const result = await tauri(async (api, path) => {
const baseDir = api.fs.BaseDirectory.AppData
await api.fs.writeTextFile(path, '0123456789', { baseDir })
await api.fs.truncate(path, 4, { baseDir })
const shortened = await api.fs.readTextFile(path, { baseDir })
await api.fs.truncate(path, undefined, { baseDir })
return {
shortened,
emptied: await api.fs.readTextFile(path, { baseDir })
}
}, `${dir}/truncate.txt`)
expect(result).toEqual({ shortened: '0123', emptied: '' })
})
it('readTextFileLines iterates a file line by line', async () => {
const lines = await tauri(async (api, path) => {
const baseDir = api.fs.BaseDirectory.AppData
await api.fs.writeTextFile(path, 'one\ntwo\r\nthree', { baseDir })
const result: string[] = []
for await (const line of await api.fs.readTextFileLines(path, {
baseDir
})) {
result.push(line)
}
return result
}, `${dir}/lines.txt`)
expect(lines).toEqual(['one', 'two', 'three'])
})
it('FileHandle supports write, seek, read, stat and truncate', async () => {
const result = await tauri(async (api, path) => {
const baseDir = api.fs.BaseDirectory.AppData
const encoder = new TextEncoder()
const decoder = new TextDecoder()
const created = await api.fs.create(path, { baseDir })
const written = await created.write(encoder.encode('hello world'))
const statAfterWrite = await created.stat()
await created.close()
const file = await api.fs.open(path, { baseDir, read: true, write: true })
// seek past "hello " and read the rest
const position = await file.seek(6, api.fs.SeekMode.Start)
const buffer = new Uint8Array(32)
const read = await file.read(buffer)
const rest = decoder.decode(buffer.subarray(0, read ?? 0))
// at EOF, read reports null
const atEof = await file.read(new Uint8Array(8))
// relative and end-relative seeks
const fromEnd = await file.seek(-5, api.fs.SeekMode.End)
const relative = await file.seek(-1, api.fs.SeekMode.Current)
await file.truncate(5)
const statAfterTruncate = await file.stat()
await file.close()
return {
written,
sizeAfterWrite: statAfterWrite.size,
position,
read,
rest,
atEof,
fromEnd,
relative,
sizeAfterTruncate: statAfterTruncate.size,
contents: await api.fs.readTextFile(path, { baseDir })
}
}, `${dir}/handle.txt`)
expect(result).toEqual({
written: 11,
sizeAfterWrite: 11,
position: 6,
read: 5,
rest: 'world',
atEof: null,
fromEnd: 6,
relative: 5,
sizeAfterTruncate: 5,
contents: 'hello'
})
})
it('a closed FileHandle cannot be used again', async () => {
const message = await tauriError(async (api, path) => {
const baseDir = api.fs.BaseDirectory.AppData
const file = await api.fs.create(path, { baseDir })
await file.close()
await file.stat()
}, `${dir}/closed.txt`)
expect(message).toMatch(/resource id \d+ is invalid/)
})
it('rejects paths outside the configured scope', async () => {
// `$HOME` itself is not in the example's fs scope (only the app dirs,
// `$DOWNLOAD` and `$RESOURCE` are).
const message = await tauriError(async (api) =>
api.fs.readTextFile('e2e-forbidden.txt', {
baseDir: api.fs.BaseDirectory.Home
})
)
expect(message).toMatch(/forbidden path/)
})
it('rejects paths escaping the scope through `..`', async () => {
const message = await tauriError(async (api) =>
api.fs.writeTextFile('../../e2e-escape.txt', 'nope', {
baseDir: api.fs.BaseDirectory.AppData
})
)
expect(message).toMatch(/cannot traverse directory|forbidden path/)
})
// The watch specs are desktop-only: `fs:allow-watch` is granted in the
// example's desktop capability only.
itDesktop(
'watchImmediate reports changes in a watched directory',
async () => {
const result = await tauri(async (api, watched) => {
const baseDir = api.fs.BaseDirectory.AppData
await api.fs.mkdir(watched, { baseDir, recursive: true })
const events: { kind: string; paths: string[] }[] = []
const unwatch = await api.fs.watchImmediate(
watched,
(event) => {
events.push({
kind:
typeof event.type === 'string'
? event.type
: Object.keys(event.type)[0],
paths: event.paths
})
},
{ baseDir, recursive: true }
)
await api.fs.writeTextFile(`${watched}/touched.txt`, 'watched', {
baseDir
})
// give the notifier a moment to deliver
const deadline = Date.now() + 10_000
while (
!events.some((e) => e.paths.some((p) => p.endsWith('touched.txt')))
) {
if (Date.now() > deadline) {
throw new Error(
`no watch event for touched.txt, got ${JSON.stringify(events)}`
)
}
await new Promise((resolve) => setTimeout(resolve, 100))
}
unwatch()
return events
}, `${dir}/watched`)
expect(
result.some((e) => e.paths.some((p) => p.endsWith('touched.txt')))
).toBe(true)
}
)
itDesktop('watch debounces and unwatch stops delivery', async () => {
const result = await tauri(async (api, watched) => {
const baseDir = api.fs.BaseDirectory.AppData
await api.fs.mkdir(watched, { baseDir, recursive: true })
let count = 0
const unwatch = await api.fs.watch(
watched,
() => {
count++
},
{ baseDir, delayMs: 200 }
)
await api.fs.writeTextFile(`${watched}/debounced.txt`, 'a', {
baseDir
})
const deadline = Date.now() + 10_000
while (count === 0) {
if (Date.now() > deadline) {
throw new Error('no debounced watch event received')
}
await new Promise((resolve) => setTimeout(resolve, 100))
}
const afterFirst = count
unwatch()
await api.fs.writeTextFile(`${watched}/after-unwatch.txt`, 'b', {
baseDir
})
await new Promise((resolve) => setTimeout(resolve, 1000))
return { afterFirst, afterUnwatch: count }
}, `${dir}/debounced`)
expect(result.afterFirst).toBeGreaterThan(0)
expect(result.afterUnwatch).toBe(result.afterFirst)
})
})