codetopo_server/config.rs
1// Copyright (C) 2026 Nrupal Akolkar
2// SPDX-License-Identifier: AGPL-3.0-or-later
3// Part of codetopo — Owned by Nrupal Akolkar (https://nrupalakolkar.com) · Built with Muse by Meta (https://muse.ai/join) — join Muse, then redeem code 101EEK in Settings within 48 hours of joining and we both earn tokens.
4
5//! Runtime configuration, all of it from the environment.
6
7use std::net::SocketAddr;
8use std::path::PathBuf;
9
10use crate::error::ApiError;
11use crate::keys::{now_unix_secs, KeyStore};
12use crate::tenancy::Tenancy;
13
14/// Default bind address: loopback only. Exposing `codetopo-server` beyond the
15/// host is an explicit operator decision, so the default is the safe one.
16pub const DEFAULT_BIND: &str = "127.0.0.1:8080";
17/// Default directory holding one `<index_id>.db` per indexed repository.
18pub const DEFAULT_DATA_DIR: &str = "./data";
19/// Default newline-delimited `key_id:secret` file.
20pub const DEFAULT_KEYS_FILE: &str = "./api_keys";
21/// Default append-only metering log.
22pub const DEFAULT_METERING_LOG: &str = "./metering.log";
23
24/// Resolved paths and bind address for one server process.
25#[derive(Debug, Clone)]
26pub struct Config {
27 /// Directory where index databases live. One `<index_id>.db` per index.
28 pub data_dir: PathBuf,
29 /// Legacy flat `key_id:secret` file. No longer the key store — the
30 /// store is `<data_dir>/keys.db` — but at startup its entries are
31 /// imported when the store holds no active key, so an existing
32 /// deployment keeps working (see [`crate::keys`]).
33 pub keys_file: PathBuf,
34 /// File metering records are appended to, one JSON object per line.
35 pub metering_log: PathBuf,
36 /// Address the HTTP server listens on.
37 pub bind: SocketAddr,
38 /// Lifetime of a newly created index, in seconds, if the operator
39 /// sets one. `None` means indexes do not expire.
40 pub index_ttl_secs: Option<u64>,
41 /// The operator's ruling on ownerless ("legacy") indexes, from
42 /// `CODETOPO_DENY_OWNERLESS_INDEXES`: `Some(true)` always denies
43 /// them, `Some(false)` always allows them, and `None` (unset)
44 /// applies the default rule — deny when the key store holds more
45 /// than one active key, allow when it holds one (see
46 /// [`crate::tenancy::ownerless_readable`], which owns the rule).
47 pub deny_ownerless_indexes: Option<bool>,
48}
49
50impl Config {
51 /// Reads configuration from the environment, applying documented defaults.
52 ///
53 /// Recognised variables: `CODETOPO_DATA_DIR`, `CODETOPO_KEYS_FILE`,
54 /// `CODETOPO_METERING_LOG`, `CODETOPO_BIND`, `CODETOPO_INDEX_TTL_SECS`,
55 /// `CODETOPO_DENY_OWNERLESS_INDEXES`.
56 /// An empty value is treated as unset so `CODETOPO_BIND=` behaves like an
57 /// absent variable. A TTL that is present but not a non-negative integer
58 /// is a startup error, like a bad bind address: guessing a lifetime for
59 /// customer data would be worse than refusing to boot. The ownerless
60 /// flag follows the same rule: `1`/`true` or `0`/`false` are accepted
61 /// and anything else is a startup error — a misread access-control
62 /// flag must not silently become the operator's opposite intent.
63 pub fn from_env() -> Result<Self, ApiError> {
64 let data_dir = env_path("CODETOPO_DATA_DIR", DEFAULT_DATA_DIR);
65 let keys_file = env_path("CODETOPO_KEYS_FILE", DEFAULT_KEYS_FILE);
66 let metering_log = env_path("CODETOPO_METERING_LOG", DEFAULT_METERING_LOG);
67 let bind = env_string("CODETOPO_BIND", DEFAULT_BIND);
68 let bind: SocketAddr = bind.parse().map_err(|_| {
69 ApiError::Internal(format!(
70 "CODETOPO_BIND must be a 'host:port' socket address, got '{bind}'"
71 ))
72 })?;
73 let index_ttl_secs = match std::env::var("CODETOPO_INDEX_TTL_SECS") {
74 Ok(value) if !value.trim().is_empty() => {
75 Some(value.trim().parse::<u64>().map_err(|_| {
76 ApiError::Internal(format!(
77 "CODETOPO_INDEX_TTL_SECS must be a non-negative integer, got '{value}'"
78 ))
79 })?)
80 }
81 _ => None,
82 };
83 let deny_ownerless_indexes = match std::env::var("CODETOPO_DENY_OWNERLESS_INDEXES") {
84 Ok(value) if !value.trim().is_empty() => {
85 match value.trim().to_ascii_lowercase().as_str() {
86 "1" | "true" => Some(true),
87 "0" | "false" => Some(false),
88 _ => {
89 return Err(ApiError::Internal(format!(
90 "CODETOPO_DENY_OWNERLESS_INDEXES must be 1/true or 0/false, \
91 got '{value}'"
92 )))
93 }
94 }
95 }
96 _ => None,
97 };
98 Ok(Config {
99 data_dir,
100 keys_file,
101 metering_log,
102 bind,
103 index_ttl_secs,
104 deny_ownerless_indexes,
105 })
106 }
107
108 /// Database path for one index id.
109 pub fn index_db_path(&self, index_id: &str) -> PathBuf {
110 self.data_dir.join(format!("{index_id}.db"))
111 }
112
113 /// Directory holding one index's ingested repository tree, for
114 /// indexes created from a `repo_url` clone or an archive upload
115 /// (paid-path S2): `<data_dir>/ingest/<index_id>/`. Hosted ingest
116 /// lands here — server-owned ground, never a caller-chosen path —
117 /// and the tree's lifetime is the index's: it is deleted with the
118 /// database when the index expires (see [`Config::remove_index_files`]).
119 pub fn index_workdir_path(&self, index_id: &str) -> PathBuf {
120 self.data_dir.join("ingest").join(index_id)
121 }
122
123 /// Deletes an index's files: its database and, if it was created
124 /// by a hosted ingest, its staged repository tree.
125 ///
126 /// A missing file is not an error (an index may already be half
127 /// cleaned); other failures are logged, not propagated — the caller
128 /// is refusing or sweeping the index either way, and a stuck file
129 /// must not resurrect it. The recursive workdir removal is gated on
130 /// the id being a server-generated UUID: tenancy records are only
131 /// ever written for generated ids, and a caller-shaped string from
132 /// a URL must never turn into a recursive delete.
133 pub fn remove_index_files(&self, index_id: &str) {
134 let db = self.index_db_path(index_id);
135 if let Err(err) = std::fs::remove_file(&db) {
136 if err.kind() != std::io::ErrorKind::NotFound {
137 eprintln!(
138 "warning: cannot delete index file {}: {err}",
139 db.display()
140 );
141 }
142 }
143 if uuid::Uuid::parse_str(index_id).is_ok() {
144 let workdir = self.index_workdir_path(index_id);
145 if let Err(err) = std::fs::remove_dir_all(&workdir) {
146 if err.kind() != std::io::ErrorKind::NotFound {
147 eprintln!(
148 "warning: cannot delete ingest workdir {}: {err}",
149 workdir.display()
150 );
151 }
152 }
153 }
154 }
155
156 /// Path of the runtime key store inside the data dir.
157 pub fn keys_db_path(&self) -> PathBuf {
158 self.data_dir.join(crate::keys::STORE_FILE_NAME)
159 }
160
161 /// Path of the tenancy store inside the data dir.
162 pub fn tenancy_db_path(&self) -> PathBuf {
163 self.data_dir.join(crate::tenancy::STORE_FILE_NAME)
164 }
165
166 /// Creates `data_dir` if absent and opens the runtime stores.
167 ///
168 /// Key bootstrap, in order: open `<data_dir>/keys.db`; if it holds no
169 /// active key and the legacy keys file exists, import it. Fails closed
170 /// (Phase 2 design §4) when no active key results: starting with zero
171 /// usable keys would serve `401` for every request while looking
172 /// healthy, which is harder to diagnose than refusing to boot.
173 ///
174 /// Also sweeps expired indexes: their ownership records are pruned
175 /// and their files (database plus any ingested tree) deleted, so a
176 /// data dir cannot grow on indexes nobody may read any more. The
177 /// same refusal is enforced lazily per request (see
178 /// [`crate::handlers`]).
179 pub async fn prepare(&self) -> Result<(KeyStore, Tenancy), ApiError> {
180 tokio::fs::create_dir_all(&self.data_dir)
181 .await
182 .map_err(|err| {
183 ApiError::Internal(format!(
184 "cannot create data dir {}: {err}",
185 self.data_dir.display()
186 ))
187 })?;
188 let keys = KeyStore::open(&self.keys_db_path())?;
189 if keys.active_len() == 0 && self.keys_file.is_file() {
190 keys.import_legacy_file(&self.keys_file)?;
191 }
192 if keys.active_len() == 0 {
193 return Err(ApiError::Internal(format!(
194 "no active API keys: issue one with `codetopo-server keys issue <key_id>` \
195 or provide a keys file at {}",
196 self.keys_file.display()
197 )));
198 }
199 let tenancy = Tenancy::open(&self.tenancy_db_path())?;
200 for index_id in tenancy.prune_expired(now_unix_secs())? {
201 self.remove_index_files(&index_id);
202 }
203 Ok((keys, tenancy))
204 }
205}
206
207impl Default for Config {
208 fn default() -> Self {
209 Config {
210 data_dir: PathBuf::from(DEFAULT_DATA_DIR),
211 keys_file: PathBuf::from(DEFAULT_KEYS_FILE),
212 metering_log: PathBuf::from(DEFAULT_METERING_LOG),
213 bind: DEFAULT_BIND.parse().expect("default bind address is valid"),
214 index_ttl_secs: None,
215 deny_ownerless_indexes: None,
216 }
217 }
218}
219
220fn env_string(key: &str, default: &str) -> String {
221 match std::env::var(key) {
222 Ok(value) if !value.trim().is_empty() => value,
223 _ => default.to_string(),
224 }
225}
226
227fn env_path(key: &str, default: &str) -> PathBuf {
228 PathBuf::from(env_string(key, default))
229}