studio-tauri-smoke.yml only ever built the crate, so none of its ~100 unit tests executed. Running them surfaced two that were broken on platforms CI never exercised: - non_utf8_import_name_preserves_csv_extension built a filename containing a raw 0xFF byte. Linux stores that fine, macOS enforces UTF-8 on APFS/HFS+ and refuses to create it, so the test panicked on the unwrap. Skip when the filesystem rejects the name; the branch under test is only reachable where such a file can exist. - losing_a_studio_package_changes_the_fingerprint created the posix venv layout unconditionally, but site_packages_dirs() only walks lib/<pyver>/site-packages on unix and looks at Lib/site-packages on Windows. The dist-info was therefore invisible to the fingerprint there, removing it changed nothing and the assert_ne could never hold. Build the layout the code actually reads for the target platform. Add the cargo test step to the existing Tauri job, where the toolchain and WebKit dev packages are already installed.
360 lines
13 KiB
Rust
360 lines
13 KiB
Rust
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
|
|
|
|
use serde::Serialize;
|
|
use std::fs::{self, File};
|
|
use std::io::{Read, Write};
|
|
use std::path::{Path, PathBuf};
|
|
use tauri::{AppHandle, WebviewWindow};
|
|
use tauri_plugin_dialog::DialogExt;
|
|
|
|
const MAX_CHAT_IMPORT_BYTES: u64 = 64 * 1024 * 1024;
|
|
const NATIVE_FILE_NAME_HEADER: &str = "x-unsloth-default-name";
|
|
const CHAT_IMPORT_EXTENSIONS: &[&str] = &["jsonl", "ndjson", "csv"];
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct NativeImportedFile {
|
|
name: String,
|
|
content: String,
|
|
}
|
|
|
|
fn default_file_name(suggested_name: &str) -> String {
|
|
Path::new(suggested_name)
|
|
.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.filter(|name| !name.is_empty() && *name != "." && *name != "..")
|
|
.unwrap_or("unsloth-export.json")
|
|
.to_string()
|
|
}
|
|
fn decode_default_file_name(encoded_name: &str) -> Result<String, String> {
|
|
let bytes = BASE64
|
|
.decode(encoded_name)
|
|
.map_err(|_| "Invalid native export filename.".to_string())?;
|
|
let name =
|
|
String::from_utf8(bytes).map_err(|_| "Invalid native export filename.".to_string())?;
|
|
Ok(default_file_name(&name))
|
|
}
|
|
|
|
fn save_filter(file_name: &str) -> (&'static str, Vec<&'static str>) {
|
|
match Path::new(file_name)
|
|
.extension()
|
|
.and_then(|extension| extension.to_str())
|
|
.map(str::to_ascii_lowercase)
|
|
.as_deref()
|
|
{
|
|
Some("json") => ("JSON", vec!["json"]),
|
|
Some("jsonl") | Some("ndjson") => ("JSON Lines", vec!["jsonl", "ndjson"]),
|
|
Some("csv") => ("CSV", vec!["csv"]),
|
|
Some("md") | Some("markdown") => ("Markdown", vec!["md", "markdown"]),
|
|
Some("html") | Some("htm") => ("HTML", vec!["html", "htm"]),
|
|
Some("py") => ("Python", vec!["py"]),
|
|
Some("sh") => ("Shell script", vec!["sh"]),
|
|
Some("zip") => ("ZIP archive", vec!["zip"]),
|
|
_ => (
|
|
"Export files",
|
|
vec![
|
|
"json", "jsonl", "ndjson", "csv", "md", "markdown", "html", "htm", "py", "sh",
|
|
"zip",
|
|
],
|
|
),
|
|
}
|
|
}
|
|
|
|
fn local_dialog_path(path: tauri_plugin_dialog::FilePath) -> Result<PathBuf, String> {
|
|
path.into_path()
|
|
.map_err(|_| "Only local filesystem paths are supported.".to_string())
|
|
}
|
|
|
|
fn save_selected_file(
|
|
selected_path: Option<PathBuf>,
|
|
content: &[u8],
|
|
) -> Result<Option<String>, String> {
|
|
let Some(path) = selected_path else {
|
|
return Ok(None);
|
|
};
|
|
let parent = path
|
|
.parent()
|
|
.filter(|parent| !parent.as_os_str().is_empty())
|
|
.unwrap_or_else(|| Path::new("."));
|
|
let mut builder = tempfile::Builder::new();
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let permissions = fs::metadata(&path)
|
|
.map(|metadata| metadata.permissions())
|
|
.unwrap_or_else(|_| fs::Permissions::from_mode(0o666));
|
|
builder.permissions(permissions);
|
|
}
|
|
let mut temporary = builder
|
|
.prefix(".unsloth-export-")
|
|
.tempfile_in(parent)
|
|
.map_err(|error| format!("Failed to prepare {}: {error}", path.display()))?;
|
|
temporary
|
|
.write_all(content)
|
|
.and_then(|()| temporary.as_file().sync_all())
|
|
.map_err(|error| format!("Failed to save {}: {error}", path.display()))?;
|
|
temporary
|
|
.persist(&path)
|
|
.map_err(|error| format!("Failed to save {}: {}", path.display(), error.error))?;
|
|
let file_name = path
|
|
.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.unwrap_or("export")
|
|
.to_string();
|
|
Ok(Some(file_name))
|
|
}
|
|
|
|
fn read_selected_import(
|
|
selected_path: Option<PathBuf>,
|
|
) -> Result<Option<NativeImportedFile>, String> {
|
|
let Some(path) = selected_path else {
|
|
return Ok(None);
|
|
};
|
|
let extension = path
|
|
.extension()
|
|
.and_then(|extension| extension.to_str())
|
|
.map(str::to_ascii_lowercase)
|
|
.ok_or_else(|| "Chat import must be a .jsonl, .ndjson, or .csv file.".to_string())?;
|
|
if !CHAT_IMPORT_EXTENSIONS.contains(&extension.as_str()) {
|
|
return Err("Chat import must be a .jsonl, .ndjson, or .csv file.".to_string());
|
|
}
|
|
|
|
let metadata = fs::metadata(&path)
|
|
.map_err(|error| format!("Failed to inspect {}: {error}", path.display()))?;
|
|
if !metadata.is_file() {
|
|
return Err(format!("Selected import is not a file: {}", path.display()));
|
|
}
|
|
if metadata.len() > MAX_CHAT_IMPORT_BYTES {
|
|
return Err(format!(
|
|
"Chat import is too large (maximum {} MiB).",
|
|
MAX_CHAT_IMPORT_BYTES / 1024 / 1024
|
|
));
|
|
}
|
|
|
|
// Limit the read too, so a file that grows after metadata inspection cannot
|
|
// make the command allocate without bound.
|
|
let file =
|
|
File::open(&path).map_err(|error| format!("Failed to open {}: {error}", path.display()))?;
|
|
let mut bytes = Vec::with_capacity(metadata.len() as usize);
|
|
file.take(MAX_CHAT_IMPORT_BYTES + 1)
|
|
.read_to_end(&mut bytes)
|
|
.map_err(|error| format!("Failed to read {}: {error}", path.display()))?;
|
|
if bytes.len() as u64 > MAX_CHAT_IMPORT_BYTES {
|
|
return Err(format!(
|
|
"Chat import is too large (maximum {} MiB).",
|
|
MAX_CHAT_IMPORT_BYTES / 1024 / 1024
|
|
));
|
|
}
|
|
let content = String::from_utf8(bytes)
|
|
.map_err(|_| format!("Chat import is not valid UTF-8: {}", path.display()))?;
|
|
let name = path
|
|
.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.map(str::to_string)
|
|
.unwrap_or_else(|| format!("chat-import.{extension}"));
|
|
Ok(Some(NativeImportedFile { name, content }))
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn save_native_file(
|
|
window: WebviewWindow,
|
|
app: AppHandle,
|
|
request: tauri::ipc::Request<'_>,
|
|
) -> Result<Option<String>, String> {
|
|
crate::native_intents::ensure_main_window(&window)?;
|
|
let encoded_name = request
|
|
.headers()
|
|
.get(NATIVE_FILE_NAME_HEADER)
|
|
.ok_or_else(|| "Native export filename is missing.".to_string())?
|
|
.to_str()
|
|
.map_err(|_| "Invalid native export filename.".to_string())?;
|
|
let file_name = decode_default_file_name(encoded_name)?;
|
|
let content = match request.body() {
|
|
tauri::ipc::InvokeBody::Raw(content) => content,
|
|
_ => return Err("Native export content must be binary.".to_string()),
|
|
};
|
|
let (filter_name, extensions) = save_filter(&file_name);
|
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
|
app.dialog()
|
|
.file()
|
|
.set_title("Save Unsloth export")
|
|
.set_file_name(file_name)
|
|
.add_filter(filter_name, &extensions)
|
|
.save_file(move |path| {
|
|
let _ = tx.send(path);
|
|
});
|
|
let selected_path = rx
|
|
.await
|
|
.map_err(|_| "Save dialog closed unexpectedly.".to_string())?
|
|
.map(local_dialog_path)
|
|
.transpose()?;
|
|
save_selected_file(selected_path, content)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn pick_native_chat_import(
|
|
window: WebviewWindow,
|
|
app: AppHandle,
|
|
) -> Result<Option<NativeImportedFile>, String> {
|
|
crate::native_intents::ensure_main_window(&window)?;
|
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
|
app.dialog()
|
|
.file()
|
|
.set_title("Import chats")
|
|
.add_filter("Chat exports", CHAT_IMPORT_EXTENSIONS)
|
|
.pick_file(move |path| {
|
|
let _ = tx.send(path);
|
|
});
|
|
let selected_path = rx
|
|
.await
|
|
.map_err(|_| "Import dialog closed unexpectedly.".to_string())?
|
|
.map(local_dialog_path)
|
|
.transpose()?;
|
|
read_selected_import(selected_path)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
fn temp_path(name: &str) -> PathBuf {
|
|
let nanos = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos();
|
|
std::env::temp_dir().join(format!(
|
|
"unsloth-native-files-{name}-{}-{nanos}",
|
|
std::process::id()
|
|
))
|
|
}
|
|
|
|
#[test]
|
|
fn cancellation_is_quiet_for_save_and_import() {
|
|
assert!(save_selected_file(None, b"x").unwrap().is_none());
|
|
assert!(read_selected_import(None).unwrap().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn writes_text_and_binary_exactly() {
|
|
// Overwriting must stage the new content before replacing the destination.
|
|
let text_path = temp_path("text").with_extension("json");
|
|
let binary_path = temp_path("binary").with_extension("zip");
|
|
|
|
fs::write(&text_path, b"previous export").unwrap();
|
|
save_selected_file(Some(text_path.clone()), b"{\"ok\":true}").unwrap();
|
|
save_selected_file(Some(binary_path.clone()), &[0, 1, 2, 255]).unwrap();
|
|
assert_eq!(fs::read(&text_path).unwrap(), b"{\"ok\":true}");
|
|
assert_eq!(fs::read(&binary_path).unwrap(), [0, 1, 2, 255]);
|
|
let _ = fs::remove_file(text_path);
|
|
let _ = fs::remove_file(binary_path);
|
|
}
|
|
|
|
#[test]
|
|
fn markdown_exports_use_a_markdown_save_filter() {
|
|
assert_eq!(
|
|
save_filter("message.md"),
|
|
("Markdown", vec!["md", "markdown"])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn html_canvas_exports_use_an_html_save_filter() {
|
|
assert_eq!(save_filter("canvas.html"), ("HTML", vec!["html", "htm"]));
|
|
assert_eq!(save_filter("canvas.HTM"), ("HTML", vec!["html", "htm"]));
|
|
}
|
|
|
|
#[test]
|
|
fn python_scripts_use_a_python_save_filter() {
|
|
assert_eq!(save_filter("script.py"), ("Python", vec!["py"]));
|
|
assert_eq!(save_filter("script.PY"), ("Python", vec!["py"]));
|
|
}
|
|
|
|
#[test]
|
|
fn shell_commands_use_a_shell_save_filter() {
|
|
// The terminal card downloads command.sh through the same cell.
|
|
assert_eq!(save_filter("command.sh"), ("Shell script", vec!["sh"]));
|
|
assert_eq!(save_filter("command.SH"), ("Shell script", vec!["sh"]));
|
|
}
|
|
|
|
#[test]
|
|
fn generic_fallback_covers_every_tool_download_name() {
|
|
let (name, extensions) = save_filter("no-extension");
|
|
assert_eq!(name, "Export files");
|
|
for wanted in ["py", "sh", "json", "jsonl", "csv", "md", "html", "zip"] {
|
|
assert!(extensions.contains(&wanted), "fallback lost {wanted}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn reads_supported_import_and_rejects_other_extensions() {
|
|
let jsonl_path = temp_path("allowed").with_extension("JSONL");
|
|
fs::write(&jsonl_path, "{\"messages\":[]}").unwrap();
|
|
let imported = read_selected_import(Some(jsonl_path.clone()))
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(imported.content, "{\"messages\":[]}");
|
|
|
|
let json_path = temp_path("unsupported").with_extension("json");
|
|
fs::write(&json_path, "{}").unwrap();
|
|
assert!(read_selected_import(Some(json_path.clone())).is_err());
|
|
let txt_path = temp_path("denied").with_extension("txt");
|
|
fs::write(&txt_path, "no").unwrap();
|
|
assert!(read_selected_import(Some(txt_path.clone()))
|
|
.unwrap_err()
|
|
.contains(".json"));
|
|
let _ = fs::remove_file(jsonl_path);
|
|
let _ = fs::remove_file(json_path);
|
|
let _ = fs::remove_file(txt_path);
|
|
}
|
|
|
|
#[test]
|
|
fn read_limit_and_utf8_errors_are_concrete() {
|
|
let oversized = temp_path("oversized").with_extension("csv");
|
|
let file = File::create(&oversized).unwrap();
|
|
file.set_len(MAX_CHAT_IMPORT_BYTES + 1).unwrap();
|
|
assert!(read_selected_import(Some(oversized.clone()))
|
|
.unwrap_err()
|
|
.contains("too large"));
|
|
|
|
let invalid = temp_path("invalid-utf8").with_extension("jsonl");
|
|
fs::write(&invalid, [0xff]).unwrap();
|
|
assert!(read_selected_import(Some(invalid.clone()))
|
|
.unwrap_err()
|
|
.contains("UTF-8"));
|
|
let _ = fs::remove_file(oversized);
|
|
let _ = fs::remove_file(invalid);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn non_utf8_import_name_preserves_csv_extension() {
|
|
use std::ffi::OsString;
|
|
use std::os::unix::ffi::OsStringExt;
|
|
|
|
let path = std::env::temp_dir().join(OsString::from_vec(vec![
|
|
b'u', b'n', b's', b'l', b'o', b't', b'h', 0xff, b'.', b'c', b's', b'v',
|
|
]));
|
|
// Linux happily stores arbitrary bytes in a filename, but macOS enforces
|
|
// UTF-8 on APFS/HFS+ and rejects this name outright. The name-recovery
|
|
// path being asserted here is only reachable where such a file can
|
|
// exist, so skip rather than fail on filesystems that forbid it.
|
|
if fs::write(&path, "role,content\nuser,hello\n").is_err() {
|
|
return;
|
|
}
|
|
let imported = read_selected_import(Some(path.clone())).unwrap().unwrap();
|
|
assert_eq!(imported.name, "chat-import.csv");
|
|
let _ = fs::remove_file(path);
|
|
}
|
|
|
|
#[test]
|
|
fn strips_directories_from_suggested_default_name() {
|
|
assert_eq!(default_file_name("../../chat.jsonl"), "chat.jsonl");
|
|
assert_eq!(default_file_name(""), "unsloth-export.json");
|
|
|
|
assert_eq!(
|
|
decode_default_file_name("Y2hhdC5qc29ubA==").unwrap(),
|
|
"chat.jsonl"
|
|
);
|
|
}
|
|
}
|