Skip to main content

jax_daemon/
state.rs

1use std::{fs, path::PathBuf};
2
3use common::prelude::SecretKey;
4use serde::{Deserialize, Serialize};
5
6pub const APP_NAME: &str = "jax";
7pub const CONFIG_FILE_NAME: &str = "config.toml";
8pub const DB_FILE_NAME: &str = "db.sqlite";
9pub const KEY_FILE_NAME: &str = "key.pem";
10pub const BLOBS_DIR_NAME: &str = "blobs";
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct AppConfig {
14    /// Port for the API HTTP server (private, mutation/RPC)
15    #[serde(default = "default_api_port")]
16    pub api_port: u16,
17    /// Port for the gateway HTTP server (public, read-only)
18    #[serde(default = "default_gateway_port")]
19    pub gateway_port: u16,
20    /// Listen port for the peer (P2P) node (optional, defaults to ephemeral)
21    #[serde(default)]
22    pub peer_port: Option<u16>,
23    /// Blob storage backend configuration (set at init time)
24    #[serde(default)]
25    pub blob_store: BlobStoreConfig,
26    /// Maximum blob size allowed for BAO imports (bytes). Defaults to 1GB.
27    #[serde(default = "default_max_import_size")]
28    pub max_import_size: u64,
29}
30
31fn default_api_port() -> u16 {
32    5001
33}
34
35fn default_gateway_port() -> u16 {
36    8080
37}
38
39fn default_max_import_size() -> u64 {
40    object_store::DEFAULT_MAX_IMPORT_SIZE
41}
42
43impl Default for AppConfig {
44    fn default() -> Self {
45        Self {
46            api_port: default_api_port(),
47            gateway_port: default_gateway_port(),
48            peer_port: None,
49            blob_store: BlobStoreConfig::default(),
50            max_import_size: default_max_import_size(),
51        }
52    }
53}
54
55/// Configuration for the blob storage backend.
56/// This determines where blob data is stored (legacy iroh, local filesystem, or S3).
57#[derive(Debug, Clone, Serialize, Deserialize, Default)]
58#[serde(tag = "type", rename_all = "snake_case")]
59pub enum BlobStoreConfig {
60    /// Legacy iroh FsStore (default, for backwards compatibility)
61    #[default]
62    Legacy,
63
64    /// New SQLite + local filesystem backend
65    Filesystem {
66        /// Absolute path for object storage
67        path: PathBuf,
68        /// Optional separate path for SQLite metadata DB (defaults to path/blobs.db)
69        #[serde(default)]
70        db_path: Option<PathBuf>,
71    },
72
73    /// S3-compatible object storage
74    S3 {
75        /// S3 URL in format: s3://access_key:secret_key@endpoint/bucket
76        /// Example: s3://minioadmin:minioadmin@localhost:9000/jax-blobs
77        url: String,
78    },
79}
80
81/// Parsed S3 configuration from URL
82#[derive(Debug, Clone)]
83pub struct S3Config {
84    pub endpoint: String,
85    pub access_key: String,
86    pub secret_key: String,
87    pub bucket: String,
88}
89
90impl BlobStoreConfig {
91    /// Parse S3 URL into components.
92    /// Format: s3://access_key:secret_key@host:port/bucket
93    pub fn parse_s3_url(url: &str) -> Result<S3Config, StateError> {
94        // Remove the s3:// prefix
95        let url = url
96            .strip_prefix("s3://")
97            .ok_or_else(|| StateError::InvalidS3Url("URL must start with s3://".to_string()))?;
98
99        // Split into credentials@host/bucket
100        let (creds, rest) = url
101            .split_once('@')
102            .ok_or_else(|| StateError::InvalidS3Url("Missing @ separator".to_string()))?;
103
104        // Parse credentials (access_key:secret_key)
105        let (access_key, secret_key) = creds
106            .split_once(':')
107            .ok_or_else(|| StateError::InvalidS3Url("Missing : in credentials".to_string()))?;
108
109        // Parse host:port/bucket
110        let (endpoint, bucket) = rest
111            .split_once('/')
112            .ok_or_else(|| StateError::InvalidS3Url("Missing / before bucket".to_string()))?;
113
114        if bucket.is_empty() {
115            return Err(StateError::InvalidS3Url("Bucket name is empty".to_string()));
116        }
117
118        // Determine protocol (default to http for localhost, https otherwise)
119        let protocol = if endpoint.contains("localhost") || endpoint.contains("127.0.0.1") {
120            "http"
121        } else {
122            "https"
123        };
124
125        Ok(S3Config {
126            endpoint: format!("{}://{}", protocol, endpoint),
127            access_key: access_key.to_string(),
128            secret_key: secret_key.to_string(),
129            bucket: bucket.to_string(),
130        })
131    }
132}
133
134#[derive(Debug, Clone)]
135pub struct AppState {
136    /// Path to the jax directory (~/.jax)
137    pub jax_dir: PathBuf,
138    /// Path to the SQLite database
139    pub db_path: PathBuf,
140    /// Path to the node key PEM file
141    pub key_path: PathBuf,
142    /// Path to the blobs directory
143    pub blobs_path: PathBuf,
144    /// Path to the config file
145    pub config_path: PathBuf,
146    /// Loaded configuration
147    pub config: AppConfig,
148}
149
150impl AppState {
151    /// Get the jax directory path (custom or default ~/.jax)
152    pub fn jax_dir(custom_path: Option<PathBuf>) -> Result<PathBuf, StateError> {
153        if let Some(path) = custom_path {
154            return Ok(path);
155        }
156
157        // Use home directory directly since we want ~/.jax
158        let home = dirs::home_dir().ok_or(StateError::NoHomeDirectory)?;
159        Ok(home.join(format!(".{}", APP_NAME)))
160    }
161
162    /// Check if jax directory exists
163    #[allow(dead_code)]
164    pub fn exists(custom_path: Option<PathBuf>) -> Result<bool, StateError> {
165        let jax_dir = Self::jax_dir(custom_path)?;
166        Ok(jax_dir.exists())
167    }
168
169    /// Initialize a new jax state directory
170    pub fn init(
171        custom_path: Option<PathBuf>,
172        config: Option<AppConfig>,
173    ) -> Result<Self, StateError> {
174        let jax_dir = Self::jax_dir(custom_path)?;
175
176        // Create jax directory if it doesn't exist
177        if jax_dir.exists() {
178            return Err(StateError::AlreadyInitialized);
179        }
180
181        fs::create_dir_all(&jax_dir)?;
182
183        // Create subdirectories
184        let blobs_path = jax_dir.join(BLOBS_DIR_NAME);
185        fs::create_dir_all(&blobs_path)?;
186
187        // Generate and save key
188        let key = SecretKey::generate();
189        let key_path = jax_dir.join(KEY_FILE_NAME);
190        fs::write(&key_path, key.to_pem())?;
191
192        // Create config (use provided or default)
193        let config = config.unwrap_or_default();
194        let config_path = jax_dir.join(CONFIG_FILE_NAME);
195        let config_toml = toml::to_string_pretty(&config)?;
196        fs::write(&config_path, config_toml)?;
197
198        // Create empty database (just touch the file, it will be initialized by the service)
199        let db_path = jax_dir.join(DB_FILE_NAME);
200        fs::write(&db_path, "")?;
201
202        Ok(Self {
203            jax_dir,
204            db_path,
205            key_path,
206            blobs_path,
207            config_path,
208            config,
209        })
210    }
211
212    /// Load existing state from jax directory
213    pub fn load(custom_path: Option<PathBuf>) -> Result<Self, StateError> {
214        let jax_dir = Self::jax_dir(custom_path)?;
215
216        if !jax_dir.exists() {
217            return Err(StateError::NotInitialized);
218        }
219
220        // Load paths
221        let db_path = jax_dir.join(DB_FILE_NAME);
222        let key_path = jax_dir.join(KEY_FILE_NAME);
223        let blobs_path = jax_dir.join(BLOBS_DIR_NAME);
224        let config_path = jax_dir.join(CONFIG_FILE_NAME);
225
226        // Verify all required files/directories exist
227        if !db_path.exists() {
228            return Err(StateError::MissingFile("db.sqlite".to_string()));
229        }
230        if !key_path.exists() {
231            return Err(StateError::MissingFile("key.pem".to_string()));
232        }
233        if !blobs_path.exists() {
234            return Err(StateError::MissingFile("blobs/".to_string()));
235        }
236        if !config_path.exists() {
237            return Err(StateError::MissingFile("config.toml".to_string()));
238        }
239
240        // Load config
241        let config_toml = fs::read_to_string(&config_path)?;
242        let config: AppConfig = toml::from_str(&config_toml)?;
243
244        Ok(Self {
245            jax_dir,
246            db_path,
247            key_path,
248            blobs_path,
249            config_path,
250            config,
251        })
252    }
253
254    /// Load the secret key from the key file
255    pub fn load_key(&self) -> Result<SecretKey, StateError> {
256        let pem = fs::read_to_string(&self.key_path)?;
257        let key = SecretKey::from_pem(&pem).map_err(|e| StateError::InvalidKey(e.to_string()))?;
258        Ok(key)
259    }
260}
261
262#[derive(Debug, thiserror::Error)]
263pub enum StateError {
264    #[error("jax directory not initialized. Run 'cli init' first")]
265    NotInitialized,
266
267    #[error("jax directory already initialized")]
268    AlreadyInitialized,
269
270    #[error("no home directory found")]
271    NoHomeDirectory,
272
273    #[error("missing required file: {0}")]
274    MissingFile(String),
275
276    #[error("invalid key: {0}")]
277    InvalidKey(String),
278
279    #[error("invalid S3 URL: {0}")]
280    InvalidS3Url(String),
281
282    #[error("IO error: {0}")]
283    Io(#[from] std::io::Error),
284
285    #[error("TOML serialization error: {0}")]
286    TomlSer(#[from] toml::ser::Error),
287
288    #[error("TOML deserialization error: {0}")]
289    TomlDe(#[from] toml::de::Error),
290}