fix(editor): protect external SQL files from conflicting changes

This commit is contained in:
zipg authored and GitHub committed 2026-08-06 20:13:59 +08:00
1 parent 575d87c022
commit 89a35d6e02
29 files changed
+1125 -65

No files matched your search

+243 -17
View File
@@ -1,8 +1,10 @@
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::UNIX_EPOCH;
use dbx_core::sql::decode_sql_file_bytes;
use serde::Serialize;
use sha2::{Digest, Sha256};
use tokio::io::AsyncReadExt;
const MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES: u64 = 64 * 1024 * 1024;
@@ -14,9 +16,39 @@ fn exceeds_external_sql_editor_limit(size_bytes: u64) -> bool {
#[derive(Debug, Serialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum ExternalSqlFileReadResult {
Content { content: String },
Content { content: String, version: ExternalSqlFileVersion },
TooLarge { size_bytes: u64, max_size_bytes: u64 },
}
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ExternalSqlFileVersion {
pub size_bytes: u64,
pub modified_ns: String,
pub content_hash: String,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum ExternalSqlFileStatus {
Present { size_bytes: u64, modified_ns: String },
Missing,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum ExternalSqlFileWriteResult {
Written { version: ExternalSqlFileVersion },
Conflict { current_version: ExternalSqlFileVersion },
Missing,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ExternalSqlFileSaveResult {
pub path: String,
pub version: ExternalSqlFileVersion,
}
use tauri_plugin_dialog::DialogExt;
#[tauri::command]
@@ -33,8 +65,19 @@ pub async fn read_external_sql_file(path: String) -> Result<ExternalSqlFileReadR
}
#[tauri::command]
pub async fn write_external_sql_file(path: String, content: String) -> Result<(), String> {
write_external_sql_file_content_async(PathBuf::from(path), content).await
pub async fn inspect_external_sql_file(path: String) -> Result<ExternalSqlFileStatus, String> {
inspect_external_sql_file_async(PathBuf::from(path)).await
}
#[tauri::command]
pub async fn write_external_sql_file(
path: String,
content: String,
expected_content_hash: Option<String>,
expected_missing: bool,
) -> Result<ExternalSqlFileWriteResult, String> {
write_external_sql_file_checked_async(PathBuf::from(path), content, expected_content_hash, expected_missing, false)
.await
}
#[tauri::command]
@@ -42,7 +85,7 @@ pub async fn save_external_sql_file(
window: tauri::Window,
default_file_name: String,
content: String,
) -> Result<Option<String>, String> {
) -> Result<Option<ExternalSqlFileSaveResult>, String> {
let (sender, receiver) = tokio::sync::oneshot::channel();
window.dialog().file().set_file_name(default_file_name).add_filter("SQL", &["sql"]).save_file(move |file_path| {
let _ = sender.send(file_path);
@@ -104,6 +147,28 @@ pub fn is_sql_file_path(path: &Path) -> bool {
path.extension().and_then(|ext| ext.to_str()).map(|ext| ext.eq_ignore_ascii_case("sql")).unwrap_or(false)
}
fn modified_ns(metadata: &std::fs::Metadata) -> String {
metadata
.modified()
.ok()
.and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_nanos())
.unwrap_or_default()
.to_string()
}
fn content_hash(bytes: &[u8]) -> String {
format!("{:x}", Sha256::digest(bytes))
}
fn external_sql_file_version(metadata: &std::fs::Metadata, bytes: &[u8]) -> ExternalSqlFileVersion {
ExternalSqlFileVersion {
size_bytes: metadata.len(),
modified_ns: modified_ns(metadata),
content_hash: content_hash(bytes),
}
}
#[cfg(test)]
fn read_external_sql_file_content(path: &Path) -> Result<ExternalSqlFileReadResult, String> {
if !is_sql_file_path(path) {
@@ -117,7 +182,8 @@ fn read_external_sql_file_content(path: &Path) -> Result<ExternalSqlFileReadResu
});
}
let bytes = std::fs::read(path).map_err(|e| format!("Failed to read SQL file: {e}"))?;
decode_sql_file_bytes(&bytes).map(|content| ExternalSqlFileReadResult::Content { content })
let version = external_sql_file_version(&metadata, &bytes);
decode_sql_file_bytes(&bytes).map(|content| ExternalSqlFileReadResult::Content { content, version })
}
async fn read_external_sql_file_content_async(path: PathBuf) -> Result<ExternalSqlFileReadResult, String> {
@@ -143,7 +209,21 @@ async fn read_external_sql_file_content_async(path: PathBuf) -> Result<ExternalS
max_size_bytes: MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES,
});
}
decode_sql_file_bytes(&bytes).map(|content| ExternalSqlFileReadResult::Content { content })
let version = external_sql_file_version(&metadata, &bytes);
decode_sql_file_bytes(&bytes).map(|content| ExternalSqlFileReadResult::Content { content, version })
}
async fn inspect_external_sql_file_async(path: PathBuf) -> Result<ExternalSqlFileStatus, String> {
if !is_sql_file_path(&path) {
return Err("Only .sql files can be inspected this way".to_string());
}
match tokio::fs::metadata(&path).await {
Ok(metadata) => {
Ok(ExternalSqlFileStatus::Present { size_bytes: metadata.len(), modified_ns: modified_ns(&metadata) })
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(ExternalSqlFileStatus::Missing),
Err(error) => Err(format!("Failed to inspect SQL file: {error}")),
}
}
#[cfg(test)]
@@ -154,31 +234,98 @@ fn write_external_sql_file_content(path: &Path, content: &str) -> Result<(), Str
std::fs::write(path, content).map_err(|e| format!("Failed to save SQL file: {e}"))
}
async fn write_external_sql_file_content_async(path: PathBuf, content: String) -> Result<(), String> {
async fn external_sql_file_version_async(path: &Path) -> Result<Option<ExternalSqlFileVersion>, String> {
let mut file = match tokio::fs::File::open(path).await {
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(format!("Failed to read SQL file before saving: {error}")),
};
let metadata =
file.metadata().await.map_err(|error| format!("Failed to inspect SQL file before saving: {error}"))?;
let mut digest = Sha256::new();
let mut buffer = vec![0_u8; 64 * 1024];
loop {
let read =
file.read(&mut buffer).await.map_err(|error| format!("Failed to read SQL file before saving: {error}"))?;
if read == 0 {
break;
}
digest.update(&buffer[..read]);
}
Ok(Some(ExternalSqlFileVersion {
size_bytes: metadata.len(),
modified_ns: modified_ns(&metadata),
content_hash: format!("{:x}", digest.finalize()),
}))
}
async fn write_external_sql_file_checked_async(
path: PathBuf,
content: String,
expected_content_hash: Option<String>,
expected_missing: bool,
force: bool,
) -> Result<ExternalSqlFileWriteResult, String> {
if !is_sql_file_path(&path) {
return Err("Only .sql files can be saved this way".to_string());
}
tokio::fs::write(&path, content).await.map_err(|e| format!("Failed to save SQL file: {e}"))
if !force {
match external_sql_file_version_async(&path).await? {
Some(current_version) => {
if expected_missing
|| expected_content_hash.as_deref().is_some_and(|expected| expected != current_version.content_hash)
{
return Ok(ExternalSqlFileWriteResult::Conflict { current_version });
}
}
None if expected_content_hash.is_some() => return Ok(ExternalSqlFileWriteResult::Missing),
None => {}
}
}
let hash = content_hash(content.as_bytes());
tokio::fs::write(&path, content).await.map_err(|error| format!("Failed to save SQL file: {error}"))?;
let metadata =
tokio::fs::metadata(&path).await.map_err(|error| format!("Failed to inspect saved SQL file: {error}"))?;
Ok(ExternalSqlFileWriteResult::Written {
version: ExternalSqlFileVersion {
size_bytes: metadata.len(),
modified_ns: modified_ns(&metadata),
content_hash: hash,
},
})
}
async fn save_external_sql_file_content_async(
path: Option<PathBuf>,
content: String,
) -> Result<Option<String>, String> {
) -> Result<Option<ExternalSqlFileSaveResult>, String> {
let Some(path) = path else {
return Ok(None);
};
write_external_sql_file_content_async(path.clone(), content).await?;
Ok(Some(path.to_string_lossy().into_owned()))
let result = write_external_sql_file_checked_async(path.clone(), content, None, false, true).await?;
let ExternalSqlFileWriteResult::Written { version } = result else {
return Err("Failed to save SQL file".to_string());
};
Ok(Some(ExternalSqlFileSaveResult { path: path.to_string_lossy().into_owned(), version }))
}
#[cfg(test)]
fn save_external_sql_file_content(path: Option<&Path>, content: &str) -> Result<Option<String>, String> {
fn save_external_sql_file_content(
path: Option<&Path>,
content: &str,
) -> Result<Option<ExternalSqlFileSaveResult>, String> {
let Some(path) = path else {
return Ok(None);
};
write_external_sql_file_content(path, content)?;
Ok(Some(path.to_string_lossy().into_owned()))
let bytes = std::fs::read(path).map_err(|error| format!("Failed to read saved SQL file: {error}"))?;
let metadata = std::fs::metadata(path).map_err(|error| format!("Failed to inspect saved SQL file: {error}"))?;
Ok(Some(ExternalSqlFileSaveResult {
path: path.to_string_lossy().into_owned(),
version: external_sql_file_version(&metadata, &bytes),
}))
}
fn dedupe_paths(paths: Vec<String>) -> Vec<String> {
@@ -226,7 +373,12 @@ mod tests {
let result = read_external_sql_file_content(&path);
let _ = std::fs::remove_file(&path);
assert_eq!(result.unwrap(), ExternalSqlFileReadResult::Content { content: "select 1;".to_string() });
let ExternalSqlFileReadResult::Content { content, version } = result.unwrap() else {
panic!("expected SQL file content");
};
assert_eq!(content, "select 1;");
assert_eq!(version.size_bytes, 9);
assert_eq!(version.content_hash, content_hash(b"select 1;"));
}
#[test]
@@ -237,7 +389,11 @@ mod tests {
let result = read_external_sql_file_content(&path);
let _ = std::fs::remove_file(&path);
assert_eq!(result.unwrap(), ExternalSqlFileReadResult::Content { content: "select '中文';".to_string() });
let ExternalSqlFileReadResult::Content { content, version } = result.unwrap() else {
panic!("expected SQL file content");
};
assert_eq!(content, "select '中文';");
assert_eq!(version.content_hash, content_hash(b"select '\xD6\xD0\xCE\xC4';"));
}
#[test]
@@ -317,6 +473,76 @@ mod tests {
assert_eq!(content, "select 2;");
}
#[tokio::test]
async fn inspects_present_and_missing_external_sql_files() {
let path = std::env::temp_dir().join(format!("dbx-test-{}.sql", uuid::Uuid::new_v4()));
std::fs::write(&path, "select 1;").unwrap();
let present = inspect_external_sql_file_async(path.clone()).await.unwrap();
assert!(matches!(present, ExternalSqlFileStatus::Present { size_bytes: 9, .. }));
std::fs::remove_file(&path).unwrap();
assert_eq!(inspect_external_sql_file_async(path).await.unwrap(), ExternalSqlFileStatus::Missing);
}
#[tokio::test]
async fn checked_write_rejects_external_content_conflicts() {
let path = std::env::temp_dir().join(format!("dbx-test-{}.sql", uuid::Uuid::new_v4()));
std::fs::write(&path, "select 2;").unwrap();
let result = write_external_sql_file_checked_async(
path.clone(),
"select 3;".to_string(),
Some(content_hash(b"select 1;")),
false,
false,
)
.await
.unwrap();
assert!(matches!(result, ExternalSqlFileWriteResult::Conflict { .. }));
assert_eq!(std::fs::read_to_string(&path).unwrap(), "select 2;");
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
async fn checked_write_reports_missing_and_can_recreate_file() {
let path = std::env::temp_dir().join(format!("dbx-test-{}.sql", uuid::Uuid::new_v4()));
let missing = write_external_sql_file_checked_async(
path.clone(),
"select 2;".to_string(),
Some(content_hash(b"select 1;")),
false,
false,
)
.await
.unwrap();
assert_eq!(missing, ExternalSqlFileWriteResult::Missing);
let recreated = write_external_sql_file_checked_async(path.clone(), "select 2;".to_string(), None, true, false)
.await
.unwrap();
assert!(matches!(recreated, ExternalSqlFileWriteResult::Written { .. }));
assert_eq!(std::fs::read_to_string(&path).unwrap(), "select 2;");
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
async fn checked_write_does_not_overwrite_a_file_recreated_after_confirmation() {
let path = std::env::temp_dir().join(format!("dbx-test-{}.sql", uuid::Uuid::new_v4()));
std::fs::write(&path, "select external;").unwrap();
let result =
write_external_sql_file_checked_async(path.clone(), "select editor;".to_string(), None, true, false)
.await
.unwrap();
assert!(matches!(result, ExternalSqlFileWriteResult::Conflict { .. }));
assert_eq!(std::fs::read_to_string(&path).unwrap(), "select external;");
let _ = std::fs::remove_file(&path);
}
#[test]
fn saves_external_sql_file_with_unicode_name() {
let path = std::env::temp_dir().join(format!("查询-{}.sql", uuid::Uuid::new_v4()));
@@ -325,7 +551,7 @@ mod tests {
let content = std::fs::read_to_string(&path).unwrap();
let _ = std::fs::remove_file(&path);
assert_eq!(result.unwrap(), Some(path.to_string_lossy().into_owned()));
assert_eq!(result.unwrap().unwrap().path, path.to_string_lossy());
assert_eq!(content, "select 3;");
}
@@ -336,7 +562,7 @@ mod tests {
let result = save_external_sql_file_content(Some(&path), "select 4;");
let _ = std::fs::remove_file(&path);
assert_eq!(result.unwrap(), Some(path.to_string_lossy().into_owned()));
assert_eq!(result.unwrap().unwrap().path, path.to_string_lossy());
}
#[test]