From e2e60fbb5c78feb853aceccce65b3ef4da565580 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20D=2E?= <9351115+decode2@users.noreply.github.com> Date: Sun, 19 Jul 2026 04:38:23 +0000 Subject: [PATCH] feat(workspace): add local profile persistence --- Cargo.lock | 15 ++ crates/splice-core/Cargo.toml | 3 + crates/splice-core/src/lib.rs | 3 + crates/splice-core/src/workspace.rs | 281 ++++++++++++++++++++ crates/splice-core/tests/workspace_store.rs | 281 ++++++++++++++++++++ 5 files changed, 583 insertions(+) create mode 100644 crates/splice-core/src/workspace.rs create mode 100644 crates/splice-core/tests/workspace_store.rs diff --git a/Cargo.lock b/Cargo.lock index 3cdd198..a21dd0d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -998,6 +998,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "funty" version = "2.0.0" @@ -3464,6 +3474,11 @@ dependencies = [ [[package]] name = "splice-core" version = "0.1.0" +dependencies = [ + "fs2", + "serde", + "serde_json", +] [[package]] name = "splice-pty" diff --git a/crates/splice-core/Cargo.toml b/crates/splice-core/Cargo.toml index e3bde6d..a34f92b 100644 --- a/crates/splice-core/Cargo.toml +++ b/crates/splice-core/Cargo.toml @@ -7,3 +7,6 @@ repository.workspace = true edition.workspace = true [dependencies] +fs2 = "0.4" +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/crates/splice-core/src/lib.rs b/crates/splice-core/src/lib.rs index 7c28a0d..6ff639d 100644 --- a/crates/splice-core/src/lib.rs +++ b/crates/splice-core/src/lib.rs @@ -1,3 +1,6 @@ +mod workspace; +pub use workspace::*; + #[derive(Debug, Clone, PartialEq, Eq)] pub enum PastePayload { Text(String), diff --git a/crates/splice-core/src/workspace.rs b/crates/splice-core/src/workspace.rs new file mode 100644 index 0000000..fc2e446 --- /dev/null +++ b/crates/splice-core/src/workspace.rs @@ -0,0 +1,281 @@ +use fs2::FileExt; +use serde::{Deserialize, Serialize}; +use std::{ + collections::{BTreeMap, BTreeSet}, + fs::{self, File, OpenOptions}, + io::Write, + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, +}; +const SCHEMA_VERSION: u32 = 1; +const STORE_FILE: &str = "workspace-profiles.v1.json"; +const LOCK_FILE: &str = "workspace-profiles.v1.lock"; +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct WorkspaceId(String); +impl WorkspaceId { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + valid_label(&value) + .then_some(Self(value)) + .ok_or(WorkspaceError::InvalidWorkspaceId) + } +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AgentDescriptor { + pub id: String, + pub command: String, +} +impl AgentDescriptor { + pub fn new(id: impl Into, command: impl Into) -> Result { + let (id, command) = (id.into(), command.into()); + if valid_label(&id) && valid_command(&command) { + Ok(Self { id, command }) + } else { + Err(WorkspaceError::InvalidProfile) + } + } +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EnvironmentMetadata { + pub profile: String, + pub variable_names: Vec, +} +impl EnvironmentMetadata { + pub fn new( + profile: impl Into, + names: impl IntoIterator>, + ) -> Result { + let metadata = Self { + profile: profile.into(), + variable_names: names.into_iter().map(Into::into).collect(), + }; + (valid_label(&metadata.profile) + && metadata + .variable_names + .iter() + .all(|name| valid_env_name(name))) + .then_some(metadata) + .ok_or(WorkspaceError::InvalidProfile) + } +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceProfile { + pub id: WorkspaceId, + pub name: String, + pub working_directory: PathBuf, + pub environment: EnvironmentMetadata, + pub agent: AgentDescriptor, + pub session_ids: Vec, +} +impl WorkspaceProfile { + pub fn new( + id: WorkspaceId, + name: impl Into, + directory: PathBuf, + environment: EnvironmentMetadata, + agent: AgentDescriptor, + sessions: Vec, + ) -> Result { + let working_directory = + fs::canonicalize(directory).map_err(|_| WorkspaceError::InvalidProfile)?; + if !working_directory.is_dir() { + return Err(WorkspaceError::InvalidProfile); + } + let session_ids = sessions.into_iter().collect::>(); + if session_ids.contains(&0) { + return Err(WorkspaceError::InvalidProfile); + } + Ok(Self { + id, + name: name.into(), + working_directory, + environment, + agent, + session_ids: session_ids.into_iter().collect(), + }) + } +} +#[derive(Debug)] +pub enum WorkspaceError { + InvalidWorkspaceId, + InvalidProfile, + UnsupportedSchema(u32), + Quarantined(PathBuf), + Io, + Serialization, +} +impl From for WorkspaceError { + fn from(_: std::io::Error) -> Self { + Self::Io + } +} +#[derive(Serialize, Deserialize)] +struct Database { + schema_version: u32, + profiles: BTreeMap, +} +pub struct WorkspaceStore { + root: PathBuf, +} +impl WorkspaceStore { + pub fn new(root: impl AsRef) -> Result { + let root = root.as_ref(); + root.is_absolute() + .then_some(Self { + root: root.to_owned(), + }) + .ok_or(WorkspaceError::InvalidProfile) + } + + pub fn save(&self, profile: &WorkspaceProfile) -> Result<(), WorkspaceError> { + if !valid_profile(profile) || !profile.working_directory.is_dir() { + return Err(WorkspaceError::InvalidProfile); + } + fs::create_dir_all(&self.root)?; + let lock = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(self.root.join(LOCK_FILE))?; + lock.lock_exclusive()?; + let mut database = self.read()?.unwrap_or(Database { + schema_version: SCHEMA_VERSION, + profiles: BTreeMap::new(), + }); + database + .profiles + .insert(profile.id.clone(), profile.clone()); + if !valid_database(&database) { + return Err(WorkspaceError::InvalidProfile); + } + self.write(&database) + } + + pub fn load(&self, id: &WorkspaceId) -> Result, WorkspaceError> { + Ok(self + .read()? + .and_then(|mut database| database.profiles.remove(id))) + } + + fn path(&self) -> PathBuf { + self.root.join(STORE_FILE) + } + + fn backup_path(&self) -> PathBuf { + self.root.join(format!("{STORE_FILE}.bak")) + } + + fn read(&self) -> Result, WorkspaceError> { + let path = self.path(); + if !path.exists() { + let backup = self.backup_path(); + return backup + .exists() + .then_some(backup) + .map_or(Ok(None), |path| self.read_file(&path)); + } + self.read_file(&path) + } + + fn read_file(&self, path: &Path) -> Result, WorkspaceError> { + let bytes = fs::read(path)?; + let database: Database = match serde_json::from_slice(&bytes) { + Ok(database) => database, + Err(_) => return Err(WorkspaceError::Quarantined(self.quarantine(path)?)), + }; + if database.schema_version != SCHEMA_VERSION { + return Err(WorkspaceError::UnsupportedSchema(database.schema_version)); + } + if valid_database(&database) { + Ok(Some(database)) + } else { + Err(WorkspaceError::Quarantined(self.quarantine(path)?)) + } + } + + fn write(&self, database: &Database) -> Result<(), WorkspaceError> { + fs::create_dir_all(&self.root)?; + let bytes = serde_json::to_vec(database).map_err(|_| WorkspaceError::Serialization)?; + let temp = self.root.join(format!( + ".{STORE_FILE}.{}-{}.tmp", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| WorkspaceError::Serialization)? + .as_nanos() + )); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp)?; + file.write_all(&bytes)?; + file.sync_all()?; + let path = self.path(); + if path.exists() { + let backup = self.backup_path(); + if backup.exists() { + fs::remove_file(&backup)?; + } + fs::rename(&path, backup)?; + } + fs::rename(&temp, path)?; + let _ = File::open(&self.root).and_then(|directory| directory.sync_all()); + Ok(()) + } + + fn quarantine(&self, path: &Path) -> Result { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| WorkspaceError::Serialization)? + .as_nanos(); + let quarantine = self + .root + .join(format!("workspace-profiles.corrupt-{suffix}.json")); + fs::rename(path, &quarantine)?; + Ok(quarantine) + } +} +fn valid_label(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) +} +fn valid_command(value: &str) -> bool { + !value.is_empty() && !value.chars().any(char::is_whitespace) +} +fn valid_env_name(value: &str) -> bool { + value.as_bytes().first().is_some_and(u8::is_ascii_uppercase) + && value + .bytes() + .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_') +} +fn valid_profile(profile: &WorkspaceProfile) -> bool { + valid_label(&profile.id.0) + && !profile.name.trim().is_empty() + && profile.working_directory.is_absolute() + && valid_label(&profile.environment.profile) + && profile + .environment + .variable_names + .iter() + .all(|name| valid_env_name(name)) + && valid_label(&profile.agent.id) + && valid_command(&profile.agent.command) + && profile.session_ids.iter().all(|id| *id != 0) + && profile.session_ids.iter().collect::>().len() == profile.session_ids.len() +} +fn valid_database(database: &Database) -> bool { + let mut session_ids = BTreeSet::new(); + database.profiles.iter().all(|(id, profile)| { + id == &profile.id + && valid_profile(profile) + && profile + .session_ids + .iter() + .all(|session| session_ids.insert(session)) + }) +} diff --git a/crates/splice-core/tests/workspace_store.rs b/crates/splice-core/tests/workspace_store.rs new file mode 100644 index 0000000..6660d9b --- /dev/null +++ b/crates/splice-core/tests/workspace_store.rs @@ -0,0 +1,281 @@ +use fs2::FileExt; +use splice_core::{ + AgentDescriptor, EnvironmentMetadata, WorkspaceId, WorkspaceProfile, WorkspaceStore, +}; +use std::{ + fs::OpenOptions, + path::PathBuf, + sync::{mpsc, Arc, Barrier}, + thread, + time::Duration, +}; +fn temp_root(name: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!("splice-core-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).expect("temp root should exist"); + root +} +fn profile(id: &str, directory: PathBuf, session_ids: Vec) -> WorkspaceProfile { + WorkspaceProfile::new( + WorkspaceId::new(id).expect("valid workspace id"), + id, + directory, + EnvironmentMetadata::new("development", ["PATH"]).expect("safe metadata"), + AgentDescriptor::new("codex", "codex").expect("safe agent"), + session_ids, + ) + .expect("valid workspace profile") +} +fn duplicate_session_profiles(root: &PathBuf, session_id: u64) -> serde_json::Value { + serde_json::json!({ + "schema_version": 1, + "profiles": { + "alpha": { + "id": "alpha", + "name": "alpha", + "working_directory": root, + "environment": { "profile": "development", "variable_names": ["PATH"] }, + "agent": { "id": "codex", "command": "codex" }, + "session_ids": [session_id] + }, + "beta": { + "id": "beta", + "name": "beta", + "working_directory": root, + "environment": { "profile": "development", "variable_names": ["PATH"] }, + "agent": { "id": "codex", "command": "codex" }, + "session_ids": [session_id] + } + } + }) +} +#[test] +fn isolates_workspaces_and_rejects_shared_sessions() { + let root = temp_root("isolation"); + let workspace_a = profile("alpha", root.clone(), vec![11]); + let workspace_b = profile("beta", root.clone(), vec![22]); + let store = WorkspaceStore::new(&root).expect("absolute store root"); + store.save(&workspace_a).expect("first workspace saves"); + assert!(store + .save(&profile("beta", root.clone(), vec![11])) + .is_err()); + store.save(&workspace_b).expect("second workspace saves"); + assert_eq!( + store.load(&workspace_a.id).expect("workspace loads"), + Some(workspace_a) + ); + assert_eq!( + store.load(&workspace_b.id).expect("workspace loads"), + Some(workspace_b) + ); +} +#[test] +fn loads_all_metadata_when_a_workspace_directory_is_unavailable() { + let root = temp_root("offline-directory"); + let directory_a = root.join("alpha-directory"); + let directory_b = root.join("beta-directory"); + std::fs::create_dir_all(&directory_a).expect("alpha directory exists"); + std::fs::create_dir_all(&directory_b).expect("beta directory exists"); + let workspace_a = profile("alpha", directory_a.clone(), vec![41]); + let workspace_b = profile("beta", directory_b, vec![43]); + let store = WorkspaceStore::new(&root).expect("absolute store root"); + store.save(&workspace_a).expect("alpha saves"); + store.save(&workspace_b).expect("beta saves"); + + std::fs::remove_dir_all(directory_a).expect("alpha becomes unavailable"); + + assert_eq!( + store.load(&workspace_b.id).expect("beta metadata loads"), + Some(workspace_b) + ); + assert_eq!( + store.load(&workspace_a.id).expect("alpha metadata loads"), + Some(workspace_a) + ); + assert!(root.join("workspace-profiles.v1.json").exists()); + assert!(!std::fs::read_dir(&root) + .expect("store root reads") + .any(|entry| entry + .expect("valid entry") + .file_name() + .to_string_lossy() + .contains("corrupt"))); +} +#[test] +fn serializes_competing_stores_and_preserves_unique_sessions() { + let root = temp_root("concurrent-save"); + let lock = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(root.join("workspace-profiles.v1.lock")) + .expect("test lock opens"); + lock.lock_exclusive().expect("test lock acquired"); + let candidates = [("alpha", 51), ("beta", 53), ("gamma", 59), ("delta", 59)]; + let barrier = Arc::new(Barrier::new(candidates.len())); + let (sender, receiver) = mpsc::channel(); + let handles = candidates + .into_iter() + .map(|(id, session)| { + let root = root.clone(); + let barrier = Arc::clone(&barrier); + let sender = sender.clone(); + thread::spawn(move || { + let workspace = profile(id, root.clone(), vec![session]); + barrier.wait(); + let saved = WorkspaceStore::new(&root) + .expect("store opens") + .save(&workspace) + .is_ok(); + sender.send((workspace, saved)).expect("result sends"); + }) + }) + .collect::>(); + drop(sender); + assert!(receiver.recv_timeout(Duration::from_millis(100)).is_err()); + FileExt::unlock(&lock).expect("test lock released"); + let results = receiver.into_iter().collect::>(); + for handle in handles { + handle.join().expect("save thread completes"); + } + + assert_eq!(results.iter().filter(|(_, saved)| *saved).count(), 3); + let store = WorkspaceStore::new(&root).expect("store opens"); + for (workspace, saved) in results { + assert_eq!( + store.load(&workspace.id).expect("metadata loads").is_some(), + saved + ); + } +} +#[test] +fn rejects_relative_directories_secret_values_and_zero_session_ids() { + let root = temp_root("validation"); + let id = WorkspaceId::new("safe").expect("valid id"); + let environment = EnvironmentMetadata::new("development", ["PATH"]).expect("safe metadata"); + let agent = AgentDescriptor::new("codex", "codex").expect("safe agent"); + assert!(WorkspaceProfile::new( + id.clone(), + "safe", + PathBuf::from("relative"), + environment.clone(), + agent.clone(), + vec![1] + ) + .is_err()); + assert!(EnvironmentMetadata::new("development", ["API_TOKEN=top-secret"]).is_err()); + assert!(WorkspaceProfile::new(id, "safe", root, environment, agent, vec![0]).is_err()); +} +#[test] +fn replaces_an_existing_store_and_recovers_its_backup() { + let root = temp_root("atomic"); + let store = WorkspaceStore::new(&root).expect("absolute store root"); + let first = profile("alpha", root.clone(), vec![3]); + let second = profile("beta", root.clone(), vec![7]); + store.save(&first).expect("first workspace saves"); + store.save(&second).expect("replacement workspace saves"); + assert_eq!( + store.load(&second.id).expect("replacement loads"), + Some(second) + ); + let path = root.join("workspace-profiles.v1.json"); + let backup = root.join("workspace-profiles.v1.json.bak"); + assert!(std::fs::read_to_string(&backup) + .expect("backup exists") + .contains("alpha")); + std::fs::remove_file(path).expect("simulate interruption after backup"); + assert_eq!(store.load(&first.id).expect("backup recovers"), Some(first)); +} +#[test] +fn quarantines_partial_data_then_recovers_with_a_new_workspace() { + let root = temp_root("corrupt"); + let path = root.join("workspace-profiles.v1.json"); + std::fs::write(&path, r#"{"schema_version":1,"profiles":"partial""#).expect("partial store"); + let store = WorkspaceStore::new(&root).expect("absolute store root"); + let error = store + .load(&WorkspaceId::new("alpha").expect("valid id")) + .expect_err("partial data must not be loaded"); + assert!(matches!(error, splice_core::WorkspaceError::Quarantined(ref path) if path.exists())); + let workspace = profile("alpha", root.clone(), vec![5]); + store + .save(&workspace) + .expect("new store recovers after quarantine"); + assert_eq!( + store.load(&workspace.id).expect("recovered load"), + Some(workspace) + ); +} +#[test] +fn quarantines_persisted_duplicate_sessions_then_recovers_cleanly() { + let root = temp_root("duplicate-session"); + let path = root.join("workspace-profiles.v1.json"); + let duplicate_profiles = duplicate_session_profiles(&root, 17); + std::fs::write( + &path, + serde_json::to_vec(&duplicate_profiles).expect("valid forged store"), + ) + .expect("forged store writes"); + let store = WorkspaceStore::new(&root).expect("absolute store root"); + let error = store + .load(&WorkspaceId::new("alpha").expect("valid id")) + .expect_err("duplicate sessions must not load"); + assert!(matches!(error, splice_core::WorkspaceError::Quarantined(ref path) if path.exists())); + assert!(!path.exists(), "duplicate store is moved out of service"); + + let recovered = profile("gamma", root.clone(), vec![23]); + store.save(&recovered).expect("clean store recovers"); + assert_eq!( + store + .load(&recovered.id) + .expect("recovered workspace loads"), + Some(recovered) + ); +} +#[test] +fn quarantines_duplicate_primary_then_recovers_the_valid_backup() { + let root = temp_root("duplicate-session-backup"); + let store = WorkspaceStore::new(&root).expect("absolute store root"); + let backup_profile = profile("alpha", root.clone(), vec![29]); + store.save(&backup_profile).expect("backup source saves"); + store + .save(&profile("beta", root.clone(), vec![31])) + .expect("replacement creates backup"); + + let path = root.join("workspace-profiles.v1.json"); + let duplicate_profiles = duplicate_session_profiles(&root, 37); + std::fs::write( + &path, + serde_json::to_vec(&duplicate_profiles).expect("valid forged store"), + ) + .expect("forged primary writes"); + + let error = store + .load(&backup_profile.id) + .expect_err("duplicate primary must not load"); + assert!(matches!(error, splice_core::WorkspaceError::Quarantined(ref path) if path.exists())); + assert_eq!( + store + .load(&backup_profile.id) + .expect("validated backup recovers"), + Some(backup_profile) + ); +} +#[test] +fn rejects_unknown_schema_without_replacing_the_existing_store() { + let root = temp_root("migration"); + let path = root.join("workspace-profiles.v1.json"); + std::fs::write(&path, r#"{"schema_version":2,"profiles":{}}"#).expect("future schema store"); + let store = WorkspaceStore::new(&root).expect("absolute store root"); + let error = store + .load(&WorkspaceId::new("alpha").expect("valid id")) + .expect_err("future schema must be rejected"); + assert!(matches!( + error, + splice_core::WorkspaceError::UnsupportedSchema(2) + )); + assert!( + path.exists(), + "a future schema must remain available to a compatible build" + ); +}