use std::path::{Path, PathBuf};
use personae::ProfileId;
use serde::{Deserialize, Serialize};
pub fn default_app_dir() -> PathBuf {
#[cfg(windows)]
let base = std::env::var_os("LOCALAPPDATA")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
#[cfg(not(windows))]
let base = std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share")))
.unwrap_or_else(|| PathBuf::from("."));
#[cfg(windows)]
let name = "Graphshell";
#[cfg(not(windows))]
let name = "graphshell";
base.join(name)
}
pub fn default_data_root(app_dir: &Path) -> PathBuf {
app_dir.join("data")
}
pub fn legacy_data_root(vault_dir: &Path) -> PathBuf {
vault_dir.join("graphshell-data")
}
pub fn settings_path(app_dir: &Path, profile: &ProfileId) -> PathBuf {
app_dir
.join("settings")
.join(format!("{}.json", sanitize_profile(&profile.0)))
}
fn sanitize_profile(profile: &str) -> String {
let cleaned: String = profile
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect();
if cleaned.is_empty() {
"default".to_string()
} else {
cleaned
}
}
#[derive(Debug, thiserror::Error)]
pub enum OwnerSettingsError {
#[error("owner settings at {path}: {message}")]
File { path: String, message: String },
#[error("owner settings: {value:?} is not a 64-character hex key")]
NotHex { value: String },
#[error("both {legacy} and {current} exist; refusing to guess which personal graph is current")]
AmbiguousDataRoot { legacy: String, current: String },
}
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(default, deny_unknown_fields)]
pub struct OwnerSettings {
pub sync: Option<SyncSettings>,
pub knot: Option<KnotResidentSettings>,
#[serde(skip_serializing_if = "ResidentContentSettings::is_default")]
pub content: ResidentContentSettings,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(default, deny_unknown_fields)]
pub struct ResidentContentSettings {
pub root: Option<PathBuf>,
pub gc_interval_seconds: u64,
}
impl ResidentContentSettings {
pub fn is_default(&self) -> bool {
self == &Self::default()
}
}
impl Default for ResidentContentSettings {
fn default() -> Self {
Self {
root: None,
gc_interval_seconds: 300,
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(default, deny_unknown_fields)]
pub struct KnotResidentSettings {
pub persona: String,
pub device_label: String,
pub evidence_root: Option<PathBuf>,
pub max_artifact_bytes: u64,
pub max_source_bytes: u64,
}
impl Default for KnotResidentSettings {
fn default() -> Self {
Self {
persona: String::new(),
device_label: "graphshell-device".into(),
evidence_root: None,
max_artifact_bytes: 64 * 1024 * 1024,
max_source_bytes: 8 * 1024 * 1024,
}
}
}
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(default, deny_unknown_fields)]
pub struct SyncSettings {
pub graph: String,
pub store_path: Option<PathBuf>,
pub roster_roots: Vec<String>,
pub paired_devices: Vec<PairedDevice>,
pub lanes: LaneSettings,
#[serde(default)]
pub encrypted: bool,
pub relay_urls: Vec<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct PairedDevice {
pub node_id: String,
#[serde(default)]
pub root: Option<String>,
#[serde(default)]
pub label: String,
#[serde(default)]
pub added_ms: u64,
#[serde(default)]
pub pairing_id: Option<String>,
#[serde(default)]
pub last_endpoint: Option<String>,
#[serde(default)]
pub prekey: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(default, deny_unknown_fields)]
pub struct LaneSettings {
pub facets: Vec<String>,
pub access_records: bool,
pub saved_scenes: bool,
pub handler_preferences: bool,
pub blob_availability: bool,
}
impl OwnerSettings {
pub fn load(path: &Path) -> Result<Self, OwnerSettingsError> {
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(Self::default());
}
Err(error) => {
return Err(OwnerSettingsError::File {
path: path.display().to_string(),
message: error.to_string(),
});
}
};
serde_json::from_str(&text).map_err(|error| OwnerSettingsError::File {
path: path.display().to_string(),
message: error.to_string(),
})
}
pub fn save(&self, path: &Path) -> Result<(), OwnerSettingsError> {
let fail = |message: String| OwnerSettingsError::File {
path: path.display().to_string(),
message,
};
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|error| fail(error.to_string()))?;
}
let mut text =
serde_json::to_string_pretty(self).map_err(|error| fail(error.to_string()))?;
text.push('\n');
let temporary = path.with_extension("json.tmp");
std::fs::write(&temporary, text.as_bytes()).map_err(|error| fail(error.to_string()))?;
if path.exists() {
std::fs::remove_file(path).map_err(|error| fail(error.to_string()))?;
}
std::fs::rename(&temporary, path).map_err(|error| fail(error.to_string()))
}
}
impl SyncSettings {
pub fn roster_root_keys(&self) -> Result<Vec<[u8; 32]>, OwnerSettingsError> {
self.roster_roots
.iter()
.map(|root| parse_hex32(root))
.chain(
self.paired_devices
.iter()
.filter_map(|device| device.root.as_deref())
.map(parse_hex32),
)
.collect()
}
pub fn receive_only_devices(&self) -> Vec<&PairedDevice> {
self.paired_devices
.iter()
.filter(|device| device.root.is_none())
.collect()
}
pub fn paired_node_keys(&self) -> Result<Vec<[u8; 32]>, OwnerSettingsError> {
self.paired_devices
.iter()
.map(|device| parse_hex32(&device.node_id))
.collect()
}
pub fn pair(
&mut self,
node_id: [u8; 32],
root: Option<[u8; 32]>,
label: &str,
at_ms: u64,
) -> bool {
self.pair_with_prekey(node_id, root, label, at_ms, None)
}
pub fn pair_with_prekey(
&mut self,
node_id: [u8; 32],
root: Option<[u8; 32]>,
label: &str,
at_ms: u64,
prekey: Option<String>,
) -> bool {
let node_id = hex32(&node_id);
if self
.paired_devices
.iter()
.any(|device| device.node_id.eq_ignore_ascii_case(&node_id))
{
return false;
}
self.paired_devices.push(PairedDevice {
node_id,
root: root.map(|root| hex32(&root)),
label: label.to_string(),
added_ms: at_ms,
pairing_id: Some(uuid::Uuid::new_v4().to_string()),
last_endpoint: None,
prekey: prekey.filter(|prekey| !prekey.trim().is_empty()),
});
true
}
pub fn record_endpoint(&mut self, node_id: &[u8; 32], ticket: &str) -> bool {
let node_id = hex32(node_id);
for device in &mut self.paired_devices {
if device.node_id.eq_ignore_ascii_case(&node_id) {
if device.last_endpoint.as_deref() == Some(ticket) {
return false;
}
device.last_endpoint = Some(ticket.to_string());
return true;
}
}
false
}
pub fn unpair(&mut self, node_id: [u8; 32]) -> bool {
let node_id = hex32(&node_id);
let before = self.paired_devices.len();
self.paired_devices
.retain(|device| !device.node_id.eq_ignore_ascii_case(&node_id));
self.paired_devices.len() != before
}
}
#[derive(Clone, Debug, Default)]
pub struct SyncOverrides {
pub graph: Option<String>,
pub store_path: Option<PathBuf>,
pub roster_roots: Vec<String>,
pub paired_nodes: Vec<String>,
pub relay_urls: Vec<String>,
pub facets: Vec<String>,
pub access_records: bool,
pub saved_scenes: bool,
pub handler_preferences: bool,
pub blob_availability: bool,
}
impl SyncOverrides {
pub fn is_empty(&self) -> bool {
self.graph.is_none()
&& self.store_path.is_none()
&& self.roster_roots.is_empty()
&& self.paired_nodes.is_empty()
&& self.relay_urls.is_empty()
&& self.facets.is_empty()
&& !self.access_records
&& !self.saved_scenes
&& !self.handler_preferences
&& !self.blob_availability
}
}
impl SyncSettings {
pub fn with_overrides(mut self, overrides: SyncOverrides) -> Self {
if let Some(graph) = overrides.graph {
self.graph = graph;
}
if overrides.store_path.is_some() {
self.store_path = overrides.store_path;
}
if !overrides.roster_roots.is_empty() {
self.roster_roots = overrides.roster_roots;
}
if !overrides.relay_urls.is_empty() {
self.relay_urls = overrides.relay_urls;
}
if !overrides.paired_nodes.is_empty() {
self.paired_devices = overrides
.paired_nodes
.into_iter()
.map(|node_id| PairedDevice {
node_id,
root: None,
label: String::new(),
added_ms: 0,
pairing_id: None,
last_endpoint: None,
prekey: None,
})
.collect();
}
if !overrides.facets.is_empty() {
self.lanes.facets = overrides.facets;
}
self.lanes.access_records |= overrides.access_records;
self.lanes.saved_scenes |= overrides.saved_scenes;
self.lanes.handler_preferences |= overrides.handler_preferences;
self.lanes.blob_availability |= overrides.blob_availability;
self
}
}
pub fn resolve_sync(
stored: Option<SyncSettings>,
overrides: SyncOverrides,
) -> Option<SyncSettings> {
match (stored, overrides.is_empty()) {
(None, true) => None,
(stored, _) => {
let resolved = stored.unwrap_or_default().with_overrides(overrides);
if resolved.graph.trim().is_empty() {
None
} else {
Some(resolved)
}
}
}
}
pub fn migrate_data_root(
legacy: &Path,
current: &Path,
) -> Result<DataRootMigration, OwnerSettingsError> {
if !legacy.exists() {
return Ok(DataRootMigration::NothingToDo);
}
if current.exists() {
return Err(OwnerSettingsError::AmbiguousDataRoot {
legacy: legacy.display().to_string(),
current: current.display().to_string(),
});
}
if let Some(parent) = current.parent() {
std::fs::create_dir_all(parent).map_err(|error| OwnerSettingsError::File {
path: parent.display().to_string(),
message: error.to_string(),
})?;
}
std::fs::rename(legacy, current).map_err(|error| OwnerSettingsError::File {
path: legacy.display().to_string(),
message: format!("move to {}: {error}", current.display()),
})?;
Ok(DataRootMigration::Moved {
from: legacy.to_path_buf(),
to: current.to_path_buf(),
})
}
#[derive(Debug, PartialEq, Eq)]
pub enum DataRootMigration {
NothingToDo,
Moved { from: PathBuf, to: PathBuf },
}
pub fn parse_hex32(value: &str) -> Result<[u8; 32], OwnerSettingsError> {
let value = value.trim();
if value.len() != 64 || !value.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(OwnerSettingsError::NotHex {
value: value.to_string(),
});
}
let mut out = [0u8; 32];
for (index, slot) in out.iter_mut().enumerate() {
let byte = &value[index * 2..index * 2 + 2];
*slot = u8::from_str_radix(byte, 16).map_err(|_| OwnerSettingsError::NotHex {
value: value.to_string(),
})?;
}
Ok(out)
}
pub fn parse_hex(value: &str) -> Result<Vec<u8>, OwnerSettingsError> {
let value = value.trim();
if value.is_empty()
|| !value.len().is_multiple_of(2)
|| !value.chars().all(|c| c.is_ascii_hexdigit())
{
return Err(OwnerSettingsError::NotHex {
value: value.to_string(),
});
}
(0..value.len() / 2)
.map(|index| {
u8::from_str_radix(&value[index * 2..index * 2 + 2], 16).map_err(|_| {
OwnerSettingsError::NotHex {
value: value.to_string(),
}
})
})
.collect()
}
pub fn hex32(bytes: &[u8; 32]) -> String {
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_missing_file_is_empty_settings_but_a_malformed_one_is_an_error() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("settings").join("default.json");
assert_eq!(
OwnerSettings::load(&path).unwrap(),
OwnerSettings::default()
);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, b"{ not json").unwrap();
assert!(
OwnerSettings::load(&path).is_err(),
"a malformed file must not silently read as defaults: that would \
turn lanes off and drop paired devices without saying so"
);
}
#[test]
fn a_typo_in_a_lane_name_is_refused_rather_than_ignored() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("default.json");
std::fs::write(
&path,
br#"{"sync":{"graph":"personal","lanes":{"access_record":true}}}"#,
)
.unwrap();
assert!(
OwnerSettings::load(&path).is_err(),
"a misspelled lane key must fail loudly; silently ignoring it \
would leave the owner believing a lane was configured"
);
}
#[test]
fn settings_round_trip_and_the_write_replaces_the_previous_file() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("settings").join("work.json");
let mut settings = OwnerSettings {
sync: Some(SyncSettings {
graph: "personal".into(),
lanes: LaneSettings {
blob_availability: true,
..LaneSettings::default()
},
..SyncSettings::default()
}),
knot: None,
content: ResidentContentSettings::default(),
};
settings.save(&path).unwrap();
assert_eq!(OwnerSettings::load(&path).unwrap(), settings);
settings.sync.as_mut().unwrap().graph = "second".into();
settings.save(&path).unwrap();
assert_eq!(OwnerSettings::load(&path).unwrap(), settings);
assert!(
!path.with_extension("json.tmp").exists(),
"the temporary file must not survive a successful write"
);
}
#[test]
fn pairing_is_idempotent_and_keyed_on_the_node_id() {
let mut sync = SyncSettings::default();
assert!(sync.pair([0x11; 32], Some([0xb1; 32]), "qpc", 100));
assert!(
!sync.pair([0x11; 32], Some([0xb1; 32]), "qpc renamed", 200),
"re-pairing the same device must not add a second entry"
);
assert!(sync.pair([0x22; 32], None, "laptop", 300));
assert_eq!(sync.paired_devices.len(), 2);
assert_eq!(
sync.paired_node_keys().unwrap(),
vec![[0x11; 32], [0x22; 32]]
);
}
#[test]
fn pairing_mints_an_identity_and_repairing_mints_a_fresh_one() {
let mut sync = SyncSettings::default();
sync.pair([0x41; 32], None, "first", 100);
let first = sync.paired_devices[0]
.pairing_id
.clone()
.expect("a recorded pairing mints an id");
assert!(sync.unpair([0x41; 32]));
sync.pair([0x41; 32], None, "again", 200);
let second = sync.paired_devices[0]
.pairing_id
.clone()
.expect("re-pairing mints again");
assert_ne!(first, second, "re-pair must not revive the old pairing id");
}
#[test]
fn a_record_written_before_the_new_fields_still_loads() {
let json = r#"{"sync":{"graph":"personal","paired_devices":[
{"node_id":"aa","root":null,"label":"old","added_ms":5}]}}"#;
let parsed: OwnerSettings = serde_json::from_str(json).unwrap();
let device = &parsed.sync.as_ref().unwrap().paired_devices[0];
assert_eq!(device.pairing_id, None);
assert_eq!(device.last_endpoint, None);
}
#[test]
fn a_dial_hint_is_recorded_once_per_value_and_only_for_paired_devices() {
let mut sync = SyncSettings::default();
sync.pair([0x51; 32], None, "sibling", 100);
assert!(sync.record_endpoint(&[0x51; 32], "endpoint-ticket-one"));
assert!(
!sync.record_endpoint(&[0x51; 32], "endpoint-ticket-one"),
"an unchanged hint must report no change, so the caller does not \
rewrite the file every poll"
);
assert!(sync.record_endpoint(&[0x51; 32], "endpoint-ticket-two"));
assert_eq!(
sync.paired_devices[0].last_endpoint.as_deref(),
Some("endpoint-ticket-two")
);
assert!(
!sync.record_endpoint(&[0x52; 32], "endpoint-ticket-three"),
"a hint for an unpaired node has nowhere to live"
);
}
#[test]
fn a_profile_id_cannot_escape_the_settings_directory() {
let app = Path::new("/app");
let escaped = settings_path(app, &ProfileId("../../evil".into()));
assert_eq!(
escaped,
app.join("settings").join("______evil.json"),
"path separators and dots must not survive into the filename"
);
assert_eq!(
settings_path(app, &ProfileId(String::new())),
app.join("settings").join("default.json")
);
}
#[test]
fn the_data_root_moves_once_and_refuses_to_guess_when_both_exist() {
let directory = tempfile::tempdir().unwrap();
let legacy = directory.path().join("vault").join("graphshell-data");
let current = directory.path().join("Graphshell").join("data");
assert_eq!(
migrate_data_root(&legacy, ¤t).unwrap(),
DataRootMigration::NothingToDo
);
std::fs::create_dir_all(legacy.join("personal-sync")).unwrap();
std::fs::write(legacy.join("personal-sync").join("graph.redb"), b"store").unwrap();
let moved = migrate_data_root(&legacy, ¤t).unwrap();
assert!(matches!(moved, DataRootMigration::Moved { .. }));
assert!(current.join("personal-sync").join("graph.redb").exists());
assert!(!legacy.exists());
std::fs::create_dir_all(&legacy).unwrap();
assert!(
migrate_data_root(&legacy, ¤t).is_err(),
"two candidate stores must stop the host rather than have it pick \
one and appear to lose the other"
);
}
#[test]
fn an_argument_wins_over_the_file_but_a_lane_flag_only_turns_a_lane_on() {
let stored = SyncSettings {
graph: "personal".into(),
lanes: LaneSettings {
access_records: true,
blob_availability: false,
..LaneSettings::default()
},
..SyncSettings::default()
};
let resolved = resolve_sync(
Some(stored),
SyncOverrides {
graph: Some("scratch".into()),
blob_availability: true,
..SyncOverrides::default()
},
)
.unwrap();
assert_eq!(resolved.graph, "scratch", "an argument overrides the file");
assert!(resolved.lanes.blob_availability, "a flag turns a lane on");
assert!(
resolved.lanes.access_records,
"a lane enabled in the file stays on: the flags have no negative \
form, so absence of a flag must not silently disable a lane"
);
}
#[test]
fn sync_stays_off_without_a_graph_from_either_source() {
assert!(resolve_sync(None, SyncOverrides::default()).is_none());
assert!(
resolve_sync(
None,
SyncOverrides {
blob_availability: true,
..SyncOverrides::default()
}
)
.is_none(),
"a lane flag alone must not start sync: there is no graph to join"
);
assert!(
resolve_sync(
None,
SyncOverrides {
graph: Some("personal".into()),
..SyncOverrides::default()
}
)
.is_some(),
"a graph named on the command line enables sync with no file"
);
}
#[test]
fn hex_round_trips_and_rejects_the_wrong_length() {
assert_eq!(parse_hex32(&hex32(&[0xab; 32])).unwrap(), [0xab; 32]);
assert!(parse_hex32("abcd").is_err());
assert!(parse_hex32(&"z".repeat(64)).is_err());
}
}