fix(pi): make catalog compensation conflict-safe

This commit is contained in:
SaladDay
2026-08-03 02:08:38 +00:00
parent 57ba6beb43
commit 969cdaed5b
5 changed files with 637 additions and 360 deletions
+114 -104
View File
@@ -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<PiModelsDocument, Ap
Ok(PiModelsDocument { providers })
}
#[cfg(unix)]
fn open_read_only(path: &Path) -> std::io::Result<File> {
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<File> {
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<Vec<u8>, 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<Option<Vec<u8>>, 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, &current)
|| 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<PiModelsDocument, AppError> {
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<PiModelsDocument, AppError> {
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<String, Option<Value>>,
}
pub(crate) fn snapshot_pi_provider_values(
path: &Path,
provider_keys: impl IntoIterator<Item = String>,
) -> Result<PiProviderValuesSnapshot, AppError> {
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<Item = String>,
) -> Result<IndexMap<String, Option<Value>>, 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,
&current.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");
+112 -118
View File
@@ -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<String>,
}
#[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<PiNativeDefaultsRollback, AppError> {
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<PathBuf, AppError> {
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<PiNativeDefaults
})
}
pub(crate) fn set_pi_native_default(
pub(crate) fn set_pi_native_default_with_receipt(
provider_key: &str,
model_id: &str,
) -> Result<PiNativeDefaults, AppError> {
) -> Result<PiNativeDefaultsReceipt, AppError> {
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<PiNativeDefaults, AppError> {
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<String, Value>, 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<String, Value>) -> Result<(), AppError>,
) -> Result<(), AppError> {
) -> Result<PiNativeDefaultsReceipt, AppError> {
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<Value, AppError> {
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<File> {
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<File> {
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<Vec<u8>, 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<Option<Vec<u8>>, 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, &current)
|| 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"
);
}
}
+166 -13
View File
@@ -23,6 +23,9 @@ type CompareExchangeHooks = HashMap<PathBuf, Vec<u8>>;
#[cfg(test)]
static BEFORE_COMPARE_EXCHANGE: LazyLock<Mutex<CompareExchangeHooks>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
#[cfg(test)]
static BEFORE_ROLLBACK_EXCHANGE: LazyLock<Mutex<CompareExchangeHooks>> =
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<CompareExchangeHooks>,
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<File> {
.open(path)
}
#[cfg(not(unix))]
#[cfg(windows)]
fn open_read_only(path: &Path) -> std::io::Result<File> {
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<File> {
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(&current)
|| !same_file(&opened, &completed)
|| !same_file(&completed, &current)
|| 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() {
+244 -124
View File
@@ -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<String>,
#[serde(skip)]
native_defaults_receipt: Option<PiNativeDefaultsReceipt>,
}
pub(crate) struct PiCatalogCoordinator;
@@ -95,11 +101,9 @@ pub(crate) struct PiCatalogCoordinator;
struct PiCatalogSnapshot {
aggregates: IndexMap<String, ProviderAggregate>,
projections: Vec<PiProviderProjection>,
defaults: PiNativeDefaults,
local_current: Option<String>,
db_current: Option<String>,
models_path: PathBuf,
native_values: IndexMap<String, Option<Value>>,
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<Value, AppError>,
) -> Result<(), AppError> {
) -> Result<Option<PiNativeDefaultsReceipt>, 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::<String, String>::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, &current_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, &current_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<PiCatalogMutationResult, AppError> {
@@ -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::<Vec<_>>();
let mut native_keys = manifest
.values()
.map(|projection| projection.provider_key.clone())
.collect::<Vec<_>>();
let mut native_keys = BTreeMap::<String, ()>::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<String>) -> PiCatalogMutationResult {
PiCatalogMutationResult {
authority: PiCatalogAuthority::Published,
provider_id,
native_defaults_receipt: None,
}
}
impl PiCatalogMutationResult {
fn with_native_defaults_receipt(mut self, receipt: Option<PiNativeDefaultsReceipt>) -> 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<std::ffi::OsString>);
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"),
+1 -1
View File
@@ -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",
)?;