use crate::imp::core::{Bytes32, SecretSalt, StoreConfig, Visibility};
use crate::imp::store::error::{Result, StoreError};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Serialize, Deserialize)]
struct ConfigToml {
store_id: String,
data_dir: String,
max_size: u64,
visibility: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
secret_salt: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
description: Option<String>,
}
impl ConfigToml {
fn from_config(cfg: &StoreConfig) -> Self {
let (visibility, secret_salt) = match &cfg.visibility {
Visibility::Public => ("public".to_string(), None),
Visibility::Private(salt) => ("private".to_string(), Some(hex::encode(salt.0))),
};
Self {
store_id: cfg.store_id.to_hex(),
data_dir: cfg.data_dir.clone(),
max_size: cfg.max_size,
visibility,
secret_salt,
label: cfg.label.clone(),
description: cfg.description.clone(),
}
}
fn into_config(self) -> Result<StoreConfig> {
let store_id = Bytes32::from_hex(&self.store_id).map_err(|_| {
StoreError::InvalidConfig(format!("bad store_id hex: {}", self.store_id))
})?;
let visibility = match self.visibility.as_str() {
"public" => Visibility::Public,
"private" => {
let salt_hex = self.secret_salt.ok_or_else(|| {
StoreError::InvalidConfig("private store missing secret_salt".into())
})?;
let bytes = hex::decode(&salt_hex)
.map_err(|_| StoreError::InvalidConfig("bad secret_salt hex".into()))?;
let arr: [u8; 32] = bytes.try_into().map_err(|_| {
StoreError::InvalidConfig("secret_salt must be 32 bytes".into())
})?;
Visibility::Private(SecretSalt(arr))
}
other => {
return Err(StoreError::InvalidConfig(format!(
"unknown visibility: {other}"
)))
}
};
Ok(StoreConfig {
store_id,
data_dir: self.data_dir,
max_size: self.max_size,
visibility,
label: self.label,
description: self.description,
})
}
}
pub fn save_config(path: impl AsRef<Path>, cfg: &StoreConfig) -> Result<()> {
let toml_repr = ConfigToml::from_config(cfg);
let text = toml::to_string_pretty(&toml_repr).map_err(|e| StoreError::Config(e.to_string()))?;
write_owner_only(path.as_ref(), text.as_bytes())?;
Ok(())
}
fn write_owner_only(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)?;
f.write_all(bytes)?;
f.flush()
}
#[cfg(not(unix))]
{
std::fs::write(path, bytes)
}
}
pub fn load_config(path: impl AsRef<Path>) -> Result<StoreConfig> {
let text = std::fs::read_to_string(path)?;
let toml_repr: ConfigToml =
toml::from_str(&text).map_err(|e| StoreError::Config(e.to_string()))?;
toml_repr.into_config()
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use super::*;
use crate::imp::core::{Bytes32, SecretSalt, StoreConfig, Visibility};
use tempfile::tempdir;
fn public_cfg() -> StoreConfig {
StoreConfig {
store_id: Bytes32([0x22u8; 32]),
data_dir: "/data".to_string(),
max_size: 1_000_000,
visibility: Visibility::Public,
label: None,
description: None,
}
}
#[test]
fn public_config_roundtrips_through_toml() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
let cfg = public_cfg();
save_config(&path, &cfg).unwrap();
let loaded = load_config(&path).unwrap();
assert_eq!(loaded.store_id, cfg.store_id);
assert_eq!(loaded.data_dir, "/data");
assert_eq!(loaded.max_size, 1_000_000);
assert!(matches!(loaded.visibility, Visibility::Public));
}
#[test]
fn private_config_preserves_salt() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
let mut cfg = public_cfg();
cfg.visibility = Visibility::Private(SecretSalt([0x07u8; 32]));
save_config(&path, &cfg).unwrap();
let loaded = load_config(&path).unwrap();
match loaded.visibility {
Visibility::Private(salt) => assert_eq!(salt.0, [0x07u8; 32]),
Visibility::Public => panic!("expected private visibility"),
}
}
#[test]
fn config_toml_is_human_readable() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
save_config(&path, &public_cfg()).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains("store_id = \""));
assert!(text.contains("visibility = \"public\""));
assert!(text.contains(&"22".repeat(32)));
}
#[test]
fn label_and_description_roundtrip() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
let mut cfg = public_cfg();
cfg.label = Some("My Project".to_string());
cfg.description = Some("A test store".to_string());
save_config(&path, &cfg).unwrap();
let loaded = load_config(&path).unwrap();
assert_eq!(loaded.label.as_deref(), Some("My Project"));
assert_eq!(loaded.description.as_deref(), Some("A test store"));
}
#[test]
fn legacy_config_without_label_loads_as_none() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
let sid = "22".repeat(32); std::fs::write(
&path,
format!("store_id = \"{sid}\"\ndata_dir = \"/data\"\nmax_size = 1\nvisibility = \"public\"\n"),
)
.unwrap();
let loaded = load_config(&path).unwrap();
assert_eq!(loaded.label, None);
assert_eq!(loaded.description, None);
}
#[test]
fn private_without_salt_is_invalid_config() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
"store_id = \"22\"\ndata_dir = \"/data\"\nmax_size = 1\nvisibility = \"private\"\n",
)
.unwrap();
let err = load_config(&path).unwrap_err();
assert!(matches!(err, StoreError::InvalidConfig(_)));
}
}