diff --git a/.changes/fix-fs-write-options-header.md b/.changes/fix-fs-write-options-header.md new file mode 100644 index 000000000..4949106f5 --- /dev/null +++ b/.changes/fix-fs-write-options-header.md @@ -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. diff --git a/packages/api-e2e/test/specs/fs.spec.ts b/packages/api-e2e/test/specs/fs.spec.ts index d590e0395..32767d606 100644 --- a/packages/api-e2e/test/specs/fs.spec.ts +++ b/packages/api-e2e/test/specs/fs.spec.ts @@ -70,6 +70,21 @@ describePlugin('fs', () => { 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( diff --git a/plugins/fs/src/commands.rs b/plugins/fs/src/commands.rs index fcc38f6bb..1fb5a69d0 100644 --- a/plugins/fs/src/commands.rs +++ b/plugins/fs/src/commands.rs @@ -1093,11 +1093,12 @@ async fn write_file_inner( }) .and_then(|p| SafeFilePath::from_str(&p).map_err(CommandError::from))?; - let options: Option = request + let options = request .headers() .get("options") - .and_then(|p| p.to_str().ok()) - .and_then(|opts| serde_json::from_str(opts).ok()); + .map(|options| parse_write_file_options(options.as_bytes())) + .transpose()? + .flatten(); let mut file_handle = resolve_file( permission, @@ -1140,8 +1141,12 @@ async fn write_file_inner( tauri::ipc::InvokeBody::Raw(data) => Cow::Borrowed(data), tauri::ipc::InvokeBody::Json(serde_json::Value::Array(data)) => Cow::Owned( data.iter() - .flat_map(|v| v.as_number().and_then(|v| v.as_u64().map(|v| v as u8))) - .collect(), + .map(|v| { + 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::, _>>()?, ), _ => return Err(anyhow::anyhow!("unexpected invoke body").into()), }; @@ -1157,6 +1162,20 @@ async fn write_file_inner( .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> { + 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] pub async fn write_file( webview: Webview, @@ -1810,6 +1829,25 @@ mod test { 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] fn safe_file_path_parse() { use super::SafeFilePath;