sqlpage/
app_config.rs

1use crate::cli::arguments::{parse_cli, Cli};
2use crate::webserver::content_security_policy::ContentSecurityPolicyTemplate;
3use crate::webserver::routing::RoutingConfig;
4use anyhow::Context;
5use config::Config;
6use openidconnect::IssuerUrl;
7use percent_encoding::AsciiSet;
8use serde::de::Error;
9use serde::{Deserialize, Deserializer, Serialize};
10use std::net::{SocketAddr, ToSocketAddrs};
11use std::path::{Path, PathBuf};
12
13#[cfg(not(feature = "lambda-web"))]
14const DEFAULT_DATABASE_FILE: &str = "sqlpage.db";
15
16impl AppConfig {
17    pub fn from_cli(cli: &Cli) -> anyhow::Result<Self> {
18        let mut config = if let Some(config_file) = &cli.config_file {
19            if !config_file.is_file() {
20                return Err(anyhow::anyhow!(
21                    "Configuration file does not exist: {:?}",
22                    config_file
23                ));
24            }
25            log::debug!("Loading configuration from file: {}", config_file.display());
26            load_from_file(config_file)?
27        } else if let Some(config_dir) = &cli.config_dir {
28            log::debug!(
29                "Loading configuration from directory: {}",
30                config_dir.display()
31            );
32            load_from_directory(config_dir)?
33        } else {
34            log::debug!("Loading configuration from environment");
35            load_from_env()?
36        };
37        if let Some(web_root) = &cli.web_root {
38            log::debug!(
39                "Setting web root to value from the command line: {}",
40                web_root.display()
41            );
42            config.web_root.clone_from(web_root);
43        }
44        if let Some(config_dir) = &cli.config_dir {
45            config.configuration_directory.clone_from(config_dir);
46        }
47
48        config.configuration_directory = std::fs::canonicalize(&config.configuration_directory)
49            .unwrap_or_else(|_| config.configuration_directory.clone());
50
51        if !config.configuration_directory.exists() {
52            log::info!(
53                "Configuration directory does not exist, creating it: {}",
54                config.configuration_directory.display()
55            );
56            std::fs::create_dir_all(&config.configuration_directory).with_context(|| {
57                format!(
58                    "Failed to create configuration directory in {}",
59                    config.configuration_directory.display()
60                )
61            })?;
62        }
63
64        if config.database_url.is_empty() {
65            log::debug!(
66                "Creating default database in {}",
67                config.configuration_directory.display()
68            );
69            config.database_url = create_default_database(&config.configuration_directory);
70        }
71
72        config
73            .validate()
74            .context("The provided configuration is invalid")?;
75
76        log::debug!("Loaded configuration: {config:#?}");
77        log::info!(
78            "Configuration loaded from {}",
79            config.configuration_directory.display()
80        );
81
82        Ok(config)
83    }
84
85    fn validate(&self) -> anyhow::Result<()> {
86        if !self.web_root.is_dir() {
87            return Err(anyhow::anyhow!(
88                "Web root is not a valid directory: {:?}",
89                self.web_root
90            ));
91        }
92        if !self.configuration_directory.is_dir() {
93            return Err(anyhow::anyhow!(
94                "Configuration directory is not a valid directory: {:?}",
95                self.configuration_directory
96            ));
97        }
98        if self.database_connection_acquire_timeout_seconds <= 0.0 {
99            return Err(anyhow::anyhow!(
100                "Database connection acquire timeout must be positive"
101            ));
102        }
103        if let Some(max_connections) = self.max_database_pool_connections {
104            if max_connections == 0 {
105                return Err(anyhow::anyhow!(
106                    "Maximum database pool connections must be greater than 0"
107                ));
108            }
109        }
110        if let Some(idle_timeout) = self.database_connection_idle_timeout_seconds {
111            if idle_timeout < 0.0 {
112                return Err(anyhow::anyhow!(
113                    "Database connection idle timeout must be non-negative"
114                ));
115            }
116        }
117        if let Some(max_lifetime) = self.database_connection_max_lifetime_seconds {
118            if max_lifetime < 0.0 {
119                return Err(anyhow::anyhow!(
120                    "Database connection max lifetime must be non-negative"
121                ));
122            }
123        }
124        anyhow::ensure!(self.max_pending_rows > 0, "max_pending_rows cannot be null");
125        Ok(())
126    }
127}
128
129pub fn load_config() -> anyhow::Result<AppConfig> {
130    let cli = parse_cli()?;
131    AppConfig::from_cli(&cli)
132}
133
134pub fn load_from_env() -> anyhow::Result<AppConfig> {
135    let config_dir = configuration_directory();
136    load_from_directory(&config_dir)
137        .with_context(|| format!("Unable to load configuration from {}", config_dir.display()))
138}
139
140#[derive(Debug, Deserialize, PartialEq, Clone)]
141#[allow(clippy::struct_excessive_bools)]
142pub struct AppConfig {
143    #[serde(default = "default_database_url")]
144    pub database_url: String,
145    /// A separate field for the database password. If set, this will override any password specified in the `database_url`.
146    #[serde(default)]
147    pub database_password: Option<String>,
148    pub max_database_pool_connections: Option<u32>,
149    pub database_connection_idle_timeout_seconds: Option<f64>,
150    pub database_connection_max_lifetime_seconds: Option<f64>,
151
152    #[serde(default)]
153    pub sqlite_extensions: Vec<String>,
154
155    #[serde(default, deserialize_with = "deserialize_socket_addr")]
156    pub listen_on: Option<SocketAddr>,
157    pub port: Option<u16>,
158    pub unix_socket: Option<PathBuf>,
159
160    /// Number of times to retry connecting to the database after a failure when the server starts
161    /// up. Retries will happen every 5 seconds. The default is 6 retries, which means the server
162    /// will wait up to 30 seconds for the database to become available.
163    #[serde(default = "default_database_connection_retries")]
164    pub database_connection_retries: u32,
165
166    /// Maximum number of seconds to wait before giving up when acquiring a database connection from the
167    /// pool. The default is 10 seconds.
168    #[serde(default = "default_database_connection_acquire_timeout_seconds")]
169    pub database_connection_acquire_timeout_seconds: f64,
170
171    /// The directory where the .sql files are located. Defaults to the current directory.
172    #[serde(default = "default_web_root")]
173    pub web_root: PathBuf,
174
175    /// The directory where the sqlpage configuration file is located. Defaults to `./sqlpage`.
176    #[serde(default = "configuration_directory")]
177    pub configuration_directory: PathBuf,
178
179    /// Set to true to allow the `sqlpage.exec` function to be used in SQL queries.
180    /// This should be enabled only if you trust the users writing SQL queries, since it gives
181    /// them the ability to execute arbitrary shell commands on the server.
182    #[serde(default)]
183    pub allow_exec: bool,
184
185    /// Maximum size of uploaded files in bytes. The default is 10MiB (10 * 1024 * 1024 bytes)
186    #[serde(default = "default_max_file_size")]
187    pub max_uploaded_file_size: usize,
188
189    /// The base URL of the `OpenID` Connect provider.
190    /// Required when enabling Single Sign-On through an OIDC provider.
191    pub oidc_issuer_url: Option<IssuerUrl>,
192    /// The client ID assigned to `SQLPage` when registering with the OIDC provider.
193    /// Defaults to `sqlpage`.
194    #[serde(default = "default_oidc_client_id")]
195    pub oidc_client_id: String,
196    /// The client secret for authenticating `SQLPage` to the OIDC provider.
197    /// Required when enabling Single Sign-On through an OIDC provider.
198    pub oidc_client_secret: Option<String>,
199    /// Space-separated list of [scopes](https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims) to request during OIDC authentication.
200    /// Defaults to "openid email profile"
201    #[serde(default = "default_oidc_scopes")]
202    pub oidc_scopes: String,
203
204    /// Defines a list of path prefixes that should be protected by OIDC authentication.
205    /// By default, all paths are protected.
206    /// If you specify a list of prefixes, only requests whose path starts with one of the prefixes will require authentication.
207    /// For example, if you set this to `["/private"]`, then requests to `/private/some_page.sql` will require authentication,
208    /// but requests to `/index.sql` will not.
209    /// NOTE: `OIDC_PUBLIC_PATHS` takes precedence over `OIDC_PROTECTED_PATHS`.
210    /// For example, if you have `["/private"]` on the `protected_paths` like before, but also `["/private/public"]` on the `public_paths`, then `/private` requires authentication, but `/private/public` requires not authentication.
211    /// You cannot make a path inside a public path private again. So expanding the previous example, if you now add `/private/public/private_again`, then this path will still be accessible.
212    #[serde(default = "default_oidc_protected_paths")]
213    pub oidc_protected_paths: Vec<String>,
214
215    /// Defines path prefixes to exclude from OIDC authentication.
216    /// By default, no paths are excluded.
217    /// Paths matching these prefixes will not require authentication.
218    /// For example, if set to `["/public"]`, requests to `/public/some_page.sql` will not require authentication,
219    /// but requests to `/index.sql` will still require it.
220    /// To make `/protected/public.sql` public while protecting its containing directory,
221    /// set `oidc_public_paths` to `["/protected/public.sql"]` and `oidc_protected_paths` to `["/protected"]`.
222    /// Be aware that any path starting with `/protected/public.sql` (e.g., `/protected/public.sql.backup`) will also become public.
223    #[serde(default)]
224    pub oidc_public_paths: Vec<String>,
225
226    /// Additional trusted audiences for OIDC JWT tokens, beyond the client ID.
227    /// By default (when None), all additional audiences are trusted for compatibility
228    /// with providers that include multiple audience values (like ZITADEL, Azure AD, etc.).
229    /// Set to an empty list to only allow the client ID as audience.
230    /// Set to a specific list to only allow those specific additional audiences.
231    #[serde(default)]
232    pub oidc_additional_trusted_audiences: Option<Vec<String>>,
233
234    /// A domain name to use for the HTTPS server. If this is set, the server will perform all the necessary
235    /// steps to set up an HTTPS server automatically. All you need to do is point your domain name to the
236    /// server's IP address.
237    ///
238    /// It will listen on port 443 for HTTPS connections,
239    /// and will automatically request a certificate from Let's Encrypt
240    /// using the ACME protocol (requesting a TLS-ALPN-01 challenge).
241    pub https_domain: Option<String>,
242
243    /// The hostname where your application is publicly accessible (e.g., "myapp.example.com").
244    /// This is used for OIDC redirect URLs. If not set, `https_domain` will be used instead.
245    pub host: Option<String>,
246
247    /// The email address to use when requesting a certificate from Let's Encrypt.
248    /// Defaults to `contact@<https_domain>`.
249    pub https_certificate_email: Option<String>,
250
251    /// The directory to store the Let's Encrypt certificate in. Defaults to `./sqlpage/https`.
252    #[serde(default = "default_https_certificate_cache_dir")]
253    pub https_certificate_cache_dir: PathBuf,
254
255    /// URL to the ACME directory. Defaults to the Let's Encrypt production directory.
256    #[serde(default = "default_https_acme_directory_url")]
257    pub https_acme_directory_url: String,
258
259    /// Whether we should run in development or production mode. Used to determine
260    /// whether to show error messages to the user.
261    #[serde(default)]
262    pub environment: DevOrProd,
263
264    /// Serve the website from a sub path. For example, if you set this to `/sqlpage/`, the website will be
265    /// served from `https://yourdomain.com/sqlpage/`. Defaults to `/`.
266    /// This is useful if you want to serve the website on the same domain as other content, and
267    /// you are using a reverse proxy to route requests to the correct server.
268    #[serde(
269        deserialize_with = "deserialize_site_prefix",
270        default = "default_site_prefix"
271    )]
272    pub site_prefix: String,
273
274    /// Maximum number of messages that can be stored in memory before sending them to the client.
275    /// This prevents a single request from using up all available memory.
276    #[serde(default = "default_max_pending_rows")]
277    pub max_pending_rows: usize,
278
279    /// Whether to compress the http response body when the client supports it.
280    #[serde(default = "default_compress_responses")]
281    pub compress_responses: bool,
282
283    /// Content-Security-Policy header to send to the client.
284    /// If not set, a default policy allowing
285    ///  - scripts from the same origin,
286    ///  - script elements with the `nonce="{{@csp_nonce}}"` attribute,
287    #[serde(default)]
288    pub content_security_policy: ContentSecurityPolicyTemplate,
289
290    /// Whether `sqlpage.fetch` should load trusted certificates from the operating system's certificate store
291    /// By default, it loads Mozilla's root certificates that are embedded in the `SQLPage` binary, or the ones pointed to by the
292    /// `SSL_CERT_FILE` and `SSL_CERT_DIR` environment variables.
293    #[serde(default = "default_system_root_ca_certificates")]
294    pub system_root_ca_certificates: bool,
295
296    /// Maximum depth of recursion allowed in the `run_sql` function.
297    #[serde(default = "default_max_recursion_depth")]
298    pub max_recursion_depth: u8,
299
300    #[serde(default = "default_markdown_allow_dangerous_html")]
301    pub markdown_allow_dangerous_html: bool,
302
303    #[serde(default = "default_markdown_allow_dangerous_protocol")]
304    pub markdown_allow_dangerous_protocol: bool,
305}
306
307impl AppConfig {
308    #[must_use]
309    pub fn listen_on(&self) -> SocketAddr {
310        let mut addr = self.listen_on.unwrap_or_else(|| {
311            if self.https_domain.is_some() {
312                SocketAddr::from(([0, 0, 0, 0], 443))
313            } else {
314                SocketAddr::from(([0, 0, 0, 0], 8080))
315            }
316        });
317        if let Some(port) = self.port {
318            addr.set_port(port);
319        }
320        addr
321    }
322}
323
324impl RoutingConfig for AppConfig {
325    fn prefix(&self) -> &str {
326        &self.site_prefix
327    }
328}
329
330/// The directory where the `sqlpage.json` file is located.
331/// Determined by the `SQLPAGE_CONFIGURATION_DIRECTORY` environment variable
332fn configuration_directory() -> PathBuf {
333    let env_var_name = "CONFIGURATION_DIRECTORY";
334    // uppercase or lowercase, with or without the "SQLPAGE_" prefix
335    for prefix in &["", "SQLPAGE_"] {
336        let var = format!("{prefix}{env_var_name}");
337        for t in [str::to_lowercase, str::to_uppercase] {
338            let dir = t(&var);
339            if let Ok(dir) = std::env::var(dir) {
340                return PathBuf::from(dir);
341            }
342        }
343    }
344    PathBuf::from("./sqlpage")
345}
346
347fn cannonicalize_if_possible(path: &std::path::Path) -> PathBuf {
348    path.canonicalize().unwrap_or_else(|_| path.to_owned())
349}
350
351/// Parses and loads the configuration from the `sqlpage.json` file in the current directory.
352/// This should be called only once at the start of the program.
353pub fn load_from_directory(directory: &Path) -> anyhow::Result<AppConfig> {
354    let cannonical = cannonicalize_if_possible(directory);
355    log::debug!("Loading configuration from {}", cannonical.display());
356    let config_file = directory.join("sqlpage");
357    let mut app_config = load_from_file(&config_file)?;
358    app_config.configuration_directory = directory.into();
359    Ok(app_config)
360}
361
362/// Parses and loads the configuration from the given file.
363pub fn load_from_file(config_file: &Path) -> anyhow::Result<AppConfig> {
364    log::debug!("Loading configuration from file: {}", config_file.display());
365    let config = Config::builder()
366        .add_source(config::File::from(config_file).required(false))
367        .add_source(env_config())
368        .add_source(env_config().prefix("SQLPAGE"))
369        .build()
370        .with_context(|| {
371            format!(
372                "Unable to build configuration loader for {}",
373                config_file.display()
374            )
375        })?;
376    log::trace!("Configuration sources: {:#?}", config.cache);
377    let app_config = config
378        .try_deserialize::<AppConfig>()
379        .context("Failed to load the configuration")?;
380    Ok(app_config)
381}
382
383fn env_config() -> config::Environment {
384    config::Environment::default()
385        .try_parsing(true)
386        .list_separator(" ")
387        .with_list_parse_key("sqlite_extensions")
388}
389
390fn deserialize_socket_addr<'de, D: Deserializer<'de>>(
391    deserializer: D,
392) -> Result<Option<SocketAddr>, D::Error> {
393    let host_str: Option<String> = Deserialize::deserialize(deserializer)?;
394    host_str
395        .map(|h| {
396            parse_socket_addr(&h).map_err(|e| {
397                D::Error::custom(anyhow::anyhow!("Failed to parse socket address {h:?}: {e}"))
398            })
399        })
400        .transpose()
401}
402
403fn deserialize_site_prefix<'de, D: Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> {
404    let prefix: String = Deserialize::deserialize(deserializer)?;
405    Ok(normalize_site_prefix(prefix.as_str()))
406}
407
408/// We standardize the site prefix to always be stored with both leading and trailing slashes.
409/// We also percent-encode special characters in the prefix, but allow it to contain slashes (to allow
410/// hosting on a sub-sub-path).
411fn normalize_site_prefix(prefix: &str) -> String {
412    const TO_ENCODE: AsciiSet = percent_encoding::CONTROLS
413        .add(b' ')
414        .add(b'"')
415        .add(b'#')
416        .add(b'<')
417        .add(b'>')
418        .add(b'?');
419
420    let prefix = prefix.trim_start_matches('/').trim_end_matches('/');
421    if prefix.is_empty() {
422        return default_site_prefix();
423    }
424    let encoded_prefix = percent_encoding::percent_encode(prefix.as_bytes(), &TO_ENCODE);
425
426    let invalid_chars = ["%09", "%0A", "%0D"];
427
428    std::iter::once("/")
429        .chain(encoded_prefix.filter(|c| !invalid_chars.contains(c)))
430        .chain(std::iter::once("/"))
431        .collect::<String>()
432}
433
434#[test]
435fn test_normalize_site_prefix() {
436    assert_eq!(normalize_site_prefix(""), "/");
437    assert_eq!(normalize_site_prefix("/"), "/");
438    assert_eq!(normalize_site_prefix("a"), "/a/");
439    assert_eq!(normalize_site_prefix("a/"), "/a/");
440    assert_eq!(normalize_site_prefix("/a"), "/a/");
441    assert_eq!(normalize_site_prefix("a/b"), "/a/b/");
442    assert_eq!(normalize_site_prefix("a/b/"), "/a/b/");
443    assert_eq!(normalize_site_prefix("a/b/c"), "/a/b/c/");
444    assert_eq!(normalize_site_prefix("a b"), "/a%20b/");
445    assert_eq!(normalize_site_prefix("a b/c"), "/a%20b/c/");
446}
447
448fn default_site_prefix() -> String {
449    '/'.to_string()
450}
451
452fn parse_socket_addr(host_str: &str) -> anyhow::Result<SocketAddr> {
453    host_str
454        .to_socket_addrs()?
455        .next()
456        .with_context(|| format!("Resolving host '{host_str}'"))
457}
458
459#[cfg(test)]
460fn default_database_url() -> String {
461    "sqlite://:memory:?cache=shared".to_owned()
462}
463#[cfg(not(test))]
464fn default_database_url() -> String {
465    // When using a custom configuration directory, the default database URL
466    // will be set later in `AppConfig::from_cli`.
467    String::new()
468}
469
470fn create_default_database(configuration_directory: &Path) -> String {
471    let prefix = "sqlite://".to_owned();
472
473    #[cfg(not(feature = "lambda-web"))]
474    {
475        let config_dir = cannonicalize_if_possible(configuration_directory);
476        let old_default_db_path = PathBuf::from(DEFAULT_DATABASE_FILE);
477        let default_db_path = config_dir.join(DEFAULT_DATABASE_FILE);
478        if let Ok(true) = old_default_db_path.try_exists() {
479            log::warn!("Your sqlite database in {} is publicly accessible through your web server. Please move it to {}.", old_default_db_path.display(), default_db_path.display());
480            return prefix + old_default_db_path.to_str().unwrap();
481        } else if let Ok(true) = default_db_path.try_exists() {
482            log::debug!(
483                "Using the default database file in {}",
484                default_db_path.display()
485            );
486            return prefix + &encode_uri(&default_db_path);
487        }
488        // Create the default database file if we can
489        if let Ok(tmp_file) = std::fs::File::create(&default_db_path) {
490            log::info!(
491                "No DATABASE_URL provided, {} is writable, creating a new database file.",
492                default_db_path.display()
493            );
494            drop(tmp_file);
495            std::fs::remove_file(&default_db_path).expect("removing temp file");
496            return prefix + &encode_uri(&default_db_path) + "?mode=rwc";
497        }
498    }
499
500    log::warn!("No DATABASE_URL provided, and {} is not writeable. Using a temporary in-memory SQLite database. All the data created will be lost when this server shuts down.", configuration_directory.display());
501    prefix + ":memory:?cache=shared"
502}
503
504#[cfg(any(test, not(feature = "lambda-web")))]
505fn encode_uri(path: &Path) -> std::borrow::Cow<'_, str> {
506    const ASCII_SET: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC
507        .remove(b'-')
508        .remove(b'_')
509        .remove(b'.')
510        .remove(b':')
511        .remove(b' ')
512        .remove(b'/');
513    let path_bytes = path.as_os_str().as_encoded_bytes();
514    percent_encoding::percent_encode(path_bytes, ASCII_SET).into()
515}
516
517fn default_database_connection_retries() -> u32 {
518    6
519}
520
521fn default_database_connection_acquire_timeout_seconds() -> f64 {
522    10.
523}
524
525fn default_web_root() -> PathBuf {
526    std::env::current_dir().unwrap_or_else(|e| {
527        log::error!("Unable to get current directory: {e}");
528        PathBuf::from(&std::path::Component::CurDir)
529    })
530}
531
532fn default_max_file_size() -> usize {
533    5 * 1024 * 1024
534}
535
536fn default_https_certificate_cache_dir() -> PathBuf {
537    default_web_root().join("sqlpage").join("https")
538}
539
540fn default_https_acme_directory_url() -> String {
541    "https://acme-v02.api.letsencrypt.org/directory".to_string()
542}
543
544/// If the sending queue exceeds this number of outgoing messages, an error will be thrown
545/// This prevents a single request from using up all available memory
546fn default_max_pending_rows() -> usize {
547    256
548}
549
550fn default_compress_responses() -> bool {
551    true
552}
553
554fn default_system_root_ca_certificates() -> bool {
555    std::env::var("SSL_CERT_FILE").is_ok_and(|x| !x.is_empty())
556        || std::env::var("SSL_CERT_DIR").is_ok_and(|x| !x.is_empty())
557}
558
559fn default_max_recursion_depth() -> u8 {
560    10
561}
562
563fn default_markdown_allow_dangerous_html() -> bool {
564    false
565}
566
567fn default_markdown_allow_dangerous_protocol() -> bool {
568    false
569}
570
571fn default_oidc_client_id() -> String {
572    "sqlpage".to_string()
573}
574
575fn default_oidc_scopes() -> String {
576    "openid email profile".to_string()
577}
578
579fn default_oidc_protected_paths() -> Vec<String> {
580    vec!["/".to_string()]
581}
582
583#[derive(Debug, Deserialize, Serialize, PartialEq, Clone, Copy, Eq, Default)]
584#[serde(rename_all = "lowercase")]
585pub enum DevOrProd {
586    #[default]
587    Development,
588    Production,
589}
590impl DevOrProd {
591    pub(crate) fn is_prod(self) -> bool {
592        self == DevOrProd::Production
593    }
594}
595
596#[must_use]
597pub fn test_database_url() -> String {
598    std::env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite::memory:".to_string())
599}
600
601#[cfg(test)]
602pub mod tests {
603    pub use super::test_database_url;
604    use super::AppConfig;
605
606    #[must_use]
607    pub fn test_config() -> AppConfig {
608        serde_json::from_str::<AppConfig>(
609            &serde_json::json!({
610                "database_url": test_database_url(),
611                "listen_on": "localhost:8080"
612            })
613            .to_string(),
614        )
615        .unwrap()
616    }
617}
618
619#[cfg(test)]
620mod test {
621    use super::*;
622    use std::env;
623    use std::sync::Mutex;
624
625    static ENV_LOCK: Mutex<()> = Mutex::new(());
626
627    #[test]
628    fn test_default_site_prefix() {
629        assert_eq!(default_site_prefix(), "/".to_string());
630    }
631
632    #[test]
633    fn test_encode_uri() {
634        assert_eq!(
635            encode_uri(Path::new("/hello world/xxx.db")),
636            "/hello world/xxx.db"
637        );
638        assert_eq!(encode_uri(Path::new("é")), "%C3%A9");
639        assert_eq!(encode_uri(Path::new("/a?b/c")), "/a%3Fb/c");
640    }
641
642    #[test]
643    fn test_normalize_site_prefix() {
644        assert_eq!(normalize_site_prefix(""), "/");
645        assert_eq!(normalize_site_prefix("/"), "/");
646        assert_eq!(normalize_site_prefix("a"), "/a/");
647        assert_eq!(normalize_site_prefix("a/"), "/a/");
648        assert_eq!(normalize_site_prefix("/a"), "/a/");
649        assert_eq!(normalize_site_prefix("a/b"), "/a/b/");
650        assert_eq!(normalize_site_prefix("a/b/"), "/a/b/");
651        assert_eq!(normalize_site_prefix("a/b/c"), "/a/b/c/");
652        assert_eq!(normalize_site_prefix("a b"), "/a%20b/");
653        assert_eq!(normalize_site_prefix("a b/c"), "/a%20b/c/");
654        assert_eq!(normalize_site_prefix("*-+/:;,?%\"'{"), "/*-+/:;,%3F%%22'{/");
655        assert_eq!(
656            normalize_site_prefix(
657                &(0..=0x7F).map(char::from).collect::<String>()
658            ),
659            "/%00%01%02%03%04%05%06%07%08%0B%0C%0E%0F%10%11%12%13%14%15%16%17%18%19%1A%1B%1C%1D%1E%1F%20!%22%23$%&'()*+,-./0123456789:;%3C=%3E%3F@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~%7F/"
660        );
661    }
662
663    #[test]
664    fn test_sqlpage_prefixed_env_variable_parsing() {
665        let _lock = ENV_LOCK
666            .lock()
667            .expect("Another test panicked while holding the lock");
668        env::set_var("SQLPAGE_CONFIGURATION_DIRECTORY", "/path/to/config");
669
670        let config = load_from_env().unwrap();
671
672        assert_eq!(
673            config.configuration_directory,
674            PathBuf::from("/path/to/config"),
675            "Configuration directory should match the SQLPAGE_CONFIGURATION_DIRECTORY env var"
676        );
677
678        env::remove_var("SQLPAGE_CONFIGURATION_DIRECTORY");
679    }
680
681    #[test]
682    fn test_config_priority() {
683        let _lock = ENV_LOCK
684            .lock()
685            .expect("Another test panicked while holding the lock");
686        env::set_var("SQLPAGE_WEB_ROOT", "/");
687
688        let cli = Cli {
689            web_root: Some(PathBuf::from(".")),
690            config_dir: None,
691            config_file: None,
692            command: None,
693        };
694
695        let config = AppConfig::from_cli(&cli).unwrap();
696
697        assert_eq!(
698            config.web_root,
699            PathBuf::from("."),
700            "CLI argument should take precedence over environment variable"
701        );
702
703        env::remove_var("SQLPAGE_WEB_ROOT");
704    }
705
706    #[test]
707    fn test_config_file_priority() {
708        let _lock = ENV_LOCK
709            .lock()
710            .expect("Another test panicked while holding the lock");
711        let temp_dir = std::env::temp_dir().join("sqlpage_test");
712        std::fs::create_dir_all(&temp_dir).unwrap();
713        let config_file_path = temp_dir.join("sqlpage.json");
714        let config_web_dir = temp_dir.join("config/web");
715        let env_web_dir = temp_dir.join("env/web");
716        let cli_web_dir = temp_dir.join("cli/web");
717        std::fs::create_dir_all(&config_web_dir).unwrap();
718        std::fs::create_dir_all(&env_web_dir).unwrap();
719        std::fs::create_dir_all(&cli_web_dir).unwrap();
720
721        let config_content = serde_json::json!({
722            "web_root": config_web_dir.to_str().unwrap()
723        })
724        .to_string();
725        std::fs::write(&config_file_path, config_content).unwrap();
726
727        env::set_var("SQLPAGE_WEB_ROOT", env_web_dir.to_str().unwrap());
728
729        let cli = Cli {
730            web_root: None,
731            config_dir: None,
732            config_file: Some(config_file_path.clone()),
733            command: None,
734        };
735
736        let config = AppConfig::from_cli(&cli).unwrap();
737
738        assert_eq!(
739            config.web_root, env_web_dir,
740            "Environment variable should override config file"
741        );
742        assert_eq!(
743            config.configuration_directory,
744            cannonicalize_if_possible(&PathBuf::from("./sqlpage")),
745            "Configuration directory should be default when not overridden"
746        );
747
748        let cli_with_web_root = Cli {
749            web_root: Some(cli_web_dir.clone()),
750            config_dir: None,
751            config_file: Some(config_file_path),
752            command: None,
753        };
754
755        let config = AppConfig::from_cli(&cli_with_web_root).unwrap();
756        assert_eq!(
757            config.web_root, cli_web_dir,
758            "CLI argument should take precedence over environment variable and config file"
759        );
760        assert_eq!(
761            config.configuration_directory,
762            cannonicalize_if_possible(&PathBuf::from("./sqlpage")),
763            "Configuration directory should remain unchanged"
764        );
765
766        env::remove_var("SQLPAGE_WEB_ROOT");
767        std::fs::remove_dir_all(&temp_dir).unwrap();
768    }
769
770    #[test]
771    fn test_default_values() {
772        let _lock = ENV_LOCK
773            .lock()
774            .expect("Another test panicked while holding the lock");
775        env::remove_var("SQLPAGE_CONFIGURATION_DIRECTORY");
776        env::remove_var("SQLPAGE_WEB_ROOT");
777
778        let cli = Cli {
779            web_root: None,
780            config_dir: None,
781            config_file: None,
782            command: None,
783        };
784
785        let config = AppConfig::from_cli(&cli).unwrap();
786
787        assert_eq!(
788            config.web_root,
789            default_web_root(),
790            "Web root should default to current directory when not specified"
791        );
792        assert_eq!(
793            config.configuration_directory,
794            cannonicalize_if_possible(&PathBuf::from("./sqlpage")),
795            "Configuration directory should default to ./sqlpage when not specified"
796        );
797    }
798}