From 969cdaed5b4b64936eba9fda42eb878901d25f4a Mon Sep 17 00:00:00 2001 From: SaladDay Date: Mon, 3 Aug 2026 02:08:38 +0000 Subject: [PATCH] fix(pi): make catalog compensation conflict-safe --- src-tauri/src/pi_config/document.rs | 218 ++++++------ src-tauri/src/pi_config/native_settings.rs | 230 +++++++------ src-tauri/src/pi_config/shared_file.rs | 179 +++++++++- src-tauri/src/services/pi_catalog.rs | 368 ++++++++++++++------- src-tauri/src/services/provider/mod.rs | 2 +- 5 files changed, 637 insertions(+), 360 deletions(-) diff --git a/src-tauri/src/pi_config/document.rs b/src-tauri/src/pi_config/document.rs index 9973d6c6f..8eae6e6f7 100644 --- a/src-tauri/src/pi_config/document.rs +++ b/src-tauri/src/pi_config/document.rs @@ -14,12 +14,13 @@ use jsonc_parser::ParseOptions; use regex::Regex; use serde_json::Value; use std::collections::HashMap; -use std::fs::{self, File, Metadata, OpenOptions}; -use std::io::{Read, Take}; +use std::fs; use std::path::{Path, PathBuf}; use std::sync::{Arc, LazyLock, Mutex, MutexGuard}; -use super::shared_file::compare_exchange_shared_file_bytes; +use super::shared_file::{ + compare_exchange_shared_file_bytes, delete_shared_file, read_shared_file, +}; const MAX_PI_MODELS_BYTES: u64 = 8 * 1024 * 1024; const EMPTY_MODELS_DOCUMENT: &str = "{\"providers\":{}}"; @@ -294,101 +295,23 @@ fn parse_models_source(path: &Path, source: &str) -> Result std::io::Result { - use std::os::unix::fs::OpenOptionsExt; - - OpenOptions::new() - .read(true) - .custom_flags(libc::O_NOFOLLOW) - .open(path) -} - -#[cfg(not(unix))] -fn open_read_only(path: &Path) -> std::io::Result { - OpenOptions::new().read(true).open(path) -} - -#[cfg(unix)] -fn same_file(opened: &Metadata, current: &Metadata) -> bool { - use std::os::unix::fs::MetadataExt; - - opened.dev() == current.dev() && opened.ino() == current.ino() -} - -#[cfg(not(unix))] -fn same_file(opened: &Metadata, current: &Metadata) -> bool { - opened.len() == current.len() && opened.modified().ok() == current.modified().ok() -} - -fn read_limited(mut reader: Take<&mut File>, path: &Path) -> Result, AppError> { - let mut bytes = Vec::new(); - reader - .read_to_end(&mut bytes) - .map_err(|error| AppError::io(path, error))?; - if bytes.len() as u64 > MAX_PI_MODELS_BYTES { - return Err(AppError::Config(format!( - "Pi models file exceeds {} bytes: {}", - MAX_PI_MODELS_BYTES, - path.display() - ))); - } - Ok(bytes) -} - fn read_models_bytes(path: &Path) -> Result>, AppError> { - let initial = match fs::symlink_metadata(path) { - Ok(metadata) => metadata, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(error) => return Err(AppError::io(path, error)), - }; - if !initial.file_type().is_file() { - return Err(AppError::Config(format!( - "Pi models path must be a regular file, not a symlink or special file: {}", - path.display() - ))); - } - if initial.len() > MAX_PI_MODELS_BYTES { - return Err(AppError::Config(format!( - "Pi models file exceeds {} bytes: {}", - MAX_PI_MODELS_BYTES, - path.display() - ))); - } + Ok(read_shared_file(path, MAX_PI_MODELS_BYTES, "Pi models file")?.bytes) +} - let mut file = open_read_only(path).map_err(|error| AppError::io(path, error))?; - let opened = file.metadata().map_err(|error| AppError::io(path, error))?; - if !opened.file_type().is_file() || opened.len() > MAX_PI_MODELS_BYTES { - return Err(AppError::Config(format!( - "Pi models path changed or exceeded its size limit: {}", - path.display() - ))); - } - let bytes = read_limited(file.by_ref().take(MAX_PI_MODELS_BYTES + 1), path)?; - let completed = file.metadata().map_err(|error| AppError::io(path, error))?; - - let current = fs::symlink_metadata(path).map_err(|error| AppError::io(path, error))?; - if !current.file_type().is_file() - || !same_file(&completed, ¤t) - || opened.len() != bytes.len() as u64 - || completed.len() != bytes.len() as u64 - || current.len() != bytes.len() as u64 - || opened.modified().ok() != completed.modified().ok() - { - return Err(AppError::Config(format!( - "Pi models file changed during inspection: {}", - path.display() - ))); - } - Ok(Some(bytes)) +fn parse_pi_models_document( + path: &Path, + bytes: Option<&[u8]>, +) -> Result { + let bytes = bytes.unwrap_or_else(|| EMPTY_MODELS_DOCUMENT.as_bytes()); + let source = std::str::from_utf8(bytes) + .map_err(|error| jsonc_error(path, format!("file is not UTF-8: {error}")))?; + parse_models_source(path, source) } pub(super) fn read_pi_models_document(path: &Path) -> Result { - let bytes = - read_models_bytes(path)?.unwrap_or_else(|| EMPTY_MODELS_DOCUMENT.as_bytes().to_vec()); - let source = std::str::from_utf8(&bytes) - .map_err(|error| jsonc_error(path, format!("file is not UTF-8: {error}")))?; - parse_models_source(path, source) + let bytes = read_models_bytes(path)?; + parse_pi_models_document(path, bytes.as_deref()) } fn serialize_models_mutation( @@ -485,26 +408,73 @@ pub(crate) fn apply_pi_provider_patch( ))) } +#[derive(Debug)] +pub(crate) struct PiProviderValuesSnapshot { + pub file_existed: bool, + pub values: IndexMap>, +} + +pub(crate) fn snapshot_pi_provider_values( + path: &Path, + provider_keys: impl IntoIterator, +) -> Result { + let bytes = read_models_bytes(path)?; + let file_existed = bytes.is_some(); + let document = parse_pi_models_document(path, bytes.as_deref())?; + Ok(PiProviderValuesSnapshot { + file_existed, + values: provider_keys + .into_iter() + .map(|key| { + let value = document + .providers() + .get(&key) + .map(|entry| entry.value.clone()); + (key, value) + }) + .collect(), + }) +} + pub(crate) fn current_pi_provider_values( path: &Path, provider_keys: impl IntoIterator, ) -> Result>, AppError> { - let document = read_pi_models_document(path)?; - Ok(provider_keys - .into_iter() - .map(|key| { - let value = document - .providers() - .get(&key) - .map(|entry| entry.value.clone()); - (key, value) - }) - .collect()) + Ok(snapshot_pi_provider_values(path, provider_keys)?.values) +} + +/// Restore an exact-key snapshot and remove a file which this operation +/// created only when its bytes are still the canonical empty document. +/// +/// If Pi or the user added any other content, the file is retained. The +/// revision-checked delete also preserves a writer which races after the +/// emptiness check. +pub(crate) fn restore_pi_provider_values( + path: &Path, + snapshot: &PiProviderValuesSnapshot, +) -> Result<(), AppError> { + apply_pi_provider_patch(path, &snapshot.values)?; + if snapshot.file_existed { + return Ok(()); + } + + let current = read_shared_file(path, MAX_PI_MODELS_BYTES, "Pi models file")?; + let canonical_empty = serialize_models_mutation(path, None, &|_| Ok(()))?; + if current.bytes.as_deref() == Some(canonical_empty.as_slice()) { + delete_shared_file( + path, + ¤t.revision, + MAX_PI_MODELS_BYTES, + "Pi models file", + )?; + } + Ok(()) } #[cfg(test)] mod tests { use super::*; + use std::fs::File; use std::io::Write; #[test] @@ -572,6 +542,46 @@ mod tests { ); } + #[test] + fn missing_models_snapshot_restores_absence_without_deleting_external_content() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("models.json"); + let before = + snapshot_pi_provider_values(&path, ["managed".to_string()]).expect("snapshot absence"); + apply_pi_provider_patch( + &path, + &IndexMap::from([("managed".to_string(), Some(serde_json::json!({"api": "x"})))]), + ) + .expect("publish"); + restore_pi_provider_values(&path, &before).expect("restore absence"); + assert!( + !path.exists(), + "rollback must not leave an empty shadow file" + ); + + let before = + snapshot_pi_provider_values(&path, ["managed".to_string()]).expect("snapshot absence"); + apply_pi_provider_patch( + &path, + &IndexMap::from([("managed".to_string(), Some(serde_json::json!({"api": "x"})))]), + ) + .expect("publish"); + fs::write( + &path, + "{\n \"providers\": {\"managed\": {\"api\": \"x\"}},\n \"external\": true\n}\n", + ) + .expect("external root update"); + restore_pi_provider_values(&path, &before).expect("restore managed key"); + let restored: Value = + serde_json::from_slice(&fs::read(&path).expect("external file retained")) + .expect("parse restored"); + assert_eq!(restored.get("external"), Some(&Value::Bool(true))); + assert!(restored + .get("providers") + .and_then(Value::as_object) + .is_some_and(serde_json::Map::is_empty)); + } + #[test] fn external_rename_during_patch_is_reparsed_before_owned_fields_change() { let temp = tempfile::tempdir().expect("tempdir"); diff --git a/src-tauri/src/pi_config/native_settings.rs b/src-tauri/src/pi_config/native_settings.rs index 213bf8c27..91b9a4644 100644 --- a/src-tauri/src/pi_config/native_settings.rs +++ b/src-tauri/src/pi_config/native_settings.rs @@ -6,12 +6,14 @@ use crate::error::AppError; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; -use std::fs::{self, File, Metadata, OpenOptions}; -use std::io::{Read, Take}; +use std::fs; use std::path::{Path, PathBuf}; use std::sync::{LazyLock, Mutex}; -use super::shared_file::compare_exchange_shared_file_bytes; +use super::shared_file::{ + compare_exchange_shared_file_bytes, delete_shared_file, read_shared_file, replace_shared_file, + SharedFileSnapshot, +}; const MAX_PI_SETTINGS_BYTES: u64 = 1024 * 1024; const MAX_WRITE_ATTEMPTS: usize = 3; @@ -28,6 +30,49 @@ pub(crate) struct PiNativeDefaults { pub session_dir: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PiNativeDefaultsReceipt { + path: PathBuf, + before: SharedFileSnapshot, + after: SharedFileSnapshot, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PiNativeDefaultsRollback { + Restored, + Superseded, +} + +impl PiNativeDefaultsReceipt { + /// Restore the exact file revision replaced by this write. A newer Pi/user + /// edit wins and is reported as Superseded rather than being overwritten. + pub(crate) fn rollback(&self) -> Result { + let result = match self.before.bytes.as_deref() { + Some(bytes) => replace_shared_file( + &self.path, + &self.after.revision, + bytes, + MAX_PI_SETTINGS_BYTES, + None, + "Pi settings rollback", + ) + .map(|_| ()), + None => delete_shared_file( + &self.path, + &self.after.revision, + MAX_PI_SETTINGS_BYTES, + "Pi settings rollback", + ) + .map(|_| ()), + }; + match result { + Ok(()) => Ok(PiNativeDefaultsRollback::Restored), + Err(AppError::Conflict(_)) => Ok(PiNativeDefaultsRollback::Superseded), + Err(error) => Err(error), + } + } +} + pub(crate) fn get_pi_settings_path() -> Result { Ok(super::native::get_pi_agent_dir()?.join("settings.json")) } @@ -51,10 +96,10 @@ pub(crate) fn read_pi_native_defaults_at(path: &Path) -> Result Result { +) -> Result { if provider_key.trim().is_empty() || model_id.trim().is_empty() { return Err(AppError::InvalidInput( "Pi default provider and model must be non-empty".to_string(), @@ -70,37 +115,7 @@ pub(crate) fn set_pi_native_default( Value::String(model_id.to_string()), ); Ok(()) - })?; - read_pi_native_defaults() -} - -/// Restore the two fields owned by cc-switch without touching Pi-owned -/// settings. This is intentionally narrower than replacing settings.json and -/// is used by catalog compensation after a later authority step fails. -pub(crate) fn replace_pi_native_defaults( - defaults: &PiNativeDefaults, -) -> Result { - mutate_settings_document(&get_pi_settings_path()?, |root| { - set_optional_string( - root, - "defaultProvider", - defaults.default_provider.as_deref(), - ); - set_optional_string(root, "defaultModel", defaults.default_model.as_deref()); - Ok(()) - })?; - read_pi_native_defaults() -} - -fn set_optional_string(root: &mut Map, key: &str, value: Option<&str>) { - match value { - Some(value) => { - root.insert(key.to_string(), Value::String(value.to_string())); - } - None => { - root.remove(key); - } - } + }) } fn optional_string( @@ -121,7 +136,7 @@ fn optional_string( fn mutate_settings_document( path: &Path, mut mutator: impl FnMut(&mut Map) -> Result<(), AppError>, -) -> Result<(), AppError> { +) -> Result { let _guard = SETTINGS_WRITE_LOCK .lock() .map_err(|error| AppError::Config(format!("Pi settings lock is poisoned: {error}")))?; @@ -130,8 +145,8 @@ fn mutate_settings_document( } for _ in 0..MAX_WRITE_ATTEMPTS { - let before = read_regular_bytes(path, MAX_PI_SETTINGS_BYTES)?; - let mut document = match before.as_deref() { + let before = read_shared_file(path, MAX_PI_SETTINGS_BYTES, "Pi settings")?; + let mut document = match before.bytes.as_deref() { Some(bytes) => { serde_json::from_slice(bytes).map_err(|error| AppError::json(path, error))? } @@ -150,13 +165,19 @@ fn mutate_settings_document( match compare_exchange_shared_file_bytes( path, - before.as_deref(), + before.bytes.as_deref(), &serialized, MAX_PI_SETTINGS_BYTES, None, "Pi settings", ) { - Ok(_) => return Ok(()), + Ok(after) => { + return Ok(PiNativeDefaultsReceipt { + path: path.to_path_buf(), + before, + after, + }) + } Err(AppError::Conflict(_)) => continue, Err(error) => return Err(error), } @@ -169,88 +190,12 @@ fn mutate_settings_document( } fn read_settings_document(path: &Path) -> Result { - match read_regular_bytes(path, MAX_PI_SETTINGS_BYTES)? { + match read_shared_file(path, MAX_PI_SETTINGS_BYTES, "Pi settings")?.bytes { Some(bytes) => serde_json::from_slice(&bytes).map_err(|error| AppError::json(path, error)), None => Ok(Value::Object(Map::new())), } } -#[cfg(unix)] -fn open_read_only(path: &Path) -> std::io::Result { - use std::os::unix::fs::OpenOptionsExt; - OpenOptions::new() - .read(true) - .custom_flags(libc::O_NOFOLLOW) - .open(path) -} - -#[cfg(not(unix))] -fn open_read_only(path: &Path) -> std::io::Result { - OpenOptions::new().read(true).open(path) -} - -#[cfg(unix)] -fn same_file(left: &Metadata, right: &Metadata) -> bool { - use std::os::unix::fs::MetadataExt; - left.dev() == right.dev() && left.ino() == right.ino() -} - -#[cfg(not(unix))] -fn same_file(left: &Metadata, right: &Metadata) -> bool { - left.len() == right.len() && left.modified().ok() == right.modified().ok() -} - -fn read_limited( - mut reader: Take<&mut File>, - path: &Path, - max_bytes: u64, -) -> Result, AppError> { - let mut bytes = Vec::new(); - reader - .read_to_end(&mut bytes) - .map_err(|error| AppError::io(path, error))?; - if bytes.len() as u64 > max_bytes { - return Err(AppError::Config(format!( - "Pi settings exceeds {max_bytes} bytes: {}", - path.display() - ))); - } - Ok(bytes) -} - -fn read_regular_bytes(path: &Path, max_bytes: u64) -> Result>, AppError> { - let initial = match fs::symlink_metadata(path) { - Ok(metadata) => metadata, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(error) => return Err(AppError::io(path, error)), - }; - if !initial.file_type().is_file() || initial.len() > max_bytes { - return Err(AppError::Config(format!( - "Pi settings must be a bounded regular file: {}", - path.display() - ))); - } - let mut file = open_read_only(path).map_err(|error| AppError::io(path, error))?; - let opened = file.metadata().map_err(|error| AppError::io(path, error))?; - let bytes = read_limited(file.by_ref().take(max_bytes + 1), path, max_bytes)?; - let completed = file.metadata().map_err(|error| AppError::io(path, error))?; - let current = fs::symlink_metadata(path).map_err(|error| AppError::io(path, error))?; - if !current.file_type().is_file() - || !same_file(&opened, &completed) - || !same_file(&completed, ¤t) - || opened.len() != bytes.len() as u64 - || completed.len() != bytes.len() as u64 - || current.len() != bytes.len() as u64 - || opened.modified().ok() != completed.modified().ok() - { - return Err(AppError::Conflict(format!( - "Pi settings changed during read: {}", - path.display() - ))); - } - Ok(Some(bytes)) -} - #[cfg(test)] mod tests { use super::*; @@ -327,4 +272,53 @@ mod tests { symlink(&target, &path).expect("symlink"); assert!(read_pi_native_defaults_at(&path).is_err()); } + + #[test] + fn rollback_receipt_never_overwrites_a_newer_external_default() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("settings.json"); + fs::write( + &path, + serde_json::to_vec_pretty(&json!({ + "theme": "before", + "defaultProvider": "old", + "defaultModel": "old-model" + })) + .expect("serialize"), + ) + .expect("write"); + + let receipt = mutate_settings_document(&path, |root| { + root.insert("defaultProvider".into(), json!("attempted")); + root.insert("defaultModel".into(), json!("attempted-model")); + Ok(()) + }) + .expect("write attempted defaults"); + fs::write( + &path, + serde_json::to_vec_pretty(&json!({ + "theme": "external", + "defaultProvider": "external", + "defaultModel": "external-model" + })) + .expect("serialize external"), + ) + .expect("external write"); + + assert_eq!( + receipt.rollback().expect("rollback decision"), + PiNativeDefaultsRollback::Superseded + ); + assert_eq!( + read_pi_native_defaults_at(&path) + .expect("live defaults") + .default_provider + .as_deref(), + Some("external") + ); + assert_eq!( + read_settings_document(&path).expect("live document")["theme"], + "external" + ); + } } diff --git a/src-tauri/src/pi_config/shared_file.rs b/src-tauri/src/pi_config/shared_file.rs index a3be586cc..0f1029315 100644 --- a/src-tauri/src/pi_config/shared_file.rs +++ b/src-tauri/src/pi_config/shared_file.rs @@ -23,6 +23,9 @@ type CompareExchangeHooks = HashMap>; #[cfg(test)] static BEFORE_COMPARE_EXCHANGE: LazyLock> = LazyLock::new(|| Mutex::new(HashMap::new())); +#[cfg(test)] +static BEFORE_ROLLBACK_EXCHANGE: LazyLock> = + LazyLock::new(|| Mutex::new(HashMap::new())); #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct SharedFileSnapshot { @@ -230,6 +233,7 @@ fn replace_existing_if_equal( return Ok(snapshot(Some(replacement))); } + run_before_rollback_exchange_hook(path)?; let proposed_or_raced = restore_displaced(&displaced, path).map_err(|error| { AppError::Conflict(format!( "{label} changed during atomic replacement and could not be restored; \ @@ -241,6 +245,20 @@ fn replace_existing_if_equal( if proposed_bytes.as_deref() == Some(replacement) { fs::remove_file(&proposed_or_raced) .map_err(|error| AppError::io(&proposed_or_raced, error))?; + } else { + // A second external replacement won the namespace after we detected + // the first conflict. The rollback necessarily moved those newer + // bytes into the recovery path. Do not let higher layers classify + // this as an ordinary retryable conflict and silently continue from + // the older canonical file. + sync_parent( + path.parent() + .expect("a path accepted by compare/exchange has a parent"), + )?; + return Err(AppError::Config(format!( + "{label} changed again during rollback; newer external bytes are preserved at {} and require explicit recovery", + proposed_or_raced.display() + ))); } sync_parent( path.parent() @@ -306,20 +324,33 @@ fn stage_replacement( let mut options = OpenOptions::new(); options.create_new(true).write(true); #[cfg(unix)] - { + let requested_mode = { use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; let mode = if preserve_mode { - fs::metadata(path) - .map(|metadata| metadata.permissions().mode()) - .unwrap_or_else(|_| new_file_mode.unwrap_or(0o666)) + Some( + fs::metadata(path) + .map(|metadata| metadata.permissions().mode()) + .unwrap_or_else(|_| new_file_mode.unwrap_or(0o666)), + ) } else { - new_file_mode.unwrap_or(0o666) + new_file_mode }; - options.mode(mode); - } + options.mode(mode.unwrap_or(0o666)); + mode.map(|mode| mode & 0o7777) + }; let mut file = options .open(&staged) .map_err(|error| AppError::io(&staged, error))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + // open(2) always applies umask, including when OpenOptionsExt::mode is + // used. Restore the exact requested bits before the durable commit. + if let Some(requested_mode) = requested_mode { + file.set_permissions(fs::Permissions::from_mode(requested_mode)) + .map_err(|error| AppError::io(&staged, error))?; + } + } file.write_all(bytes) .map_err(|error| AppError::io(&staged, error))?; file.flush() @@ -566,6 +597,12 @@ fn is_destination_exists(error: &std::io::Error) -> bool { || matches!(error.raw_os_error(), Some(libc::EEXIST)) } +/// Publish a staged file or directory without replacing a path created by a +/// concurrent writer. +pub(crate) fn publish_path_noreplace(source: &Path, destination: &Path) -> std::io::Result<()> { + rename_noreplace(source, destination) +} + #[cfg(test)] pub(crate) fn replace_before_next_compare_exchange(path: &Path, bytes: &[u8]) { BEFORE_COMPARE_EXCHANGE @@ -575,9 +612,12 @@ pub(crate) fn replace_before_next_compare_exchange(path: &Path, bytes: &[u8]) { } #[cfg(test)] -fn run_before_compare_exchange_hook(path: &Path) -> Result<(), AppError> { +fn run_file_replacement_hook( + hooks: &Mutex, + path: &Path, +) -> Result<(), AppError> { let replacement = { - let mut hook = BEFORE_COMPARE_EXCHANGE + let mut hook = hooks .lock() .map_err(|error| AppError::Config(format!("Pi CAS test hook is poisoned: {error}")))?; hook.remove(path) @@ -588,11 +628,34 @@ fn run_before_compare_exchange_hook(path: &Path) -> Result<(), AppError> { Ok(()) } +#[cfg(test)] +fn run_before_compare_exchange_hook(path: &Path) -> Result<(), AppError> { + run_file_replacement_hook(&BEFORE_COMPARE_EXCHANGE, path) +} + #[cfg(not(test))] fn run_before_compare_exchange_hook(_path: &Path) -> Result<(), AppError> { Ok(()) } +#[cfg(test)] +fn replace_before_next_rollback_exchange(path: &Path, bytes: &[u8]) { + BEFORE_ROLLBACK_EXCHANGE + .lock() + .expect("rollback exchange test hook lock") + .insert(path.to_path_buf(), bytes.to_vec()); +} + +#[cfg(test)] +fn run_before_rollback_exchange_hook(path: &Path) -> Result<(), AppError> { + run_file_replacement_hook(&BEFORE_ROLLBACK_EXCHANGE, path) +} + +#[cfg(not(test))] +fn run_before_rollback_exchange_hook(_path: &Path) -> Result<(), AppError> { + Ok(()) +} + fn ensure_revision(path: &Path, expected: &str, actual: &str) -> Result<(), AppError> { if expected == actual { Ok(()) @@ -630,7 +693,17 @@ fn open_read_only(path: &Path) -> std::io::Result { .open(path) } -#[cfg(not(unix))] +#[cfg(windows)] +fn open_read_only(path: &Path) -> std::io::Result { + use std::os::windows::fs::OpenOptionsExt; + use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OPEN_REPARSE_POINT; + OpenOptions::new() + .read(true) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) + .open(path) +} + +#[cfg(not(any(unix, windows)))] fn open_read_only(path: &Path) -> std::io::Result { OpenOptions::new().read(true).open(path) } @@ -641,11 +714,38 @@ fn same_file(left: &Metadata, right: &Metadata) -> bool { left.dev() == right.dev() && left.ino() == right.ino() } -#[cfg(not(unix))] +#[cfg(windows)] +fn same_file(left: &Metadata, right: &Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + matches!( + ( + left.volume_serial_number(), + left.file_index(), + right.volume_serial_number(), + right.file_index(), + ), + (Some(left_volume), Some(left_index), Some(right_volume), Some(right_index)) + if left_volume == right_volume && left_index == right_index + ) +} + +#[cfg(not(any(unix, windows)))] fn same_file(left: &Metadata, right: &Metadata) -> bool { left.len() == right.len() && left.modified().ok() == right.modified().ok() } +#[cfg(windows)] +fn is_reparse_point(metadata: &Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT; + metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 +} + +#[cfg(not(windows))] +fn is_reparse_point(_metadata: &Metadata) -> bool { + false +} + fn read_limited( mut reader: Take<&mut File>, path: &Path, @@ -674,7 +774,7 @@ fn read_regular_bytes( Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(error) => return Err(AppError::io(path, error)), }; - if !initial.file_type().is_file() || initial.len() > max_bytes { + if !initial.file_type().is_file() || is_reparse_point(&initial) || initial.len() > max_bytes { return Err(AppError::InvalidInput(format!( "{label} must be a bounded regular file: {}", path.display() @@ -685,7 +785,12 @@ fn read_regular_bytes( let bytes = read_limited(Read::by_ref(&mut file).take(max_bytes + 1), path, max_bytes)?; let completed = file.metadata().map_err(|error| AppError::io(path, error))?; let current = fs::symlink_metadata(path).map_err(|error| AppError::io(path, error))?; - if !current.file_type().is_file() + if !opened.file_type().is_file() + || is_reparse_point(&opened) + || !completed.file_type().is_file() + || is_reparse_point(&completed) + || !current.file_type().is_file() + || is_reparse_point(¤t) || !same_file(&opened, &completed) || !same_file(&completed, ¤t) || opened.len() != bytes.len() as u64 @@ -747,6 +852,54 @@ mod tests { ); } + #[test] + fn second_external_rename_surfaces_the_recovery_artifact() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("shared.md"); + fs::write(&path, b"observed").expect("seed"); + let observed = read_shared_file(&path, 1024, "test").expect("snapshot"); + + replace_before_next_compare_exchange(&path, b"external-a"); + replace_before_next_rollback_exchange(&path, b"external-b"); + let error = replace_shared_file(&path, &observed.revision, b"ours", 1024, None, "test") + .expect_err("the second race needs explicit recovery"); + let message = error.to_string(); + assert!( + matches!(error, AppError::Config(_)), + "recovery conflicts must not be auto-retried: {message}" + ); + assert!(message.contains("explicit recovery")); + assert_eq!( + fs::read(&path).expect("first external version restored"), + b"external-a" + ); + assert!( + fs::read_dir(temp.path()) + .expect("recovery directory") + .filter_map(Result::ok) + .any(|entry| fs::read(entry.path()).ok().as_deref() == Some(b"external-b")), + "the newest external bytes must remain in a named recovery artifact" + ); + } + + #[cfg(unix)] + #[test] + fn replacement_preserves_exact_existing_permissions_despite_umask() { + use std::os::unix::fs::PermissionsExt; + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("shared.md"); + fs::write(&path, b"before").expect("seed"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o764)).expect("set mode"); + let before = read_shared_file(&path, 1024, "test").expect("snapshot"); + + replace_shared_file(&path, &before.revision, b"after", 1024, None, "test") + .expect("replace"); + assert_eq!( + fs::metadata(&path).expect("metadata").permissions().mode() & 0o7777, + 0o764 + ); + } + #[cfg(unix)] #[test] fn symlink_targets_fail_closed() { diff --git a/src-tauri/src/services/pi_catalog.rs b/src-tauri/src/services/pi_catalog.rs index 3f3ddeff2..4a9133f4a 100644 --- a/src-tauri/src/services/pi_catalog.rs +++ b/src-tauri/src/services/pi_catalog.rs @@ -10,7 +10,10 @@ use crate::database::{ NewEndpoint, NewProviderAggregate, PiProviderProjection, ProviderKey, ProviderRowUpdate, }; use crate::error::AppError; -use crate::pi_config::document::{apply_pi_provider_patch, current_pi_provider_values}; +use crate::pi_config::document::{ + apply_pi_provider_patch, current_pi_provider_values, restore_pi_provider_values, + snapshot_pi_provider_values, PiProviderValuesSnapshot, +}; use crate::pi_config::model::{ effective_pi_model, validate_pi_managed_provider, PiManagedProviderConfig, PiManagementStatus, }; @@ -18,7 +21,8 @@ use crate::pi_config::native::{ get_pi_models_path, inspect_pi_native_entry, PiNativeInspectionService, }; use crate::pi_config::native_settings::{ - read_pi_native_defaults, replace_pi_native_defaults, set_pi_native_default, PiNativeDefaults, + read_pi_native_defaults, set_pi_native_default_with_receipt, PiNativeDefaults, + PiNativeDefaultsReceipt, PiNativeDefaultsRollback, }; use crate::provider::{ProviderAggregate, ProviderMutationInput}; use crate::settings; @@ -88,6 +92,8 @@ pub(crate) struct PiCatalogMutationResult { pub authority: PiCatalogAuthority, #[serde(skip_serializing_if = "Option::is_none")] pub provider_id: Option, + #[serde(skip)] + native_defaults_receipt: Option, } pub(crate) struct PiCatalogCoordinator; @@ -95,11 +101,9 @@ pub(crate) struct PiCatalogCoordinator; struct PiCatalogSnapshot { aggregates: IndexMap, projections: Vec, - defaults: PiNativeDefaults, - local_current: Option, db_current: Option, models_path: PathBuf, - native_values: IndexMap>, + native: PiProviderValuesSnapshot, } impl PiCatalogCoordinator { @@ -191,7 +195,7 @@ impl PiCatalogCoordinator { pub(crate) fn reconcile_portable_import(state: &AppState) -> Result<(), AppError> { Self::run_with_runtime_reconcile(state, None, || { let models_path = get_pi_models_path()?; - Self::reconcile_portable_catalog_at( + let native_defaults_receipt = Self::reconcile_portable_catalog_at( state, &models_path, |provider_id, provider_key, config| { @@ -200,7 +204,7 @@ impl PiCatalogCoordinator { .project_pi_provider_value(provider_id, provider_key, config) }, )?; - Ok(success(None)) + Ok(success(None).with_native_defaults_receipt(native_defaults_receipt)) }) .map(|_| ()) } @@ -220,6 +224,11 @@ impl PiCatalogCoordinator { let catalog_epoch = futures::executor::block_on(state.proxy_service.begin_pi_catalog_mutation()); let result = operation(); + let native_defaults_receipt = result + .as_ref() + .ok() + .and_then(|result| result.native_defaults_receipt.as_ref()) + .cloned(); let reconcile = futures::executor::block_on( state .proxy_service @@ -228,7 +237,9 @@ impl PiCatalogCoordinator { match (result, reconcile) { (Ok(result), Ok(_)) => Ok(result), (Ok(_), Err(error)) => { - if let Err(rollback_error) = snapshot.restore(state) { + if let Err(rollback_error) = + snapshot.restore(state, native_defaults_receipt.as_ref()) + { let _ = futures::executor::block_on( state.proxy_service.close_pi_runtime_at_epoch(catalog_epoch), ); @@ -277,7 +288,7 @@ impl PiCatalogCoordinator { state: &AppState, models_path: &std::path::Path, mut project: impl FnMut(&str, &str, &PiManagedProviderConfig) -> Result, - ) -> Result<(), AppError> { + ) -> Result, AppError> { let providers = state.db.get_all_providers(PI_APP)?; let manifest = state.db.get_pi_projection_manifest()?; let claimed_keys = manifest @@ -299,6 +310,7 @@ impl PiCatalogCoordinator { } let mut plans = Vec::with_capacity(providers.len()); + let mut planned_key_owners = BTreeMap::::new(); for provider in providers.values() { let config: PiManagedProviderConfig = serde_json::from_value(provider.settings_config.clone()).map_err(|error| { @@ -317,6 +329,16 @@ impl PiCatalogCoordinator { Some(projection) => (projection.provider_key.clone(), false), None => (non_empty_native_key(&provider.id)?.to_string(), true), }; + if let Some(owner) = + planned_key_owners.insert(provider_key.clone(), provider.id.clone()) + { + if owner != provider.id { + return Err(AppError::Conflict(format!( + "imported Pi providers '{owner}' and '{}' normalize to the same native key '{provider_key}'", + provider.id + ))); + } + } if let Some(owner) = claimed_keys.get(&provider_key) { if owner != &provider.id { return Err(AppError::Conflict(format!( @@ -478,37 +500,31 @@ impl PiCatalogCoordinator { None }; + let mut native_defaults_receipt = None; if let Some((current_provider, model_id)) = current_selection { - let previous_local = settings::get_current_provider(&AppType::Pi); - let previous_db = state.db.get_current_provider(PI_APP)?; - if let Err(error) = Self::set_default(state, ¤t_provider, &model_id) { - let file_restored = apply_pi_provider_patch(models_path, &before_file); - let claims_restored = compensate_portable_projection_ledger( - state, - &newly_claimed, - &released_claims, - error, - ); - let defaults_restored = replace_pi_native_defaults(&previous_defaults); - let local_restored = - settings::set_current_provider(&AppType::Pi, previous_local.as_deref()); - let db_restored = restore_db_current(state, previous_db.as_deref()); - if file_restored.is_err() - || defaults_restored.is_err() - || local_restored.is_err() - || db_restored.is_err() - { - return Err(authority_error( - PiCatalogAuthority::ProjectionPending, - format!( - "{claims_restored}; imported Pi default compensation was incomplete" - ), - )); + match Self::set_default(state, ¤t_provider, &model_id) { + Ok(result) => native_defaults_receipt = result.native_defaults_receipt, + Err(error) => { + let file_restored = apply_pi_provider_patch(models_path, &before_file); + let claims_restored = compensate_portable_projection_ledger( + state, + &newly_claimed, + &released_claims, + error, + ); + if file_restored.is_err() { + return Err(authority_error( + PiCatalogAuthority::ProjectionPending, + format!( + "{claims_restored}; imported Pi default compensation was incomplete" + ), + )); + } + return Err(claims_restored); } - return Err(claims_restored); } } - Ok(()) + Ok(native_defaults_receipt) } pub(crate) fn inspect_native( @@ -605,6 +621,7 @@ impl PiCatalogCoordinator { let no_selected_provider = previous_local.is_none() && previous_db.is_none(); let native_defaults_empty = previous_defaults.default_provider.is_none() && previous_defaults.default_model.is_none(); + let mut result = success(Some(provider_id.clone())); if activate_if_first && catalog_was_empty && no_selected_provider && native_defaults_empty { let first_model = config .models @@ -612,31 +629,23 @@ impl PiCatalogCoordinator { .expect("validated Pi config has at least one model") .id .clone(); - if let Err(error) = Self::set_default(state, &provider_id, &first_model) { - let defaults_restore = replace_pi_native_defaults(&previous_defaults); - let local_restore = - settings::set_current_provider(&AppType::Pi, previous_local.as_deref()); - let db_restore = restore_db_current(state, previous_db.as_deref()); - let catalog_restore = compensate_created_provider( - state, - &models_path, - &provider_id, - &before_file, - error, - ); - if defaults_restore.is_err() || local_restore.is_err() || db_restore.is_err() { - return Err(authority_error( - PiCatalogAuthority::ProjectionPending, - format!( - "Pi create activation failed and compensation was incomplete: {catalog_restore}" - ), + match Self::set_default(state, &provider_id, &first_model) { + Ok(activation) => { + result.native_defaults_receipt = activation.native_defaults_receipt; + } + Err(error) => { + return Err(compensate_created_provider( + state, + &models_path, + &provider_id, + &before_file, + error, )); } - return Err(catalog_restore); } } - Ok(success(Some(provider_id))) + Ok(result) } fn update( @@ -655,7 +664,6 @@ impl PiCatalogCoordinator { .get_provider_aggregate(PI_APP, &provider_id)? .ok_or_else(|| AppError::NotFound(format!("Pi provider '{provider_id}'")))?; let previous_defaults = read_pi_native_defaults()?; - let previous_local = settings::get_current_provider(&AppType::Pi); let previous_db = state.db.get_current_provider(PI_APP)?; let was_current = previous_db.as_deref() == Some(&provider_id); let models_path = get_pi_models_path()?; @@ -683,6 +691,7 @@ impl PiCatalogCoordinator { error, )); } + let mut result = success(Some(provider_id.clone())); if previous_defaults.default_provider.as_deref() == Some(&projection.provider_key) { if let Some(default_model) = previous_defaults.default_model.as_deref() { if effective_pi_model(&config, default_model).is_err() { @@ -692,37 +701,26 @@ impl PiCatalogCoordinator { .expect("validated Pi config has at least one model") .id .clone(); - if let Err(error) = Self::set_default(state, &provider_id, &replacement) { - let defaults_restored = replace_pi_native_defaults(&previous_defaults); - let local_restored = - settings::set_current_provider(&AppType::Pi, previous_local.as_deref()); - let db_restored = restore_db_current(state, previous_db.as_deref()); - let catalog_restored = compensate_existing_provider( - state, - &models_path, - &previous, - was_current, - &projection, - &before_file, - error, - ); - if defaults_restored.is_err() - || local_restored.is_err() - || db_restored.is_err() - { - return Err(authority_error( - PiCatalogAuthority::ProjectionPending, - format!( - "{catalog_restored}; Pi default-model compensation was incomplete" - ), + match Self::set_default(state, &provider_id, &replacement) { + Ok(default_update) => { + result.native_defaults_receipt = default_update.native_defaults_receipt; + } + Err(error) => { + return Err(compensate_existing_provider( + state, + &models_path, + &previous, + was_current, + &projection, + &before_file, + error, )); } - return Err(catalog_restored); } } } } - Ok(success(Some(provider_id))) + Ok(result) } fn delete(state: &AppState, provider_id: &str) -> Result { @@ -872,32 +870,71 @@ impl PiCatalogCoordinator { )) })?; - let previous_defaults = read_pi_native_defaults()?; let previous_local = settings::get_current_provider(&AppType::Pi); let previous_db = state.db.get_current_provider(PI_APP)?; - set_pi_native_default(&projection.provider_key, model_id)?; + let native_defaults_receipt = + set_pi_native_default_with_receipt(&projection.provider_key, model_id)?; if let Err(error) = settings::set_current_provider(&AppType::Pi, Some(provider_id)) { - let restored = replace_pi_native_defaults(&previous_defaults); - return Err(if restored.is_ok() { - authority_error(PiCatalogAuthority::PreviousRestored, error.to_string()) - } else { - authority_error(PiCatalogAuthority::ProjectionPending, error.to_string()) - }); + return match native_defaults_receipt.rollback() { + Ok(PiNativeDefaultsRollback::Restored) => Err(authority_error( + PiCatalogAuthority::PreviousRestored, + error.to_string(), + )), + Ok(PiNativeDefaultsRollback::Superseded) => { + let indexes = Self::reconcile_current_indexes_from_native(state).map_or_else( + |index_error| format!("; current-index reconcile failed: {index_error}"), + |()| "; external native defaults were preserved".to_string(), + ); + Err(authority_error( + PiCatalogAuthority::ProjectionPending, + format!("{error}{indexes}"), + )) + } + Err(rollback_error) => Err(authority_error( + PiCatalogAuthority::ProjectionPending, + format!("{error}; native-default rollback failed: {rollback_error}"), + )), + }; } if let Err(error) = state.db.set_current_provider(PI_APP, provider_id) { - let native_restored = replace_pi_native_defaults(&previous_defaults); - let local_restored = - settings::set_current_provider(&AppType::Pi, previous_local.as_deref()); - let db_restored = restore_db_current(state, previous_db.as_deref()); - let authority = - if native_restored.is_ok() && local_restored.is_ok() && db_restored.is_ok() { - PiCatalogAuthority::PreviousRestored - } else { - PiCatalogAuthority::ProjectionPending - }; - return Err(authority_error(authority, error.to_string())); + return match native_defaults_receipt.rollback() { + Ok(PiNativeDefaultsRollback::Restored) => { + let local_restored = + settings::set_current_provider(&AppType::Pi, previous_local.as_deref()); + let db_restored = restore_db_current(state, previous_db.as_deref()); + let authority = if local_restored.is_ok() && db_restored.is_ok() { + PiCatalogAuthority::PreviousRestored + } else { + PiCatalogAuthority::ProjectionPending + }; + Err(authority_error(authority, error.to_string())) + } + Ok(PiNativeDefaultsRollback::Superseded) => { + let indexes = Self::reconcile_current_indexes_from_native(state).map_or_else( + |index_error| format!("; current-index reconcile failed: {index_error}"), + |()| "; external native defaults were preserved".to_string(), + ); + Err(authority_error( + PiCatalogAuthority::ProjectionPending, + format!("{error}{indexes}"), + )) + } + Err(rollback_error) => { + let indexes = Self::reconcile_current_indexes_from_native(state).map_or_else( + |index_error| format!("; current-index reconcile failed: {index_error}"), + |()| String::new(), + ); + Err(authority_error( + PiCatalogAuthority::ProjectionPending, + format!( + "{error}; native-default rollback failed: {rollback_error}{indexes}" + ), + )) + } + }; } - Ok(success(Some(provider_id.to_string()))) + Ok(success(Some(provider_id.to_string())) + .with_native_defaults_receipt(Some(native_defaults_receipt))) } } @@ -1063,29 +1100,35 @@ impl PiCatalogSnapshot { let aggregates = state.db.get_all_provider_aggregates(PI_APP)?; let manifest = state.db.get_pi_projection_manifest()?; let projections = manifest.values().cloned().collect::>(); - let mut native_keys = manifest - .values() - .map(|projection| projection.provider_key.clone()) - .collect::>(); + let mut native_keys = BTreeMap::::new(); + for projection in manifest.values() { + native_keys.insert(projection.provider_key.clone(), ()); + } + // Portable providers have no device-local claim yet and reconcile + // under their normalized id. Include every possible new key in the + // snapshot so a later runtime failure can remove it again. + for provider_id in aggregates.keys() { + native_keys.insert(non_empty_native_key(provider_id)?.to_string(), ()); + } if let Some(key) = additional_native_key { - if !native_keys.iter().any(|candidate| candidate == key) { - native_keys.push(key.to_string()); - } + native_keys.insert(non_empty_native_key(key)?.to_string(), ()); } let models_path = get_pi_models_path()?; - let native_values = current_pi_provider_values(&models_path, native_keys)?; + let native = snapshot_pi_provider_values(&models_path, native_keys.into_keys())?; Ok(Self { aggregates, projections, - defaults: read_pi_native_defaults()?, - local_current: settings::get_current_provider(&AppType::Pi), db_current: state.db.get_current_provider(PI_APP)?, models_path, - native_values, + native, }) } - fn restore(&self, state: &AppState) -> Result<(), AppError> { + fn restore( + &self, + state: &AppState, + native_defaults_receipt: Option<&PiNativeDefaultsReceipt>, + ) -> Result<(), AppError> { let mut failures = Vec::new(); if let Err(error) = state.db.restore_pi_catalog_snapshot( &self.aggregates, @@ -1094,17 +1137,19 @@ impl PiCatalogSnapshot { ) { failures.push(format!("database={error}")); } - if let Err(error) = replace_pi_native_defaults(&self.defaults) { - failures.push(format!("native_defaults={error}")); - } - if let Err(error) = - settings::set_current_provider(&AppType::Pi, self.local_current.as_deref()) - { - failures.push(format!("local_current={error}")); - } - if let Err(error) = apply_pi_provider_patch(&self.models_path, &self.native_values) { + if let Err(error) = restore_pi_provider_values(&self.models_path, &self.native) { failures.push(format!("models={error}")); } + if let Some(receipt) = native_defaults_receipt { + if let Err(error) = receipt.rollback() { + failures.push(format!("native_defaults={error}")); + } + } + // Current markers are derived indexes. Re-read the native authority so + // an external selection which superseded our receipt is preserved. + if let Err(error) = PiCatalogCoordinator::reconcile_current_indexes_from_native(state) { + failures.push(format!("current_indexes={error}")); + } if failures.is_empty() { Ok(()) } else { @@ -1134,6 +1179,14 @@ fn success(provider_id: Option) -> PiCatalogMutationResult { PiCatalogMutationResult { authority: PiCatalogAuthority::Published, provider_id, + native_defaults_receipt: None, + } +} + +impl PiCatalogMutationResult { + fn with_native_defaults_receipt(mut self, receipt: Option) -> Self { + self.native_defaults_receipt = receipt; + self } } @@ -1373,6 +1426,71 @@ mod tests { Ok(()) } + #[test] + #[serial_test::serial] + fn catalog_projection_runtime_failures_restore_new_normalized_keys_and_file_absence( + ) -> Result<(), AppError> { + struct HomeGuard(Option); + impl Drop for HomeGuard { + fn drop(&mut self) { + match self.0.take() { + Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value), + None => std::env::remove_var("CC_SWITCH_TEST_HOME"), + } + let _ = crate::settings::reload_settings(); + } + } + + let temp = tempfile::tempdir().expect("tempdir"); + let _home = HomeGuard(std::env::var_os("CC_SWITCH_TEST_HOME")); + std::env::set_var("CC_SWITCH_TEST_HOME", temp.path()); + crate::settings::reload_settings()?; + let pi_dir = temp.path().join("pi-agent"); + let mut app_settings = crate::settings::get_settings(); + app_settings.pi_config_dir = Some(pi_dir.to_string_lossy().into_owned()); + app_settings.pi_takeover_enabled = false; + crate::settings::update_settings(app_settings)?; + + let db = Arc::new(Database::memory()?); + insert_portable_pi_provider(&db, "portable-pi")?; + let state = AppState::new(db.clone()); + state.proxy_service.fail_next_pi_reconcile_for_test(); + let error = PiCatalogCoordinator::reconcile_portable_import(&state) + .expect_err("injected runtime publication failure"); + assert!(error.to_string().contains("previous catalog was restored")); + assert!( + db.get_pi_projection("portable-pi")?.is_none(), + "a failed portable projection must release its new exact-key claim" + ); + assert!( + !pi_dir.join("models.json").exists(), + "a failed portable projection must restore a previously absent native file" + ); + assert!( + db.get_provider_aggregate(PI_APP, "portable-pi")?.is_some(), + "portable provider rows predate reconciliation and remain authoritative" + ); + + state.proxy_service.fail_next_pi_reconcile_for_test(); + let error = PiCatalogCoordinator::apply( + &state, + PiCatalogMutation::CreateProvider { + input: managed_input("new-provider", "https://new.example/v1"), + provider_key: " new-native ".to_string(), + activate_if_first: false, + }, + ) + .expect_err("injected create publication failure"); + assert!(error.to_string().contains("previous catalog was restored")); + assert!(db.get_provider_aggregate(PI_APP, "new-provider")?.is_none()); + assert!(db.get_pi_projection("new-provider")?.is_none()); + assert!( + !pi_dir.join("models.json").exists(), + "the normalized additional create key must be part of the rollback snapshot" + ); + Ok(()) + } + #[test] #[serial_test::serial] fn external_native_switch_repairs_indexes_before_deleting_the_inactive_provider( @@ -1425,7 +1543,9 @@ mod tests { Some("provider-a") ); - set_pi_native_default("native-b", "model-a")?; + crate::pi_config::native_settings::set_pi_native_default_with_receipt( + "native-b", "model-a", + )?; assert_eq!( PiCatalogCoordinator::current_native_provider(&state)?.as_deref(), Some("provider-b"), diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index cf5292a09..c501e6649 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -810,7 +810,7 @@ mod tests { state.db.get_current_provider("pi")?.as_deref(), Some("native-first") ); - crate::pi_config::native_settings::set_pi_native_default( + crate::pi_config::native_settings::set_pi_native_default_with_receipt( "native-second", "model-b", )?;