Skip to main content

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}