1468 lines
45 KiB
Rust
1468 lines
45 KiB
Rust
use std::collections::BTreeMap;
|
|
use std::fs;
|
|
use std::path::{Component, Path, PathBuf};
|
|
use std::sync::OnceLock;
|
|
|
|
use serde::Deserialize;
|
|
|
|
mod command;
|
|
mod compilation;
|
|
pub use command::CommandLimits;
|
|
pub use compilation::{CompilationBindings, CompilationSite, DatabaseDriver};
|
|
|
|
const DATA_DIRECTORIES: [&str; 5] = ["db", "upload", "log", "cache", "tmp"];
|
|
const ADAPTER_FIELDS: &[&str] = &["use", "setting", "files", "command"];
|
|
static SETTINGS: OnceLock<Settings> = OnceLock::new();
|
|
pub const MAX_JOB_TIMEOUT_MS: u32 = 3_600_000;
|
|
pub const MAX_JOB_LEASE_MS: i64 = 2 * MAX_JOB_TIMEOUT_MS as i64;
|
|
|
|
pub struct Settings {
|
|
executable_directory: PathBuf,
|
|
dever: Option<DeverSettings>,
|
|
lib: Vec<String>,
|
|
package: Option<PackageSettings>,
|
|
databases: BTreeMap<String, Database>,
|
|
tenant: Option<TenantSettings>,
|
|
http: Option<serde_json::Map<String, serde_json::Value>>,
|
|
adapter: Option<Box<serde_json::value::RawValue>>,
|
|
auth: AuthSettings,
|
|
sites: BTreeMap<String, SiteSettings>,
|
|
log: LogSettings,
|
|
runtime: Option<RuntimeSettings>,
|
|
job: Option<JobSettings>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct PackageSettings {
|
|
registry: String,
|
|
#[serde(default, rename = "use")]
|
|
roots: Vec<String>,
|
|
}
|
|
|
|
impl PackageSettings {
|
|
pub fn registry(&self) -> &str {
|
|
&self.registry
|
|
}
|
|
pub fn roots(&self) -> &[String] {
|
|
&self.roots
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct DeverSettings {
|
|
version: String,
|
|
}
|
|
|
|
impl DeverSettings {
|
|
pub fn version(&self) -> &str {
|
|
&self.version
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct AuthSettings {
|
|
providers: BTreeMap<String, AuthProvider>,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct AuthProvider {
|
|
verify: String,
|
|
#[cfg_attr(not(feature = "api"), allow(dead_code))]
|
|
jwt_secret: crate::secret::Secret,
|
|
ttl_seconds: u64,
|
|
cookie: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct SiteSettings {
|
|
path: String,
|
|
auth: String,
|
|
hosts: Vec<String>,
|
|
origin: Option<HttpOrigin>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct HttpOrigin {
|
|
scheme: String,
|
|
host: String,
|
|
port: u16,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum LogLevel {
|
|
Debug,
|
|
Info,
|
|
Warn,
|
|
Error,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct LogSettings {
|
|
#[serde(default = "default_log_level")]
|
|
pub level: LogLevel,
|
|
}
|
|
|
|
impl Default for LogSettings {
|
|
fn default() -> Self {
|
|
Self {
|
|
level: default_log_level(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum RuntimeMode {
|
|
Api,
|
|
Worker,
|
|
All,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct RuntimeSettings {
|
|
pub mode: RuntimeMode,
|
|
pub shutdown_ms: i64,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct JobSettings {
|
|
pub workers: usize,
|
|
pub poll_ms: i64,
|
|
pub lease_ms: i64,
|
|
pub retry_base_ms: i64,
|
|
pub retry_max_ms: i64,
|
|
}
|
|
|
|
impl JobSettings {
|
|
pub(crate) fn validate_lease(
|
|
&self,
|
|
database: &Database,
|
|
timeout_ms: i64,
|
|
) -> Result<(), String> {
|
|
if !(1..=i64::from(MAX_JOB_TIMEOUT_MS)).contains(&timeout_ms) {
|
|
return Err("invalid Job timeout_ms".into());
|
|
}
|
|
// A spare connection cannot bypass SQLite's single writer. The lease must
|
|
// cover the whole bounded handler even while its transaction blocks renewal.
|
|
if matches!(database, Database::Sqlite { .. }) && self.lease_ms <= timeout_ms {
|
|
return Err("job.lease_ms must exceed Job timeout_ms for SQLite".into());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn validate(self) -> Result<Self, String> {
|
|
if !(1..=256).contains(&self.workers) {
|
|
return Err("job.workers must be between 1 and 256".into());
|
|
}
|
|
for (field, value, minimum, maximum) in [
|
|
("poll_ms", self.poll_ms, 1, 60_000),
|
|
("lease_ms", self.lease_ms, 100, MAX_JOB_LEASE_MS),
|
|
("retry_base_ms", self.retry_base_ms, 1, 86_400_000),
|
|
("retry_max_ms", self.retry_max_ms, 1, 86_400_000),
|
|
] {
|
|
if !(minimum..=maximum).contains(&value) {
|
|
return Err(format!(
|
|
"job.{field} must be between {minimum} and {maximum}"
|
|
));
|
|
}
|
|
}
|
|
if self.retry_base_ms > self.retry_max_ms {
|
|
return Err("job.retry_base_ms exceeds retry_max_ms".into());
|
|
}
|
|
Ok(self)
|
|
}
|
|
|
|
pub fn for_test() -> Self {
|
|
Self {
|
|
workers: 1,
|
|
poll_ms: 1,
|
|
lease_ms: MAX_JOB_LEASE_MS,
|
|
retry_base_ms: 1000,
|
|
retry_max_ms: 300_000,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl AuthSettings {
|
|
pub fn provider(&self, key: &str) -> Option<&AuthProvider> {
|
|
self.providers.get(key)
|
|
}
|
|
}
|
|
|
|
impl AuthProvider {
|
|
pub fn verify(&self) -> &str {
|
|
&self.verify
|
|
}
|
|
pub fn ttl_seconds(&self) -> u64 {
|
|
self.ttl_seconds
|
|
}
|
|
pub fn cookie(&self) -> &str {
|
|
&self.cookie
|
|
}
|
|
#[cfg(feature = "api")]
|
|
pub(crate) fn jwt_secret(&self) -> &crate::secret::Secret {
|
|
&self.jwt_secret
|
|
}
|
|
}
|
|
|
|
impl SiteSettings {
|
|
pub fn path(&self) -> &str {
|
|
&self.path
|
|
}
|
|
pub fn auth(&self) -> &str {
|
|
&self.auth
|
|
}
|
|
pub fn hosts(&self) -> &[String] {
|
|
&self.hosts
|
|
}
|
|
pub fn origin(&self) -> Option<&HttpOrigin> {
|
|
self.origin.as_ref()
|
|
}
|
|
}
|
|
|
|
impl HttpOrigin {
|
|
pub fn scheme(&self) -> &str {
|
|
&self.scheme
|
|
}
|
|
pub fn host(&self) -> &str {
|
|
&self.host
|
|
}
|
|
pub fn port(&self) -> u16 {
|
|
self.port
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct HttpSettings {
|
|
pub host: String,
|
|
pub port: i64,
|
|
pub limits: crate::http::Limits,
|
|
pub upload: UploadLimits,
|
|
pub upload_directory: PathBuf,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Deserialize)]
|
|
#[serde(default, deny_unknown_fields)]
|
|
pub struct UploadLimits {
|
|
pub total_bytes: usize,
|
|
pub file_bytes: usize,
|
|
pub field_bytes: usize,
|
|
pub parts: usize,
|
|
pub fields: usize,
|
|
pub header_bytes: usize,
|
|
pub filename_bytes: usize,
|
|
}
|
|
|
|
impl Default for UploadLimits {
|
|
fn default() -> Self {
|
|
Self {
|
|
total_bytes: 67_108_864,
|
|
file_bytes: 33_554_432,
|
|
field_bytes: 65_536,
|
|
parts: 32,
|
|
fields: 16,
|
|
header_bytes: 8192,
|
|
filename_bytes: 255,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl UploadLimits {
|
|
pub fn validate(self) -> Result<(), String> {
|
|
for (name, value, maximum) in [
|
|
("total_bytes", self.total_bytes, 1_073_741_824),
|
|
("file_bytes", self.file_bytes, self.total_bytes),
|
|
("field_bytes", self.field_bytes, 1_048_576),
|
|
("parts", self.parts, 256),
|
|
("fields", self.fields, self.parts),
|
|
("header_bytes", self.header_bytes, 65_536),
|
|
("filename_bytes", self.filename_bytes, 1024),
|
|
] {
|
|
if value == 0 || value > maximum {
|
|
return Err(format!(
|
|
"http.upload.{name} must be between 1 and {maximum}"
|
|
));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct HttpDocument {
|
|
host: String,
|
|
port: u16,
|
|
#[serde(default = "default_http_header_bytes")]
|
|
header_bytes: i64,
|
|
#[serde(default = "default_http_body_bytes")]
|
|
body_bytes: i64,
|
|
#[serde(default = "default_http_timeout_ms")]
|
|
timeout_ms: i64,
|
|
#[serde(default = "default_http_connections")]
|
|
connections: i64,
|
|
#[serde(default)]
|
|
upload: UploadLimits,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub enum Database {
|
|
Sqlite {
|
|
path: PathBuf,
|
|
tenant_directory: Option<PathBuf>,
|
|
max_connections: usize,
|
|
max_page_size: usize,
|
|
},
|
|
Postgres {
|
|
url: String,
|
|
tenant_database_prefix: Option<String>,
|
|
tls: PostgresTls,
|
|
min_connections: usize,
|
|
max_connections: usize,
|
|
max_page_size: usize,
|
|
wait_timeout_ms: u64,
|
|
io_timeout_ms: u64,
|
|
},
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct TenantSettings {
|
|
database: String,
|
|
max_pools: usize,
|
|
idle_timeout_ms: u64,
|
|
}
|
|
|
|
impl TenantSettings {
|
|
pub fn database(&self) -> &str {
|
|
&self.database
|
|
}
|
|
pub fn max_pools(&self) -> usize {
|
|
self.max_pools
|
|
}
|
|
pub fn idle_timeout_ms(&self) -> u64 {
|
|
self.idle_timeout_ms
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum PostgresTls {
|
|
System,
|
|
Disabled,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
|
pub struct RuntimeProfile {
|
|
pub sqlite: bool,
|
|
pub postgres: bool,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct Document {
|
|
#[serde(default, deserialize_with = "deserialize_present")]
|
|
dever: Option<DeverSettings>,
|
|
#[serde(default)]
|
|
lib: Vec<String>,
|
|
#[serde(default, deserialize_with = "deserialize_present")]
|
|
package: Option<PackageSettings>,
|
|
#[serde(default, deserialize_with = "deserialize_present")]
|
|
runtime: Option<RuntimeSettings>,
|
|
#[serde(default, deserialize_with = "deserialize_present")]
|
|
job: Option<JobSettings>,
|
|
#[serde(default, deserialize_with = "deserialize_adapter")]
|
|
adapter: Option<Box<serde_json::value::RawValue>>,
|
|
#[serde(default)]
|
|
auth: AuthDocument,
|
|
#[serde(default)]
|
|
sites: BTreeMap<String, SiteDocument>,
|
|
#[serde(default)]
|
|
performance: Option<serde_json::Map<String, serde_json::Value>>,
|
|
#[serde(default)]
|
|
log: LogSettings,
|
|
#[serde(default)]
|
|
http: Option<serde_json::Map<String, serde_json::Value>>,
|
|
#[serde(default, deserialize_with = "deserialize_database")]
|
|
database: Option<BTreeMap<String, DatabaseDocument>>,
|
|
#[serde(default, deserialize_with = "deserialize_present")]
|
|
tenant: Option<TenantDocument>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct TenantDocument {
|
|
database: String,
|
|
#[serde(default = "default_tenant_max_pools")]
|
|
max_pools: usize,
|
|
#[serde(default = "default_tenant_idle_timeout_ms")]
|
|
idle_timeout_ms: u64,
|
|
}
|
|
|
|
#[derive(Default, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct AuthDocument {
|
|
#[serde(default)]
|
|
providers: BTreeMap<String, AuthProviderDocument>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct AuthProviderDocument {
|
|
verify: String,
|
|
#[serde(rename = "jwtSecret")]
|
|
jwt_secret: String,
|
|
#[serde(rename = "ttlSeconds", default = "default_auth_ttl_seconds")]
|
|
ttl_seconds: u64,
|
|
#[serde(default = "default_auth_cookie")]
|
|
cookie: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct SiteDocument {
|
|
path: String,
|
|
auth: String,
|
|
#[serde(default)]
|
|
hosts: Vec<String>,
|
|
#[serde(default)]
|
|
origin: Option<String>,
|
|
}
|
|
|
|
fn deserialize_present<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
|
|
where
|
|
D: serde::Deserializer<'de>,
|
|
T: Deserialize<'de>,
|
|
{
|
|
T::deserialize(deserializer).map(Some)
|
|
}
|
|
|
|
fn deserialize_adapter<'de, D>(
|
|
deserializer: D,
|
|
) -> Result<Option<Box<serde_json::value::RawValue>>, D::Error>
|
|
where
|
|
D: serde::Deserializer<'de>,
|
|
{
|
|
// Preserve an explicit null so the shared wire validator rejects it as a wrong type.
|
|
Box::<serde_json::value::RawValue>::deserialize(deserializer).map(Some)
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(tag = "type", rename_all = "lowercase", deny_unknown_fields)]
|
|
enum DatabaseDocument {
|
|
Sqlite {
|
|
path: PathBuf,
|
|
#[serde(default)]
|
|
tenant_directory: Option<PathBuf>,
|
|
#[serde(default = "default_sqlite_connections")]
|
|
max_connections: usize,
|
|
#[serde(default = "default_page_size")]
|
|
max_page_size: usize,
|
|
},
|
|
Postgres {
|
|
url: String,
|
|
#[serde(default)]
|
|
tenant_database_prefix: Option<String>,
|
|
tls: TlsDocument,
|
|
#[serde(default)]
|
|
min_connections: usize,
|
|
#[serde(default = "default_postgres_connections")]
|
|
max_connections: usize,
|
|
#[serde(default = "default_page_size")]
|
|
max_page_size: usize,
|
|
#[serde(default = "default_postgres_wait_timeout_ms")]
|
|
wait_timeout_ms: u64,
|
|
#[serde(default = "default_postgres_io_timeout_ms")]
|
|
io_timeout_ms: u64,
|
|
},
|
|
}
|
|
|
|
#[derive(Clone, Copy, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
enum TlsDocument {
|
|
System,
|
|
Disabled,
|
|
}
|
|
|
|
fn deserialize_database<'de, D>(
|
|
deserializer: D,
|
|
) -> Result<Option<BTreeMap<String, DatabaseDocument>>, D::Error>
|
|
where
|
|
D: serde::Deserializer<'de>,
|
|
{
|
|
BTreeMap::deserialize(deserializer).map(Some)
|
|
}
|
|
|
|
pub fn validate_dever_version(version: &str) -> Result<(), String> {
|
|
let mut parts = version.split('.');
|
|
for _ in 0..3 {
|
|
let part = parts
|
|
.next()
|
|
.ok_or_else(|| "dever.version must be an exact major.minor.patch version".to_owned())?;
|
|
if part.is_empty()
|
|
|| !part.bytes().all(|byte| byte.is_ascii_digit())
|
|
|| (part.len() > 1 && part.starts_with('0'))
|
|
|| part.parse::<u64>().is_err()
|
|
{
|
|
return Err("dever.version must be an exact major.minor.patch version".into());
|
|
}
|
|
}
|
|
if parts.next().is_some() {
|
|
return Err("dever.version must be an exact major.minor.patch version".into());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
impl Settings {
|
|
pub fn load_for_executable() -> Result<Self, String> {
|
|
Self::load(&executable_directory()?)
|
|
}
|
|
|
|
pub fn load_project(project_root: &Path) -> Result<Self, String> {
|
|
Self::load(project_root)
|
|
}
|
|
|
|
fn load(executable_directory: &Path) -> Result<Self, String> {
|
|
let path = executable_directory.join("config/setting.json");
|
|
let text = fs::read_to_string(&path)
|
|
.map_err(|error| format!("cannot read '{}': {error}", path.display()))?;
|
|
crate::wire::parse(&text).map_err(|error| format!("invalid setting.json: {error}"))?;
|
|
let document: Document = serde_json::from_str(&text)
|
|
.map_err(|error| format!("invalid '{}': {error}", path.display()))?;
|
|
let Document {
|
|
dever,
|
|
lib,
|
|
package,
|
|
runtime,
|
|
job,
|
|
adapter,
|
|
auth,
|
|
sites,
|
|
performance,
|
|
log,
|
|
http,
|
|
database,
|
|
tenant,
|
|
} = document;
|
|
if let Some(dever) = &dever {
|
|
validate_dever_version(dever.version())?;
|
|
}
|
|
if let Some(runtime) = runtime
|
|
&& !(1..=300_000).contains(&runtime.shutdown_ms)
|
|
{
|
|
return Err("runtime.shutdown_ms must be between 1 and 300000".into());
|
|
}
|
|
if let Some(job) = job {
|
|
job.validate()?;
|
|
}
|
|
let _ = performance;
|
|
let auth = normalize_auth(auth)?;
|
|
let sites = normalize_sites(sites, &auth)?;
|
|
if database
|
|
.as_ref()
|
|
.is_some_and(|database| !database.contains_key("default"))
|
|
{
|
|
return Err("database.default is required".into());
|
|
}
|
|
let mut databases = BTreeMap::new();
|
|
for (name, database) in database.unwrap_or_default() {
|
|
compilation::validate_database_name(&name)?;
|
|
databases.insert(
|
|
name.clone(),
|
|
normalize_database(&name, database, executable_directory)?,
|
|
);
|
|
}
|
|
let tenant = normalize_tenant(tenant, &databases)?;
|
|
Ok(Self {
|
|
executable_directory: executable_directory.to_path_buf(),
|
|
dever,
|
|
lib,
|
|
package,
|
|
databases,
|
|
tenant,
|
|
http,
|
|
adapter,
|
|
auth,
|
|
sites,
|
|
log,
|
|
runtime,
|
|
job,
|
|
})
|
|
}
|
|
|
|
/// HTTP 配置只在启动 API 时必需;普通程序可保留空 http 对象。
|
|
pub fn http(&self) -> Result<HttpSettings, String> {
|
|
let document = self
|
|
.http
|
|
.clone()
|
|
.ok_or("http.host and http.port are required for dever.api.serve()")?;
|
|
let document: HttpDocument = serde_json::from_value(serde_json::Value::Object(document))
|
|
.map_err(|error| format!("invalid http settings: {error}"))?;
|
|
if document.host.parse::<std::net::IpAddr>().is_err() {
|
|
return Err("http.host must be an explicit IPv4 or IPv6 address".into());
|
|
}
|
|
// 验证上限后再监听;缺省只限制资源,不猜测监听地址或端口。
|
|
for (field, value, minimum, maximum) in [
|
|
("header_bytes", document.header_bytes, 8192, 1_048_576),
|
|
("body_bytes", document.body_bytes, 0, 67_108_864),
|
|
("timeout_ms", document.timeout_ms, 1, 300_000),
|
|
("connections", document.connections, 1, 65_536),
|
|
] {
|
|
if !(minimum..=maximum).contains(&value) {
|
|
return Err(format!(
|
|
"http.{field} must be between {minimum} and {maximum}"
|
|
));
|
|
}
|
|
}
|
|
document.upload.validate()?;
|
|
Ok(HttpSettings {
|
|
host: document.host,
|
|
port: i64::from(document.port),
|
|
upload: document.upload,
|
|
upload_directory: self.executable_directory.join("data/tmp"),
|
|
limits: crate::http::Limits {
|
|
header_bytes: document.header_bytes,
|
|
body_bytes: document.body_bytes,
|
|
timeout_ms: document.timeout_ms,
|
|
connections: document.connections,
|
|
http2: None,
|
|
},
|
|
})
|
|
}
|
|
|
|
pub fn services(&self, api: bool, worker: bool) -> Result<RuntimeSettings, String> {
|
|
let runtime = self.runtime.unwrap_or(RuntimeSettings {
|
|
mode: match (api, worker) {
|
|
(true, true) => RuntimeMode::All,
|
|
(false, true) => RuntimeMode::Worker,
|
|
_ => RuntimeMode::Api,
|
|
},
|
|
shutdown_ms: 30_000,
|
|
});
|
|
if matches!(runtime.mode, RuntimeMode::Api | RuntimeMode::All) {
|
|
if !api {
|
|
return Err("runtime.mode requires an API declaration".into());
|
|
}
|
|
self.http()?;
|
|
}
|
|
if matches!(runtime.mode, RuntimeMode::Worker | RuntimeMode::All) {
|
|
if !worker {
|
|
return Err("runtime.mode requires a Job declaration".into());
|
|
}
|
|
self.job
|
|
.ok_or("job settings are required for worker mode")?
|
|
.validate()?;
|
|
}
|
|
Ok(runtime)
|
|
}
|
|
|
|
pub fn jobs(&self) -> Result<JobSettings, String> {
|
|
self.job
|
|
.ok_or_else(|| "job settings are required".into())
|
|
.and_then(JobSettings::validate)
|
|
}
|
|
|
|
pub fn validate_job_connection(
|
|
&self,
|
|
explicit: Option<&str>,
|
|
root: &str,
|
|
timeout_ms: u32,
|
|
) -> Result<(), String> {
|
|
let settings = self.jobs()?;
|
|
let (name, database) = self.resolve_database(explicit, root)?;
|
|
let capacity = match database {
|
|
Database::Sqlite {
|
|
max_connections, ..
|
|
}
|
|
| Database::Postgres {
|
|
max_connections, ..
|
|
} => *max_connections,
|
|
};
|
|
// Every handler may own a transaction; control and heartbeat retain one spare slot.
|
|
if capacity <= settings.workers {
|
|
return Err(format!(
|
|
"database.{name}.max_connections must exceed job.workers to reserve a lease-control connection"
|
|
));
|
|
}
|
|
settings
|
|
.validate_lease(database, i64::from(timeout_ms))
|
|
.map_err(|error| format!("database.{name}: {error}"))
|
|
}
|
|
|
|
/// Port-only programs may omit the file when every binding is automatic and has no setting.
|
|
pub fn load_adapter_settings() -> Result<Self, String> {
|
|
let directory = executable_directory()?;
|
|
Self::load_adapter_settings_at(&directory)
|
|
}
|
|
|
|
pub(crate) fn load_adapter_settings_at(directory: &Path) -> Result<Self, String> {
|
|
match fs::metadata(directory.join("config/setting.json")) {
|
|
Ok(_) => Self::load(directory),
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Self {
|
|
executable_directory: directory.to_owned(),
|
|
dever: None,
|
|
lib: Vec::new(),
|
|
package: None,
|
|
databases: BTreeMap::new(),
|
|
tenant: None,
|
|
http: None,
|
|
adapter: None,
|
|
auth: AuthSettings::default(),
|
|
sites: BTreeMap::new(),
|
|
log: LogSettings::default(),
|
|
runtime: None,
|
|
job: None,
|
|
}),
|
|
Err(error) => Err(format!("cannot inspect setting.json: {error}")),
|
|
}
|
|
}
|
|
|
|
pub fn validate_adapter_names(&self, identities: &[&str]) -> Result<(), String> {
|
|
if let Some(raw) = &self.adapter {
|
|
crate::wire::parse(raw.get())?.fields(identities)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn select_adapter(
|
|
&self,
|
|
identity: &str,
|
|
candidates: &[&str],
|
|
) -> Result<(usize, Option<String>), String> {
|
|
let node = self
|
|
.adapter
|
|
.as_ref()
|
|
.map(|raw| crate::wire::parse(raw.get()))
|
|
.transpose()?;
|
|
let binding = node
|
|
.as_ref()
|
|
.map(|node| node.object())
|
|
.transpose()?
|
|
.and_then(|fields| fields.get(identity));
|
|
let fields = binding
|
|
.map(|node| node.fields(ADAPTER_FIELDS))
|
|
.transpose()?;
|
|
let selected = fields
|
|
.and_then(|fields| fields.get("use"))
|
|
.map(|node| node.text())
|
|
.transpose()?;
|
|
let index = match selected {
|
|
Some(name) => candidates
|
|
.iter()
|
|
.position(|candidate| *candidate == name)
|
|
.ok_or_else(|| format!("unknown Adapter selection for Port '{identity}'"))?,
|
|
None if candidates.len() == 1 => 0,
|
|
None => return Err(format!("adapter.{identity}.use is required")),
|
|
};
|
|
let setting = fields
|
|
.and_then(|fields| fields.get("setting"))
|
|
.map(|node| node.raw().to_owned());
|
|
Ok((index, setting))
|
|
}
|
|
|
|
/// File access is a deployment grant for the selected Port, not Worker
|
|
/// business settings. Paths stay below the application and appear at /data.
|
|
#[cfg(feature = "external")]
|
|
pub fn external_grants(
|
|
&self,
|
|
identity: &str,
|
|
capabilities: &[String],
|
|
) -> Result<Vec<dever_sandbox::Grant>, String> {
|
|
#[derive(Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct FileGrant {
|
|
path: PathBuf,
|
|
#[serde(default)]
|
|
write: bool,
|
|
}
|
|
let Some(adapter) = &self.adapter else {
|
|
return Ok(Vec::new());
|
|
};
|
|
let node = crate::wire::parse(adapter.get())?;
|
|
let Some(binding) = node.object()?.get(identity) else {
|
|
return Ok(Vec::new());
|
|
};
|
|
let binding = binding.fields(ADAPTER_FIELDS)?;
|
|
let Some(files) = binding.get("files") else {
|
|
return Ok(Vec::new());
|
|
};
|
|
if !capabilities.iter().any(|capability| capability == "file") {
|
|
return Err("Adapter file grants require its checked file capability".into());
|
|
}
|
|
let files: BTreeMap<String, FileGrant> = serde_json::from_str(files.raw())
|
|
.map_err(|_| "Adapter files must map names to {path, write} grants")?;
|
|
if files.len() > 64 {
|
|
return Err("Adapter has too many file grants".into());
|
|
}
|
|
let mut grants = Vec::new();
|
|
for (name, grant) in files {
|
|
if name.is_empty()
|
|
|| name.len() > 64
|
|
|| !name
|
|
.bytes()
|
|
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
|
|
{
|
|
return Err("Adapter file grant names must contain lowercase letters, digits or underscores".into());
|
|
}
|
|
if grant.path.as_os_str().is_empty()
|
|
|| grant.path.is_absolute()
|
|
|| grant
|
|
.path
|
|
.components()
|
|
.any(|part| !matches!(part, Component::Normal(_)))
|
|
{
|
|
return Err("Adapter file grant path must be relative to the application".into());
|
|
}
|
|
let executable_cache = Path::new("data/cache/lib");
|
|
if grant.write
|
|
&& (grant.path.starts_with(executable_cache)
|
|
|| executable_cache.starts_with(&grant.path))
|
|
{
|
|
return Err(
|
|
"Adapter writable file grant overlaps the executable resource cache".into(),
|
|
);
|
|
}
|
|
grants.push(dever_sandbox::Grant {
|
|
source: self.executable_directory.join(grant.path),
|
|
destination: Path::new("/data").join(name),
|
|
writable: grant.write,
|
|
});
|
|
}
|
|
Ok(grants)
|
|
}
|
|
|
|
pub fn initialize_data_directories(&self) -> Result<(), String> {
|
|
for name in DATA_DIRECTORIES {
|
|
let path = self.executable_directory.join("data").join(name);
|
|
fs::create_dir_all(&path)
|
|
.map_err(|error| format!("cannot create '{}': {error}", path.display()))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn database(&self, name: &str) -> Option<&Database> {
|
|
self.databases.get(name)
|
|
}
|
|
|
|
/// The launcher consumes this value before dispatch. Runtime keeps it only so
|
|
/// one strict setting.json contract can also be read by standalone artifacts.
|
|
pub fn dever_version(&self) -> Option<&str> {
|
|
self.dever.as_ref().map(DeverSettings::version)
|
|
}
|
|
|
|
pub fn external_libs(&self) -> &[String] {
|
|
&self.lib
|
|
}
|
|
|
|
pub fn packages(&self) -> Option<&PackageSettings> {
|
|
self.package.as_ref()
|
|
}
|
|
|
|
pub fn tenant(&self) -> Option<&TenantSettings> {
|
|
self.tenant.as_ref()
|
|
}
|
|
|
|
pub fn auth(&self) -> &AuthSettings {
|
|
&self.auth
|
|
}
|
|
|
|
pub fn sites(&self) -> &BTreeMap<String, SiteSettings> {
|
|
&self.sites
|
|
}
|
|
|
|
pub fn log(&self) -> LogSettings {
|
|
self.log
|
|
}
|
|
|
|
pub fn site_for_directory<'a>(
|
|
&'a self,
|
|
directory: &[&str],
|
|
) -> Result<Option<(&'a str, &'a SiteSettings)>, String> {
|
|
compilation::select_site(&self.sites, directory, |site| &site.path)
|
|
}
|
|
|
|
pub fn databases(&self) -> impl Iterator<Item = (&String, &Database)> {
|
|
self.databases.iter()
|
|
}
|
|
|
|
pub fn resolve_database(
|
|
&self,
|
|
explicit: Option<&str>,
|
|
package_root: &str,
|
|
) -> Result<(&str, &Database), String> {
|
|
compilation::resolve_database(&self.databases, explicit, package_root)
|
|
}
|
|
|
|
pub fn profile(&self) -> RuntimeProfile {
|
|
let mut profile = RuntimeProfile::default();
|
|
for database in self.databases.values() {
|
|
match database {
|
|
Database::Sqlite { .. } => profile.sqlite = true,
|
|
Database::Postgres { .. } => profile.postgres = true,
|
|
}
|
|
}
|
|
profile
|
|
}
|
|
|
|
pub fn validate_transaction_bindings(
|
|
&self,
|
|
transactions: &[&[(Option<&str>, &str)]],
|
|
) -> Result<(), String> {
|
|
for transaction in transactions {
|
|
let mut connection = None;
|
|
for (explicit, package_root) in *transaction {
|
|
let name = self.resolve_database(*explicit, package_root)?.0;
|
|
if connection
|
|
.replace(name)
|
|
.is_some_and(|current| current != name)
|
|
{
|
|
return Err("a transaction resolves to multiple database connections".into());
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn validate_scoped_database_bindings(
|
|
&self,
|
|
bindings: &[(Option<&str>, &str, bool)],
|
|
transactions: &[&[(Option<&str>, &str, bool)]],
|
|
) -> Result<(), String> {
|
|
for (explicit, root, tenant_scoped) in bindings {
|
|
let (name, database) = self.resolve_database(*explicit, root)?;
|
|
if self.tenant.is_some() && *tenant_scoped {
|
|
match database {
|
|
Database::Sqlite {
|
|
tenant_directory: Some(_),
|
|
..
|
|
}
|
|
| Database::Postgres {
|
|
tenant_database_prefix: Some(_),
|
|
..
|
|
} => {}
|
|
Database::Sqlite { .. } => {
|
|
return Err(format!(
|
|
"database.{name}.tenant_directory is required by a tenant Model"
|
|
));
|
|
}
|
|
Database::Postgres { .. } => {
|
|
return Err(format!(
|
|
"database.{name}.tenant_database_prefix is required by a tenant Model"
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for transaction in transactions {
|
|
let mut selected: Option<(&str, bool)> = None;
|
|
for (explicit, root, tenant_scoped) in *transaction {
|
|
let name = self.resolve_database(*explicit, root)?.0;
|
|
if let Some((current_name, current_scope)) = selected {
|
|
if current_name != name {
|
|
return Err(
|
|
"a transaction resolves to multiple database connections".into()
|
|
);
|
|
}
|
|
if self.tenant.is_some() && current_scope != *tenant_scoped {
|
|
return Err("a transaction cannot mix global and tenant Models".into());
|
|
}
|
|
} else {
|
|
selected = Some((name, *tenant_scoped));
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn prepare_databases(
|
|
&self,
|
|
bundled: RuntimeProfile,
|
|
bindings: &[(Option<&str>, &str, bool)],
|
|
transactions: &[&[(Option<&str>, &str, bool)]],
|
|
) -> Result<(), String> {
|
|
let actual = self.profile();
|
|
if actual.sqlite && !bundled.sqlite {
|
|
return Err("setting.json requires SQLite support; rebuild the application".into());
|
|
}
|
|
if actual.postgres && !bundled.postgres {
|
|
return Err("setting.json requires PostgreSQL support; rebuild the application".into());
|
|
}
|
|
for (explicit, package_root, _) in bindings {
|
|
self.resolve_database(*explicit, package_root)?;
|
|
}
|
|
self.validate_scoped_database_bindings(bindings, transactions)?;
|
|
self.initialize_data_directories()
|
|
}
|
|
|
|
#[cfg(any(feature = "sqlite", feature = "postgres"))]
|
|
pub(crate) fn validate_database_entry(&self) -> Result<(), String> {
|
|
if self.tenant.is_some() {
|
|
return Err("this application entry does not support tenant database settings".into());
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
pub fn executable_directory() -> Result<PathBuf, String> {
|
|
let executable = std::env::current_exe()
|
|
.map_err(|error| format!("cannot locate the executable: {error}"))?;
|
|
executable
|
|
.parent()
|
|
.map(Path::to_path_buf)
|
|
.ok_or_else(|| "the executable has no parent directory".to_owned())
|
|
}
|
|
|
|
pub fn bootstrap(
|
|
bundled: RuntimeProfile,
|
|
bindings: &[(Option<&str>, &str)],
|
|
transactions: &[&[(Option<&str>, &str)]],
|
|
) -> Result<(), String> {
|
|
let scoped_bindings = bindings
|
|
.iter()
|
|
.map(|(explicit, root)| (*explicit, *root, false))
|
|
.collect::<Vec<_>>();
|
|
let scoped_transactions = transactions
|
|
.iter()
|
|
.map(|transaction| {
|
|
transaction
|
|
.iter()
|
|
.map(|(explicit, root)| (*explicit, *root, false))
|
|
.collect::<Vec<_>>()
|
|
})
|
|
.collect::<Vec<_>>();
|
|
let scoped_transaction_refs = scoped_transactions
|
|
.iter()
|
|
.map(Vec::as_slice)
|
|
.collect::<Vec<_>>();
|
|
bootstrap_scoped(bundled, &scoped_bindings, &scoped_transaction_refs)
|
|
}
|
|
|
|
pub fn bootstrap_scoped(
|
|
bundled: RuntimeProfile,
|
|
bindings: &[(Option<&str>, &str, bool)],
|
|
transactions: &[&[(Option<&str>, &str, bool)]],
|
|
) -> Result<(), String> {
|
|
let settings = Settings::load_for_executable()?;
|
|
settings.prepare_databases(bundled, bindings, transactions)?;
|
|
#[cfg(feature = "sqlite")]
|
|
crate::sqlite::initialize(&settings)?;
|
|
#[cfg(feature = "postgres")]
|
|
crate::postgres::initialize(&settings)?;
|
|
SETTINGS
|
|
.set(settings)
|
|
.map_err(|_| "runtime settings were initialized more than once".to_owned())
|
|
}
|
|
|
|
pub fn settings() -> &'static Settings {
|
|
SETTINGS
|
|
.get()
|
|
.expect("runtime settings are initialized before entry")
|
|
}
|
|
|
|
pub fn try_settings() -> Option<&'static Settings> {
|
|
SETTINGS.get()
|
|
}
|
|
|
|
pub enum CurrentSettings {
|
|
Process(&'static Settings),
|
|
#[cfg(feature = "api")]
|
|
Application(std::sync::Arc<Settings>),
|
|
}
|
|
|
|
impl std::ops::Deref for CurrentSettings {
|
|
type Target = Settings;
|
|
fn deref(&self) -> &Settings {
|
|
match self {
|
|
Self::Process(settings) => settings,
|
|
#[cfg(feature = "api")]
|
|
Self::Application(settings) => settings,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn try_current_settings() -> Option<CurrentSettings> {
|
|
#[cfg(feature = "api")]
|
|
if let Some(session) = crate::application::current() {
|
|
return Some(CurrentSettings::Application(session.settings().clone()));
|
|
}
|
|
SETTINGS.get().map(CurrentSettings::Process)
|
|
}
|
|
|
|
pub fn current_settings() -> CurrentSettings {
|
|
try_current_settings().expect("runtime settings are initialized before entry")
|
|
}
|
|
|
|
pub fn http_settings() -> Result<HttpSettings, String> {
|
|
match try_current_settings() {
|
|
Some(settings) => settings.http(),
|
|
None => Settings::load_for_executable()?.http(),
|
|
}
|
|
}
|
|
|
|
const fn default_http_header_bytes() -> i64 {
|
|
16_384
|
|
}
|
|
|
|
const fn default_http_body_bytes() -> i64 {
|
|
1_048_576
|
|
}
|
|
|
|
const fn default_http_timeout_ms() -> i64 {
|
|
30_000
|
|
}
|
|
|
|
const fn default_http_connections() -> i64 {
|
|
64
|
|
}
|
|
|
|
const fn default_tenant_max_pools() -> usize {
|
|
64
|
|
}
|
|
|
|
const fn default_tenant_idle_timeout_ms() -> u64 {
|
|
300_000
|
|
}
|
|
|
|
fn normalize_database(
|
|
name: &str,
|
|
database: DatabaseDocument,
|
|
executable_directory: &Path,
|
|
) -> Result<Database, String> {
|
|
match database {
|
|
DatabaseDocument::Sqlite {
|
|
path,
|
|
tenant_directory,
|
|
max_connections,
|
|
max_page_size,
|
|
} => {
|
|
validate_limit(name, "max_connections", max_connections, 1, 64)?;
|
|
validate_limit(name, "max_page_size", max_page_size, 1, 10_000)?;
|
|
validate_relative_path(name, &path)?;
|
|
if let Some(directory) = &tenant_directory {
|
|
validate_relative_database_path(name, "tenant_directory", directory)?;
|
|
}
|
|
Ok(Database::Sqlite {
|
|
path: executable_directory.join(path),
|
|
tenant_directory: tenant_directory.map(|path| executable_directory.join(path)),
|
|
max_connections,
|
|
max_page_size,
|
|
})
|
|
}
|
|
DatabaseDocument::Postgres {
|
|
url,
|
|
tenant_database_prefix,
|
|
tls,
|
|
min_connections,
|
|
max_connections,
|
|
max_page_size,
|
|
wait_timeout_ms,
|
|
io_timeout_ms,
|
|
} => {
|
|
if url.is_empty() {
|
|
return Err(format!("database.{name}.url must not be empty"));
|
|
}
|
|
validate_limit(name, "max_connections", max_connections, 1, 256)?;
|
|
if min_connections > max_connections {
|
|
return Err(format!(
|
|
"database.{name}.min_connections must not exceed max_connections"
|
|
));
|
|
}
|
|
validate_limit(name, "max_page_size", max_page_size, 1, 10_000)?;
|
|
validate_duration(name, "wait_timeout_ms", wait_timeout_ms)?;
|
|
validate_duration(name, "io_timeout_ms", io_timeout_ms)?;
|
|
if let Some(prefix) = &tenant_database_prefix
|
|
&& (!valid_name(prefix) || prefix.len() > 43)
|
|
{
|
|
return Err(format!(
|
|
"database.{name}.tenant_database_prefix must use at most 43 lower_snake_case bytes"
|
|
));
|
|
}
|
|
Ok(Database::Postgres {
|
|
url,
|
|
tenant_database_prefix,
|
|
tls: match tls {
|
|
TlsDocument::System => PostgresTls::System,
|
|
TlsDocument::Disabled => PostgresTls::Disabled,
|
|
},
|
|
min_connections,
|
|
max_connections,
|
|
max_page_size,
|
|
wait_timeout_ms,
|
|
io_timeout_ms,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
fn normalize_tenant(
|
|
document: Option<TenantDocument>,
|
|
databases: &BTreeMap<String, Database>,
|
|
) -> Result<Option<TenantSettings>, String> {
|
|
let Some(document) = document else {
|
|
if databases.values().any(|database| match database {
|
|
Database::Sqlite {
|
|
tenant_directory, ..
|
|
} => tenant_directory.is_some(),
|
|
Database::Postgres {
|
|
tenant_database_prefix,
|
|
..
|
|
} => tenant_database_prefix.is_some(),
|
|
}) {
|
|
return Err("tenant database targets require top-level tenant settings".into());
|
|
}
|
|
return Ok(None);
|
|
};
|
|
if !valid_name(&document.database) {
|
|
return Err("tenant.database must use lower_snake_case".into());
|
|
}
|
|
if !databases.contains_key(&document.database) {
|
|
return Err(format!(
|
|
"tenant.database references unknown database '{}'",
|
|
document.database
|
|
));
|
|
}
|
|
if !(1..=1024).contains(&document.max_pools) {
|
|
return Err("tenant.max_pools must be between 1 and 1024".into());
|
|
}
|
|
if !(1_000..=86_400_000).contains(&document.idle_timeout_ms) {
|
|
return Err("tenant.idle_timeout_ms must be between 1000 and 86400000".into());
|
|
}
|
|
Ok(Some(TenantSettings {
|
|
database: document.database,
|
|
max_pools: document.max_pools,
|
|
idle_timeout_ms: document.idle_timeout_ms,
|
|
}))
|
|
}
|
|
|
|
fn validate_limit(
|
|
connection: &str,
|
|
field: &str,
|
|
value: usize,
|
|
minimum: usize,
|
|
maximum: usize,
|
|
) -> Result<(), String> {
|
|
if (minimum..=maximum).contains(&value) {
|
|
Ok(())
|
|
} else {
|
|
Err(format!(
|
|
"database.{connection}.{field} must be between {minimum} and {maximum}"
|
|
))
|
|
}
|
|
}
|
|
|
|
fn validate_relative_path(connection: &str, path: &Path) -> Result<(), String> {
|
|
validate_relative_database_path(connection, "path", path)
|
|
}
|
|
|
|
fn validate_relative_database_path(
|
|
connection: &str,
|
|
field: &str,
|
|
path: &Path,
|
|
) -> Result<(), String> {
|
|
let valid = !path.as_os_str().is_empty()
|
|
&& path
|
|
.components()
|
|
.all(|component| matches!(component, Component::Normal(_) | Component::CurDir));
|
|
if valid {
|
|
Ok(())
|
|
} else {
|
|
Err(format!(
|
|
"database.{connection}.{field} must stay below the executable directory"
|
|
))
|
|
}
|
|
}
|
|
|
|
fn valid_name(name: &str) -> bool {
|
|
name.starts_with(|character: char| character.is_ascii_lowercase())
|
|
&& name.split('_').all(|part| {
|
|
!part.is_empty()
|
|
&& part
|
|
.chars()
|
|
.all(|character| character.is_ascii_lowercase() || character.is_ascii_digit())
|
|
})
|
|
}
|
|
|
|
fn normalize_auth(document: AuthDocument) -> Result<AuthSettings, String> {
|
|
let mut providers = BTreeMap::new();
|
|
for (key, provider) in document.providers {
|
|
compilation::validate_provider(&key, &provider.verify)?;
|
|
if !(32..=4096).contains(&provider.jwt_secret.len()) {
|
|
return Err(format!(
|
|
"auth.providers.{key}.jwtSecret must contain 32 to 4096 bytes"
|
|
));
|
|
}
|
|
if !(60..=604_800).contains(&provider.ttl_seconds) {
|
|
return Err(format!(
|
|
"auth.providers.{key}.ttlSeconds must be between 60 and 604800"
|
|
));
|
|
}
|
|
validate_cookie_token(&provider.cookie)
|
|
.map_err(|error| format!("auth.providers.{key}.cookie {error}"))?;
|
|
providers.insert(
|
|
key,
|
|
AuthProvider {
|
|
verify: provider.verify,
|
|
jwt_secret: crate::secret::Secret::from_input(provider.jwt_secret.into_bytes()),
|
|
ttl_seconds: provider.ttl_seconds,
|
|
cookie: provider.cookie,
|
|
},
|
|
);
|
|
}
|
|
Ok(AuthSettings { providers })
|
|
}
|
|
|
|
fn normalize_sites(
|
|
documents: BTreeMap<String, SiteDocument>,
|
|
auth: &AuthSettings,
|
|
) -> Result<BTreeMap<String, SiteSettings>, String> {
|
|
let mut sites = BTreeMap::new();
|
|
for (key, site) in documents {
|
|
compilation::validate_site(
|
|
&key,
|
|
&site.path,
|
|
&site.auth,
|
|
auth.provider(&site.auth).is_some(),
|
|
)?;
|
|
let origin = site
|
|
.origin
|
|
.as_deref()
|
|
.map(normalize_http_origin)
|
|
.transpose()
|
|
.map_err(|error| format!("sites.{key}.origin: {error}"))?;
|
|
let mut hosts = Vec::with_capacity(site.hosts.len().max(1));
|
|
for host in site.hosts {
|
|
let normalized = normalize_authority_host(&host)
|
|
.map_err(|error| format!("sites.{key}.hosts: {error}"))?;
|
|
if hosts.iter().any(|existing| existing == &normalized) {
|
|
return Err(format!(
|
|
"sites.{key}.hosts contains duplicate host '{normalized}'"
|
|
));
|
|
}
|
|
hosts.push(normalized);
|
|
}
|
|
if let Some(origin) = &origin {
|
|
if hosts.is_empty() {
|
|
hosts.push(origin.host.clone());
|
|
} else if !hosts.iter().any(|host| host == &origin.host) {
|
|
return Err(format!(
|
|
"sites.{key}.origin host must be present in sites.{key}.hosts"
|
|
));
|
|
}
|
|
}
|
|
sites.insert(
|
|
key,
|
|
SiteSettings {
|
|
path: site.path,
|
|
auth: site.auth,
|
|
hosts,
|
|
origin,
|
|
},
|
|
);
|
|
}
|
|
|
|
compilation::validate_site_paths(
|
|
sites
|
|
.iter()
|
|
.map(|(key, site)| (key.as_str(), site.path.as_str())),
|
|
)?;
|
|
Ok(sites)
|
|
}
|
|
|
|
pub(crate) fn normalize_authority_host(authority: &str) -> Result<String, String> {
|
|
let authority = authority
|
|
.parse::<hyper::http::uri::Authority>()
|
|
.map_err(|_| "host must be an HTTP authority without a scheme or path".to_owned())?;
|
|
if authority.as_str().contains('@') {
|
|
return Err("host cannot contain credentials".into());
|
|
}
|
|
Ok(authority.host().to_ascii_lowercase())
|
|
}
|
|
|
|
fn normalize_http_origin(value: &str) -> Result<HttpOrigin, String> {
|
|
let origin = value
|
|
.parse::<hyper::Uri>()
|
|
.map_err(|_| "must be an absolute HTTP or HTTPS origin".to_owned())?;
|
|
let scheme = origin
|
|
.scheme_str()
|
|
.filter(|scheme| matches!(*scheme, "http" | "https"))
|
|
.ok_or_else(|| "scheme must be http or https".to_owned())?;
|
|
if origin.path() != "/" || origin.query().is_some() {
|
|
return Err("must not contain a path, query, or fragment".into());
|
|
}
|
|
let authority = origin
|
|
.authority()
|
|
.ok_or_else(|| "must contain a host".to_owned())?;
|
|
if authority.as_str().contains('@') {
|
|
return Err("cannot contain credentials".into());
|
|
}
|
|
Ok(HttpOrigin {
|
|
scheme: scheme.to_owned(),
|
|
host: authority.host().to_ascii_lowercase(),
|
|
port: authority
|
|
.port_u16()
|
|
.unwrap_or(if scheme == "https" { 443 } else { 80 }),
|
|
})
|
|
}
|
|
|
|
fn validate_cookie_token(name: &str) -> Result<(), String> {
|
|
if name.is_empty()
|
|
|| !name.bytes().all(|byte| {
|
|
byte.is_ascii_alphanumeric()
|
|
|| matches!(
|
|
byte,
|
|
b'!' | b'#'
|
|
..=b'\'' | b'*' | b'+' | b'-' | b'.' | b'^' | b'_' | b'`' | b'|' | b'~'
|
|
)
|
|
})
|
|
{
|
|
return Err("must be a valid Cookie name".into());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
const fn default_auth_ttl_seconds() -> u64 {
|
|
86_400
|
|
}
|
|
|
|
fn default_auth_cookie() -> String {
|
|
"dever_session".into()
|
|
}
|
|
|
|
const fn default_log_level() -> LogLevel {
|
|
LogLevel::Info
|
|
}
|
|
|
|
const fn default_sqlite_connections() -> usize {
|
|
1
|
|
}
|
|
|
|
const fn default_postgres_connections() -> usize {
|
|
4
|
|
}
|
|
|
|
const fn default_page_size() -> usize {
|
|
100
|
|
}
|
|
|
|
const fn default_postgres_wait_timeout_ms() -> u64 {
|
|
5_000
|
|
}
|
|
|
|
const fn default_postgres_io_timeout_ms() -> u64 {
|
|
30_000
|
|
}
|
|
|
|
fn validate_duration(connection: &str, field: &str, value: u64) -> Result<(), String> {
|
|
if (1..=300_000).contains(&value) {
|
|
Ok(())
|
|
} else {
|
|
Err(format!(
|
|
"database.{connection}.{field} must be between 1 and 300000"
|
|
))
|
|
}
|
|
}
|