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.
This commit is contained in:
Lucas Fernandes Nogueira
2026-09-23 12:57:49 -03:00
committed by GitHub
parent 0c84c917e3
commit b46a88ff55
3 changed files with 64 additions and 5 deletions
+6
View File
@@ -0,0 +1,6 @@
---
fs: patch
fs-js: patch
---
`writeFile` and `writeTextFile` now fail when their options cannot be parsed instead of silently ignoring them (including `baseDir`, `append` and `createNew`) and writing to the raw path. Data sent as a JSON array is rejected if it contains values that are not bytes, instead of truncating them.
+15
View File
@@ -70,6 +70,21 @@ describePlugin('fs', () => {
expect(read).toBe('first second') 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 () => { it('writeFile and readFile round-trip binary data', async () => {
const bytes = [0, 1, 2, 3, 250, 251, 252, 253, 254, 255] const bytes = [0, 1, 2, 3, 250, 251, 252, 253, 254, 255]
const read = await tauri( const read = await tauri(
+43 -5
View File
@@ -1093,11 +1093,12 @@ async fn write_file_inner<R: Runtime>(
}) })
.and_then(|p| SafeFilePath::from_str(&p).map_err(CommandError::from))?; .and_then(|p| SafeFilePath::from_str(&p).map_err(CommandError::from))?;
let options: Option<WriteFileOptions> = request let options = request
.headers() .headers()
.get("options") .get("options")
.and_then(|p| p.to_str().ok()) .map(|options| parse_write_file_options(options.as_bytes()))
.and_then(|opts| serde_json::from_str(opts).ok()); .transpose()?
.flatten();
let mut file_handle = resolve_file( let mut file_handle = resolve_file(
permission, permission,
@@ -1140,8 +1141,12 @@ async fn write_file_inner<R: Runtime>(
tauri::ipc::InvokeBody::Raw(data) => Cow::Borrowed(data), tauri::ipc::InvokeBody::Raw(data) => Cow::Borrowed(data),
tauri::ipc::InvokeBody::Json(serde_json::Value::Array(data)) => Cow::Owned( tauri::ipc::InvokeBody::Json(serde_json::Value::Array(data)) => Cow::Owned(
data.iter() data.iter()
.flat_map(|v| v.as_number().and_then(|v| v.as_u64().map(|v| v as u8))) .map(|v| {
.collect(), v.as_u64()
.and_then(|v| u8::try_from(v).ok())
.ok_or_else(|| anyhow::anyhow!("invalid byte in the data to write: {v}"))
})
.collect::<Result<Vec<u8>, _>>()?,
), ),
_ => return Err(anyhow::anyhow!("unexpected invoke body").into()), _ => return Err(anyhow::anyhow!("unexpected invoke body").into()),
}; };
@@ -1157,6 +1162,20 @@ async fn write_file_inner<R: Runtime>(
.map_err(Into::into) .map_err(Into::into)
} }
/// Parses the `options` header of the `write_file` command.
///
/// Fails instead of silently falling back to the defaults, which would e.g. ignore `baseDir`.
fn parse_write_file_options(header: &[u8]) -> CommandResult<Option<WriteFileOptions>> {
let header = String::from_utf8_lossy(header);
match header.trim() {
// `JSON.stringify(undefined)` is sent as `undefined` by `fetch`
"" | "undefined" | "null" => Ok(None),
options => serde_json::from_str(options)
.map(Some)
.map_err(|e| anyhow::anyhow!("invalid write file options {options}: {e}").into()),
}
}
#[tauri::command] #[tauri::command]
pub async fn write_file<R: Runtime>( pub async fn write_file<R: Runtime>(
webview: Webview<R>, webview: Webview<R>,
@@ -1810,6 +1829,25 @@ mod test {
use super::LinesBytes; use super::LinesBytes;
#[test]
fn write_file_options_header() {
use super::parse_write_file_options;
assert!(parse_write_file_options(b"undefined").unwrap().is_none());
assert!(parse_write_file_options(b"null").unwrap().is_none());
assert!(parse_write_file_options(b"").unwrap().is_none());
let options = parse_write_file_options(br#"{"baseDir":14,"append":true}"#)
.unwrap()
.unwrap();
assert!(options.append);
assert!(options.create);
assert!(options.base.base_dir.is_some());
assert!(parse_write_file_options(b"{not json").is_err());
assert!(parse_write_file_options(br#"{"append":"yes"}"#).is_err());
}
#[test] #[test]
fn safe_file_path_parse() { fn safe_file_path_parse() {
use super::SafeFilePath; use super::SafeFilePath;