use std::fs::{self, File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use crate::error::AppError;
const PREVIEW_TTL_SECS: i64 = 900;
const DIGEST_HEX_LEN: usize = 64;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SavedPreview {
pub version: u32,
pub account_hash: String,
pub order: Value,
pub command: String,
pub saved_at: i64,
}
fn preview_dir() -> Result<PathBuf, AppError> {
let base = preview_state_base()?;
let dir = base.join("schwab-agent").join("previews");
fs::create_dir_all(&dir)
.map_err(|e| AppError::Preview(format!("failed to create preview directory: {e}")))?;
set_private_dir_permissions(&dir)?;
Ok(dir.to_path_buf())
}
fn preview_state_base() -> Result<PathBuf, AppError> {
std::env::var_os("XDG_STATE_HOME")
.filter(|value| !value.is_empty())
.map(PathBuf::from)
.or_else(dirs::state_dir)
.or_else(dirs::data_local_dir)
.ok_or(AppError::Preview(
"cannot determine state directory".to_string(),
))
}
fn set_private_dir_permissions(path: &Path) -> Result<(), AppError> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o700)).map_err(|e| {
AppError::Preview(format!("failed to set preview directory permissions: {e}"))
})?;
}
Ok(())
}
fn private_preview_file(path: &Path) -> Result<File, AppError> {
let mut options = OpenOptions::new();
options.create(true).write(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let file = options
.open(path)
.map_err(|e| AppError::Preview(format!("failed to write preview file: {e}")))?;
set_private_file_permissions(path)?;
Ok(file)
}
fn set_private_file_permissions(path: &Path) -> Result<(), AppError> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|e| {
AppError::Preview(format!("failed to set preview file permissions: {e}"))
})?;
}
Ok(())
}
fn compute_digest(payload: &SavedPreview) -> Result<String, AppError> {
let json = serde_json::to_string(payload)
.map_err(|e| AppError::Preview(format!("failed to serialize preview payload: {e}")))?;
let mut hasher = Sha256::new();
hasher.update(json.as_bytes());
let hash = hasher.finalize();
Ok(hash.iter().fold(String::with_capacity(64), |mut s, b| {
use std::fmt::Write;
let _ = write!(s, "{b:02x}");
s
}))
}
pub fn save_preview<T: Serialize>(
account_hash: &str,
order: &T,
command: &str,
) -> Result<String, AppError> {
let order_value = serde_json::to_value(order)
.map_err(|e| AppError::Preview(format!("failed to serialize order for preview: {e}")))?;
let now = time::OffsetDateTime::now_utc().unix_timestamp();
let payload = SavedPreview {
version: 1,
account_hash: account_hash.to_string(),
order: order_value,
command: command.to_string(),
saved_at: now,
};
let digest = compute_digest(&payload)?;
let path = preview_dir()?.join(format!("{digest}.json"));
let json = serde_json::to_string_pretty(&payload)
.map_err(|e| AppError::Preview(format!("failed to serialize preview: {e}")))?;
private_preview_file(&path)?
.write_all(json.as_bytes())
.map_err(|e| AppError::Preview(format!("failed to write preview file: {e}")))?;
Ok(digest)
}
pub fn load_preview(digest: &str, account_hash: &str) -> Result<SavedPreview, AppError> {
if digest.len() != DIGEST_HEX_LEN || !digest.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(AppError::Preview(format!(
"invalid digest format: expected {DIGEST_HEX_LEN}-character hex string, \
got {}-character '{digest}'",
digest.len()
)));
}
let path = preview_dir()?.join(format!("{digest}.json"));
let json = fs::read_to_string(&path)
.map_err(|e| AppError::Preview(format!("preview {digest} not found or unreadable: {e}")))?;
let payload: SavedPreview = serde_json::from_str(&json)
.map_err(|e| AppError::Preview(format!("preview {digest} has corrupt data: {e}")))?;
let verified = compute_digest(&payload)?;
if verified != digest {
return Err(AppError::Preview(format!(
"preview integrity check failed: expected {digest}, derived {verified}"
)));
}
if payload.account_hash != account_hash {
return Err(AppError::Preview(
"preview account mismatch; regenerate preview for this account".to_string(),
));
}
let now = time::OffsetDateTime::now_utc().unix_timestamp();
let age = now - payload.saved_at;
if age > PREVIEW_TTL_SECS {
let _ = fs::remove_file(&path);
return Err(AppError::Preview(format!(
"preview {digest} expired ({age}s old, TTL is {PREVIEW_TTL_SECS}s)"
)));
}
Ok(payload)
}
#[cfg(test)]
mod tests {
use std::{ffi::OsString, path::Path};
use schwab::{Duration, Instruction, PutCall, Session};
use super::*;
use crate::order::builder::{self, OptionLegSpec, OrderTiming};
struct EnvVarGuard {
key: &'static str,
previous: Option<OsString>,
}
impl EnvVarGuard {
fn set_path(key: &'static str, value: &Path) -> Self {
let previous = std::env::var_os(key);
unsafe {
std::env::set_var(key, value);
}
Self { key, previous }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match self.previous.as_ref() {
Some(value) => unsafe { std::env::set_var(self.key, value) },
None => unsafe { std::env::remove_var(self.key) },
}
}
}
fn test_order_value() -> Value {
let order = builder::build_single_leg(
OptionLegSpec {
underlying: "AAPL",
expiration: "2025-01-17",
strike: 200.0,
quantity: 1,
price: Some(5.0),
put_call: PutCall::Call,
},
OrderTiming {
session: Session::Normal,
duration: Duration::Day,
},
Instruction::BuyToOpen,
)
.unwrap();
serde_json::to_value(order).unwrap()
}
#[test]
fn round_trip_save_load() {
let dir = tempfile::tempdir().unwrap();
let order = test_order_value();
let now = time::OffsetDateTime::now_utc().unix_timestamp();
let payload = SavedPreview {
version: 1,
account_hash: "test-account-hash".to_string(),
order,
command: "order.option.buy-to-open".to_string(),
saved_at: now,
};
let digest = compute_digest(&payload).unwrap();
assert_eq!(digest.len(), DIGEST_HEX_LEN);
assert!(digest.chars().all(|c| c.is_ascii_hexdigit()));
let path = dir.path().join(format!("{digest}.json"));
let json = serde_json::to_string_pretty(&payload).unwrap();
fs::write(&path, &json).unwrap();
let loaded: SavedPreview =
serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
let re_digest = compute_digest(&loaded).unwrap();
assert_eq!(digest, re_digest);
assert_eq!(loaded.account_hash, "test-account-hash");
}
#[test]
fn digest_changes_with_account() {
let order = test_order_value();
let now = time::OffsetDateTime::now_utc().unix_timestamp();
let p1 = SavedPreview {
version: 1,
account_hash: "account-a".to_string(),
order: order.clone(),
command: "order.option.buy-to-open".to_string(),
saved_at: now,
};
let p2 = SavedPreview {
version: 1,
account_hash: "account-b".to_string(),
order,
command: "order.option.buy-to-open".to_string(),
saved_at: now,
};
let d1 = compute_digest(&p1).unwrap();
let d2 = compute_digest(&p2).unwrap();
assert_ne!(d1, d2);
}
#[test]
fn load_rejects_bad_digest_format() {
let result = load_preview("not-a-valid-hex", "account");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("invalid digest format"));
}
#[test]
fn load_rejects_account_mismatch() {
let _guard = crate::config::TEST_ENV_LOCK.lock().unwrap();
let temp_dir = tempfile::tempdir().unwrap();
let _state_home = EnvVarGuard::set_path("XDG_STATE_HOME", temp_dir.path());
let result = (|| {
let digest = save_preview("HASH_A", &test_order_value(), "order.option.buy-to-open")?;
load_preview(&digest, "HASH_B")
})();
let err = result.unwrap_err();
assert_eq!(err.exit_code(), 11);
match err {
AppError::Preview(message) => {
assert_eq!(
message,
"preview account mismatch; regenerate preview for this account"
);
assert!(!message.contains("HASH_A"));
assert!(!message.contains("HASH_B"));
}
other => panic!("expected AppError::Preview, got {other:?}"),
}
}
#[cfg(unix)]
#[test]
fn preview_directory_and_file_are_owner_only() {
use std::os::unix::fs::PermissionsExt;
let _guard = crate::config::TEST_ENV_LOCK.lock().unwrap();
let temp_dir = tempfile::tempdir().unwrap();
let _state_home = EnvVarGuard::set_path("XDG_STATE_HOME", temp_dir.path());
let digest = save_preview("HASH_A", &test_order_value(), "order.option.buy-to-open")
.expect("preview should save");
let preview_path = temp_dir
.path()
.join("schwab-agent")
.join("previews")
.join(format!("{digest}.json"));
let preview_dir = preview_path.parent().expect("preview file has parent");
assert_eq!(
fs::metadata(preview_dir).unwrap().permissions().mode() & 0o777,
0o700
);
assert_eq!(
fs::metadata(preview_path).unwrap().permissions().mode() & 0o777,
0o600
);
}
#[test]
fn preview_state_base_prefers_non_empty_xdg_state_home() {
let _guard = crate::config::TEST_ENV_LOCK.lock().unwrap();
let temp_dir = tempfile::tempdir().unwrap();
let _state_home = EnvVarGuard::set_path("XDG_STATE_HOME", temp_dir.path());
assert_eq!(preview_state_base().unwrap(), temp_dir.path());
}
}