Files
Voiced/src-tauri/src/commands/raw.rs
T
xavierk 2ade618fb5 Add PDF archive store, raw-body IPC and export file helpers
The searchable PDF can be archived content-addressed under the local data
dir with its sha256 and layout fingerprint; a different PDF for an archived
invoice is refused and reads verify the hash. Export writes go through a
raw-body command that remembers the last folder; reveal-in-folder and open
fall back to xdg-open / explorer. Adds a Windows-safe export file name
builder. Migration M4 adds the archive columns and last export dir.
2026-10-04 05:54:26 +05:30

93 lines
3.1 KiB
Rust

//! Helpers for commands that take their payload as a raw IPC body (no base64).
//!
//! The webview sends `invoke(cmd, Uint8Array, { headers })`; Tauri hands the bytes over as
//! `InvokeBody::Raw` and the headers as an `http::HeaderMap`. Header values must be visible
//! ASCII (`HeaderValue::from_str` rejects anything else), so non-ASCII data such as file
//! paths is percent-encoded by the caller.
use percent_encoding::percent_decode_str;
use std::collections::HashMap;
use tauri::ipc::{InvokeBody, Request};
/// Lower-cased header name to value; values that are not valid UTF-8 are dropped.
pub type Headers = HashMap<String, String>;
pub fn raw_body<'a>(request: &'a Request<'_>) -> Result<&'a [u8], String> {
match request.body() {
InvokeBody::Raw(bytes) => Ok(bytes),
InvokeBody::Json(_) => Err("Expected a raw binary request body".to_string()),
}
}
pub fn header_map(request: &Request<'_>) -> Headers {
request
.headers()
.iter()
.filter_map(|(name, value)| {
let value = value.to_str().ok()?;
Some((name.as_str().to_ascii_lowercase(), value.to_string()))
})
.collect()
}
pub fn required_header<'a>(headers: &'a Headers, name: &str) -> Result<&'a str, String> {
headers
.get(name)
.map(String::as_str)
.ok_or_else(|| format!("Missing {name} header"))
}
pub fn decode_header_path(value: &str) -> Result<String, String> {
percent_decode_str(value)
.decode_utf8()
.map(|s| s.into_owned())
.map_err(|_| "The path header is not valid UTF-8".to_string())
}
/// Write `bytes` to `path` via a sibling temp file and a rename, so a crash never leaves a
/// half-written file under the final name.
pub fn write_atomic(path: &std::path::Path, bytes: &[u8]) -> Result<(), String> {
use std::io::Write;
let mut tmp_name = path
.file_name()
.ok_or_else(|| "The path has no file name".to_string())?
.to_os_string();
tmp_name.push(".tmp");
let tmp = path.with_file_name(tmp_name);
let result = (|| {
let mut file = std::fs::File::create(&tmp)?;
file.write_all(bytes)?;
file.sync_all()?;
std::fs::rename(&tmp, path)
})();
if let Err(e) = result {
let _ = std::fs::remove_file(&tmp);
return Err(format!("Could not write {}: {e}", path.display()));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn decodes_percent_encoded_paths() {
assert_eq!(
decode_header_path("%2Ftmp%2Fa%20b%2F%E0%A6%AC.pdf").unwrap(),
"/tmp/a b/\u{9ac}.pdf"
);
assert!(decode_header_path("%FF").is_err());
}
#[test]
fn atomic_write_replaces_and_leaves_no_temp_file() {
let dir = tempdir().unwrap();
let target = dir.path().join("out.pdf");
write_atomic(&target, b"one").unwrap();
write_atomic(&target, b"two").unwrap();
assert_eq!(std::fs::read(&target).unwrap(), b"two");
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
}
}