Something went wrong. Try again.
Monorepo for Tangled
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685use std::fmt;use std::net::SocketAddr;use std::path::{Path, PathBuf};use std::sync::OnceLock;use std::time::Duration;
use base64::Engine;use confique::Config;use knot_runtime::HttpLimits;use knot_types::{AccountDid, AdmissionPolicy, AppviewEndpoint};use url::Url;
#[derive(Debug, Config)]pub struct KnotConfig { #[config(nested)] pub server: ServerConfig, #[config(nested)] pub tls: TlsConfig, #[config(nested)] pub acl: AclConfig, #[config(nested)] pub repo: RepoConfig, #[config(nested)] pub git: GitConfig, #[config(nested)] pub secrets: SecretsConfig, #[config(nested)] pub http: HttpConfig, #[config(nested)] pub atproto: AtprotoConfig, #[config(nested)] pub xrpc: XrpcConfig, #[config(nested)] pub maintenance: MaintenanceConfig, #[config(nested)] pub pack_cache: PackCacheConfig, #[config(nested)] pub pack: PackConfig, #[config(nested)] pub lfs: LfsConfig, #[config(nested)] pub resources: ResourcesConfig, #[config(nested)] pub homepage: HomepageConfig,
#[config(nested)] pub ci: CiConfig, #[config(nested)] pub messages: knot_messages::MessagesConfig,}
#[derive(Debug, Config)]pub struct AclConfig { #[config(env = "KNOT_ADMISSION", default = "closed")] pub admission: AdmissionPolicy,
#[config(env = "KNOT_LEGACY_ADMIN_SECRET_ENV")] pub legacy_admin_secret_env: Option<String>,}
#[derive(Debug, Config)]pub struct TlsConfig { #[config(env = "KNOT_TLS_CERT_PATH")] pub cert_path: Option<PathBuf>,
#[config(env = "KNOT_TLS_KEY_PATH")] pub key_path: Option<PathBuf>,
#[config(env = "KNOT_TLS_HTTP3", default = true)] pub http3: bool,
#[config(env = "KNOT_TLS_ACME_ENABLED", default = false)] pub acme_enabled: bool,
#[config(env = "KNOT_TLS_ACME_CACHE_DIR")] pub acme_cache_dir: Option<PathBuf>,
#[config(env = "KNOT_TLS_ACME_CONTACT")] pub acme_contact: Option<String>,
#[config(env = "KNOT_TLS_ACME_STAGING", default = false)] pub acme_staging: bool,
#[config(env = "KNOT_TLS_MTLS_ENABLED", default = false)] pub mtls_enabled: bool,
#[config(env = "KNOT_TLS_MTLS_CLIENT_CA_PATH")] pub mtls_client_ca_path: Option<PathBuf>,
#[config(env = "KNOT_TLS_MTLS_ADMIN_SPKI_PIN")] pub mtls_admin_spki_pin: Option<String>,}
#[derive(Debug, Config)]pub struct ServerConfig { #[config(env = "KNOT_HOSTNAME")] pub hostname: String,
#[config(env = "KNOT_ADMINS", parse_env = parse_admins)] pub admins: Vec<AccountDid>,
#[config(env = "KNOT_LISTEN_ADDR", default = "[::]:5555")] pub listen_addr: SocketAddr,
#[config(env = "KNOT_LISTEN_HEADER_TIMEOUT_MS", default = 10_000)] pub listen_header_timeout_ms: u64,
#[config(env = "KNOT_LISTEN_IDLE_TIMEOUT_MS", default = 60_000)] pub listen_idle_timeout_ms: u64,
#[config(env = "KNOT_LISTEN_MAX_CONNECTIONS", default = 1_024)] pub listen_max_connections: u32,
#[config(env = "KNOT_LISTEN_RATE_LIMIT_PER_SECOND", default = 50)] pub listen_rate_limit_per_second: u32,
#[config(env = "KNOT_LISTEN_RATE_LIMIT_BURST", default = 200)] pub listen_rate_limit_burst: u32,
#[config(env = "KNOT_LISTEN_MAX_INFLIGHT_REQUESTS", default = 1_024)] pub listen_max_inflight_requests: u32,
#[config(env = "KNOT_LISTEN_REQUEST_TIMEOUT_MS", default = 60_000)] pub listen_request_timeout_ms: u64,
#[config(env = "KNOT_LISTEN_BODY_TIMEOUT_MS", default = 30_000)] pub listen_body_timeout_ms: u64,
#[config(env = "KNOT_LISTEN_WRITE_REQUEST_TIMEOUT_MS", default = 1_800_000)] pub listen_write_request_timeout_ms: u64,
#[config(env = "KNOT_INTERNAL_LISTEN_ADDR", default = "[::1]:5444")] pub internal_listen_addr: SocketAddr,
#[config(env = "KNOT_SSH_LISTEN_ADDR", default = "[::]:2222")] pub ssh_listen_addr: SocketAddr,
#[config(env = "KNOT_SSH_HOST_KEY_FILE")] pub ssh_host_key_file: PathBuf,
#[config(env = "KNOT_SSH_MAX_PACK_BYTES", default = 8_589_934_592u64)] pub ssh_max_pack_bytes: u64,
#[config(env = "KNOT_APPVIEW_ENDPOINT", default = "https://tangled.org")] pub appview_endpoint: AppviewEndpoint,}
#[derive(Debug, Config)]pub struct RepoConfig { #[config(env = "KNOT_SCAN_PATH")] pub scan_path: PathBuf,
#[config(env = "KNOT_DEFAULT_BRANCH", default = "main")] pub default_branch: String,}
#[derive(Debug, Config)]pub struct CiConfig { #[config(env = "KNOT_CI_LOGS_ADDR")] pub logs_addr: Option<String>,}
#[derive(Debug, Config)]pub struct HomepageConfig { #[config(env = "KNOT_HOMEPAGE_ENABLED", default = true)] pub enabled: bool,
#[config(env = "KNOT_HOMEPAGE_PATH")] pub path: Option<PathBuf>,}
#[derive(Debug)]pub enum HomepageSource { Disabled, Default, File(PathBuf),}
impl HomepageConfig { pub fn source(&self) -> HomepageSource { match (self.enabled, self.path.as_ref()) { (false, _) => HomepageSource::Disabled, (true, None) => HomepageSource::Default, (true, Some(path)) => HomepageSource::File(path.clone()), } }}
#[derive(Debug, Config)]pub struct GitConfig { /// Committer identity stamped on merge commits the knot creates. #[config(env = "KNOT_GIT_USER_NAME", default = "Tangled")] pub user_name: String,
#[config(env = "KNOT_GIT_USER_EMAIL", default = "noreply@tangled.sh")] pub user_email: String,
#[config(env = "KNOT_GIT_OBJECT_FORMAT", default = "sha256")] pub object_format: String,}
#[derive(Debug, Config)]pub struct SecretsConfig { #[config(env = "KNOT_SEALED_KEY_FILE")] pub sealed_key_file: PathBuf,
#[config(env = "KNOT_MASTER_KEY_ENV")] pub master_key_env: String,}
#[derive(Debug, Config)]pub struct HttpConfig { #[config(env = "KNOT_HTTP_CONNECT_TIMEOUT_MS", default = 5_000)] pub connect_timeout_ms: u64,
#[config(env = "KNOT_HTTP_READ_TIMEOUT_MS", default = 30_000)] pub read_timeout_ms: u64,
#[config(env = "KNOT_HTTP_REQUEST_TIMEOUT_MS", default = 60_000)] pub request_timeout_ms: u64,
#[config(env = "KNOT_HTTP_MAX_RESPONSE_BYTES", default = 16_777_216)] pub max_response_bytes: u64,}
#[derive(Debug, Config)]pub struct AtprotoConfig { #[config(env = "KNOT_PLC_DIRECTORY")] pub plc_directory: Url,}
#[derive(Debug, Config)]pub struct XrpcConfig { #[config(env = "KNOT_XRPC_MAX_BODY_BYTES", default = 65_536)] pub max_body_bytes: u64,
#[config(env = "KNOT_XRPC_MAX_RESPONSE_BYTES", default = 5_242_880)] pub max_response_bytes: u64,
#[config(env = "KNOT_XRPC_MAX_ARCHIVE_BYTES", default = 1_073_741_824)] pub max_archive_bytes: u64,
#[config(env = "KNOT_XRPC_TREE_LAST_COMMIT_BUDGET_MS", default = 300)] pub tree_last_commit_budget_ms: u64,
#[config(env = "KNOT_XRPC_BLOB_LAST_COMMIT_BUDGET_MS", default = 2_000)] pub blob_last_commit_budget_ms: u64,
#[config(env = "KNOT_XRPC_LANGUAGES_BUDGET_MS", default = 1_000)] pub languages_budget_ms: u64,
#[config(env = "KNOT_XRPC_LANGUAGES_PUSH_BUDGET_MS", default = 2_000)] pub languages_push_budget_ms: u64,
/// Body limit for the merge and mergeCheck procedures, whose patch payloads /// routinely exceed the general XRPC body limit. #[config(env = "KNOT_XRPC_MAX_PATCH_BYTES", default = 16_777_216)] pub max_patch_bytes: u64,
/// Limit on the total decompressed size of a patch the merge procedures parse, /// bounding binary-delta inflation and hunk expansion apart from the /// compressed body limit above. #[config(env = "KNOT_XRPC_MAX_PATCH_DECOMPRESSED_BYTES", default = 134_217_728)] pub max_patch_decompressed_bytes: u64,
#[config(env = "KNOT_XRPC_PREAUTH_BURST", default = 20)] pub preauth_burst: u32,
#[config(env = "KNOT_XRPC_PREAUTH_REFILL_MS", default = 100)] pub preauth_refill_ms: u64,
#[config(env = "KNOT_XRPC_PER_PEER_INFLIGHT", default = 8)] pub per_peer_inflight: u32,
#[config(env = "KNOT_XRPC_GLOBAL_INFLIGHT", default = 64)] pub global_inflight: u32,
#[config(env = "KNOT_XRPC_MAX_PENDING_RESERVATIONS", default = 256)] pub max_pending_reservations: u32,
/// Per-account limit on reserved repository keys awaiting creation, so one /// account cannot consume the whole pending-reservation budget. #[config(env = "KNOT_XRPC_PER_ACTOR_RESERVATIONS", default = 32)] pub per_actor_reservations: u32,
/// How long a reserved repository key is held before it lapses and its /// sealed key is reclaimed, in seconds. #[config(env = "KNOT_XRPC_RESERVATION_TTL_SECS", default = 3600)] pub reservation_ttl_secs: u64,
#[config(env = "KNOT_XRPC_FORK_MAX_PACK_BYTES", default = 1_073_741_824)] pub fork_max_pack_bytes: u64,
#[config(env = "KNOT_XRPC_FORK_FETCH_TIMEOUT_MS", default = 600_000)] pub fork_fetch_timeout_ms: u64,
/// When the knot runs behind a trusted reverse proxy that terminates TLS, /// set this to the header the proxy appends the client address to, for /// example x-forwarded-for. The rightmost entry is used. Leave unset when /// the knot is directly exposed so the socket peer address is used. Only set /// this when a trusted proxy overwrites or appends the header, since a client /// can forge it otherwise. #[config(env = "KNOT_XRPC_TRUSTED_PROXY_HEADER")] pub trusted_proxy_header: Option<String>,
#[config(env = "KNOT_XRPC_EVENTS_REPLAY_BUFFER", default = 4096)] pub events_replay_buffer: u32,
#[config(env = "KNOT_XRPC_EVENTS_REPLAY_BYTES", default = 67_108_864)] pub events_replay_bytes: u64,
#[config(env = "KNOT_XRPC_EVENTS_MAX_SUBSCRIBERS", default = 256)] pub events_max_subscribers: u32,
#[config(env = "KNOT_XRPC_EVENTS_MAX_PER_PEER", default = 8)] pub events_max_per_peer: u32,}
#[derive(Debug, Config)]pub struct MaintenanceConfig { #[config(env = "KNOT_MAINTENANCE_ENABLED", default = true)] pub enabled: bool,
#[config(env = "KNOT_MAINTENANCE_COMMIT_GRAPH", default = true)] pub commit_graph: bool,
#[config(env = "KNOT_MAINTENANCE_MULTI_PACK_INDEX", default = true)] pub multi_pack_index: bool,
#[config(env = "KNOT_MAINTENANCE_BITMAP", default = true)] pub bitmap: bool,
#[config(env = "KNOT_MAINTENANCE_INTERVAL_SECS", default = 21_600)] pub interval_secs: u64,
#[config(env = "KNOT_MAINTENANCE_REPACK_MAX_OBJECTS", default = 16_000_000)] pub repack_max_objects: u64,
#[config(env = "KNOT_MAINTENANCE_REPACK_GEOMETRIC_FACTOR", default = 2)] pub repack_geometric_factor: u64,
#[config(env = "KNOT_MAINTENANCE_PRUNE_GRACE_SECS", default = 1_209_600)] pub prune_grace_secs: u64,
#[config(env = "KNOT_MAINTENANCE_REFLOG_EXPIRE_SECS", default = 7_776_000)] pub reflog_expire_secs: u64,
#[config(env = "KNOT_MAINTENANCE_LARGE_PUSH_BYTES", default = 52_428_800)] pub large_push_bytes: u64,}
#[derive(Debug, Config)]pub struct PackCacheConfig { #[config(env = "KNOT_PACK_CACHE_ENABLED", default = true)] pub enabled: bool,
#[config(env = "KNOT_PACK_CACHE_TTL_SECS", default = 60)] pub ttl_secs: u64,
#[config(env = "KNOT_PACK_CACHE_MAX_ENTRY_BYTES", default = 67_108_864)] pub max_entry_bytes: u64,
#[config(env = "KNOT_PACK_CACHE_MAX_TOTAL_BYTES", default = 2_147_483_648u64)] pub max_total_bytes: u64,}
#[derive(Debug, Config)]pub struct PackConfig { #[config(env = "KNOT_PACK_MAX_OBJECTS", default = 16_000_000)] pub max_objects: u32,
#[config(env = "KNOT_PACK_MAX_TOTAL_BYTES", default = 68_719_476_736u64)] pub max_total_bytes: u64,
#[config(env = "KNOT_PACK_SELECTION_MAX_OBJECTS", default = 16_000_000)] pub selection_max_objects: u32,
#[config(env = "KNOT_PACK_SELECTION_TIME_BUDGET_SECS", default = 600)] pub selection_time_budget_secs: u64,}
#[derive(Debug, Config)]pub struct LfsConfig { #[config(env = "KNOT_LFS_STORE_PATH")] pub store_path: Option<PathBuf>,
#[config(env = "KNOT_LFS_MAX_OBJECT_BYTES", default = 5_368_709_120u64)] pub max_object_bytes: u64,
#[config(env = "KNOT_LFS_FREE_SPACE_FLOOR_BYTES", default = 1_073_741_824u64)] pub free_space_floor_bytes: u64,
#[config(env = "KNOT_LFS_GC_GRACE_SECS", default = 1_209_600)] pub gc_grace_secs: u64,
#[config(env = "KNOT_LFS_GC_INTERVAL_SECS", default = 21_600)] pub gc_interval_secs: u64,
#[config(env = "KNOT_LFS_MAX_SSH_TRANSFERS", default = 16)] pub max_ssh_transfers: u32,
#[config(env = "KNOT_LFS_MAX_HTTP_DOWNLOADS", default = 64)] pub max_http_downloads: u32,}
#[derive(Debug, Config)]pub struct ResourcesConfig { #[config(env = "KNOT_MAX_THREADS", default = 0)] pub max_threads: u32,
#[config(env = "KNOT_MAX_MEMORY_BYTES", default = 0)] pub max_memory_bytes: u64,}
fn parse_admins(raw: &str) -> Result<Vec<AccountDid>, knot_types::ParseError> { raw.split(',') .map(str::trim) .filter(|item| !item.is_empty()) .map(AccountDid::new) .collect()}
impl KnotConfig { pub fn object_format(&self) -> Option<knot_types::ObjectFormat> { knot_types::ObjectFormat::from_capability(&self.git.object_format) }
pub fn tls_enabled(&self) -> bool { self.static_cert_enabled() || self.tls.acme_enabled }
pub fn static_cert_enabled(&self) -> bool { self.tls.cert_path.is_some() && self.tls.key_path.is_some() }
pub fn http_limits(&self) -> HttpLimits { HttpLimits { connect_timeout: Duration::from_millis(self.http.connect_timeout_ms), read_timeout: Duration::from_millis(self.http.read_timeout_ms), request_timeout: Duration::from_millis(self.http.request_timeout_ms), max_response_bytes: self.http.max_response_bytes, block_private_addresses: true, } }
pub fn fork_http_limits(&self) -> HttpLimits { HttpLimits { connect_timeout: Duration::from_millis(self.http.connect_timeout_ms), read_timeout: Duration::from_millis(self.http.read_timeout_ms), request_timeout: Duration::from_millis(self.xrpc.fork_fetch_timeout_ms), max_response_bytes: self .xrpc .fork_max_pack_bytes .saturating_add(self.xrpc.fork_max_pack_bytes / 64) .saturating_add(1_048_576), block_private_addresses: true, } }
pub fn validate(&self) -> Result<(), ConfigError> { let errors: Vec<String> = [ check( !self.server.hostname.is_empty(), "server.hostname mustn't be empty", ), knot_messages::Catalog::parse(&self.messages) .err() .map(|error| error.to_string()), knot_types::KnotHostname::new(self.server.hostname.clone()) .err() .map(|_| "server.hostname isn't a valid bare hostname".to_string()), check( !self.server.admins.is_empty(), "server.admins must list at least one DID", ), check( self.repo.scan_path.is_absolute(), "repo.scan_path must be absolute path", ), check( self.secrets.sealed_key_file.is_absolute(), "secrets.sealed_key_file must be absolute path", ), check( self.server.ssh_host_key_file.is_absolute(), "server.ssh_host_key_file must be absolute path", ), check( self.tls.cert_path.is_some() == self.tls.key_path.is_some(), "tls.cert_path and tls.key_path must both be set or both unset", ), self.tls .cert_path .as_ref() .filter(|path| !path.is_absolute()) .map(|_| "tls.cert_path must be absolute path".to_string()), self.tls .key_path .as_ref() .filter(|path| !path.is_absolute()) .map(|_| "tls.key_path must be absolute path".to_string()), check( !(self.tls.acme_enabled && self.static_cert_enabled()), "tls.acme_enabled cannot combine with a static tls.cert_path and tls.key_path", ), check( !self.tls.acme_enabled || self.tls.acme_cache_dir.is_some(), "tls.acme_cache_dir is required when tls.acme_enabled is set", ), self.tls .acme_cache_dir .as_ref() .filter(|path| !path.is_absolute()) .map(|_| "tls.acme_cache_dir must be absolute path".to_string()), check( !self.tls.acme_enabled || self .tls .acme_contact .as_deref() .is_some_and(is_contact_email), "tls.acme_contact must be a contact email when tls.acme_enabled is set", ), check( !self.tls.mtls_enabled || self.tls_enabled(), "tls.mtls_enabled requires a server certificate via static paths or ACME", ), check( !self.tls.mtls_enabled || self.tls.mtls_client_ca_path.is_some(), "tls.mtls_client_ca_path is required when tls.mtls_enabled is set", ), self.tls .mtls_client_ca_path .as_ref() .filter(|path| !path.is_absolute()) .map(|_| "tls.mtls_client_ca_path must be absolute path".to_string()), check( !self.tls.mtls_enabled || self .tls .mtls_admin_spki_pin .as_deref() .is_some_and(is_spki_pin), "tls.mtls_admin_spki_pin must be a base64 SHA-256 pin when tls.mtls_enabled is set", ), check( is_env_var_name(&self.secrets.master_key_env), "secrets.master_key_env must be valid environment variable name", ), check( self.server.ssh_max_pack_bytes > 0, "server.ssh_max_pack_bytes must be greater than zero", ), check( self.server.listen_header_timeout_ms > 0, "server.listen_header_timeout_ms must be greater than zero", ), check( self.server.listen_idle_timeout_ms > 0, "server.listen_idle_timeout_ms must be greater than zero", ), check( self.server.listen_idle_timeout_ms >= self.server.listen_header_timeout_ms, "server.listen_idle_timeout_ms must be at least server.listen_header_timeout_ms", ), check( self.server.listen_max_connections > 0, "server.listen_max_connections must be greater than zero", ), check( self.server.listen_rate_limit_per_second > 0, "server.listen_rate_limit_per_second must be greater than zero", ), check( self.server.listen_rate_limit_burst > 0, "server.listen_rate_limit_burst must be greater than zero", ), check( self.server.listen_max_inflight_requests > 0, "server.listen_max_inflight_requests must be greater than zero", ), check( self.server.listen_request_timeout_ms > 0, "server.listen_request_timeout_ms must be greater than zero", ), check( self.server.listen_body_timeout_ms > 0, "server.listen_body_timeout_ms must be greater than zero", ), check( self.server.listen_write_request_timeout_ms > 0, "server.listen_write_request_timeout_ms must be greater than zero", ), knot_types::RefName::new(format!("refs/heads/{}", self.repo.default_branch)) .err() .map(|_| "repo.default_branch isn't valid branch name".to_string()), check( self.http.connect_timeout_ms > 0, "http.connect_timeout_ms must be greater than zero", ), check( self.http.read_timeout_ms > 0, "http.read_timeout_ms must be greater than zero", ), check( self.http.request_timeout_ms > 0, "http.request_timeout_ms must be greater than zero", ), check( self.http.max_response_bytes > 0, "http.max_response_bytes must be greater than zero", ), check( self.atproto.plc_directory.scheme() == "https", "atproto.plc_directory must be https URL", ), check( self.atproto.plc_directory.host().is_some(), "atproto.plc_directory must have host", ), check( self.xrpc.max_body_bytes > 0, "xrpc.max_body_bytes must be greater than zero", ), check( self.xrpc.max_response_bytes > 0, "xrpc.max_response_bytes must be greater than zero", ), check( self.xrpc.max_archive_bytes > 0, "xrpc.max_archive_bytes must be greater than zero", ), check( self.xrpc.tree_last_commit_budget_ms > 0, "xrpc.tree_last_commit_budget_ms must be greater than zero", ), check( self.xrpc.blob_last_commit_budget_ms > 0, "xrpc.blob_last_commit_budget_ms must be greater than zero", ), check( self.xrpc.languages_budget_ms > 0, "xrpc.languages_budget_ms must be greater than zero", ), check( self.xrpc.languages_push_budget_ms > 0, "xrpc.languages_push_budget_ms must be greater than zero", ), check( self.xrpc.max_patch_bytes > 0, "xrpc.max_patch_bytes must be greater than zero", ), check( self.xrpc.max_patch_decompressed_bytes > 0, "xrpc.max_patch_decompressed_bytes must be greater than zero", ), check( self.xrpc.fork_max_pack_bytes > 0, "xrpc.fork_max_pack_bytes must be greater than zero", ), check( self.xrpc.fork_fetch_timeout_ms > 0, "xrpc.fork_fetch_timeout_ms must be greater than zero", ), check( !self.git.user_name.trim().is_empty(), "git.user_name mustn't be empty", ), check( !self.git.user_email.trim().is_empty(), "git.user_email mustn't be empty", ), check( self.object_format().is_some(), "git.object_format must be \"sha1\" or \"sha256\"", ), check( self.xrpc.preauth_burst > 0, "xrpc.preauth_burst must be greater than zero", ), check( self.xrpc.preauth_refill_ms > 0, "xrpc.preauth_refill_ms must be greater than zero", ), check( self.xrpc.per_peer_inflight > 0, "xrpc.per_peer_inflight must be greater than zero", ), check( self.xrpc.global_inflight >= self.xrpc.per_peer_inflight, "xrpc.global_inflight must be at least xrpc.per_peer_inflight", ), check( self.xrpc.per_actor_reservations > 0, "xrpc.per_actor_reservations must be greater than zero", ), check( self.xrpc.max_pending_reservations >= self.xrpc.per_actor_reservations, "xrpc.max_pending_reservations must be at least xrpc.per_actor_reservations", ), check( self.xrpc.reservation_ttl_secs > 0, "xrpc.reservation_ttl_secs must be greater than zero", ), check( self.xrpc.events_replay_buffer > 0, "xrpc.events_replay_buffer must be greater than zero", ), check( self.xrpc.events_replay_bytes > 0, "xrpc.events_replay_bytes must be greater than zero", ), check( self.xrpc.events_max_subscribers > 0, "xrpc.events_max_subscribers must be greater than zero", ), check( self.xrpc.events_max_per_peer > 0, "xrpc.events_max_per_peer must be greater than zero", ), check( self.xrpc.events_max_subscribers >= self.xrpc.events_max_per_peer, "xrpc.events_max_subscribers must be at least xrpc.events_max_per_peer", ), check( self.maintenance.interval_secs > 0, "maintenance.interval_secs must be greater than zero", ), check( self.maintenance.repack_max_objects > 0, "maintenance.repack_max_objects must be greater than zero", ), check( self.maintenance.repack_geometric_factor >= 2, "maintenance.repack_geometric_factor must be at least 2", ), check( self.maintenance.large_push_bytes > 0, "maintenance.large_push_bytes must be greater than zero", ), check( self.pack_cache.ttl_secs > 0, "pack_cache.ttl_secs must be greater than zero", ), check( self.pack_cache.max_entry_bytes > 0, "pack_cache.max_entry_bytes must be greater than zero", ), check( self.pack_cache.max_total_bytes > 0, "pack_cache.max_total_bytes must be greater than zero", ), check( self.pack_cache.max_total_bytes >= self.pack_cache.max_entry_bytes, "pack_cache.max_total_bytes must be at least pack_cache.max_entry_bytes", ), check( self.pack.max_objects > 0, "pack.max_objects must be greater than zero", ), check( self.pack.max_total_bytes > 0, "pack.max_total_bytes must be greater than zero", ), check( self.pack.selection_max_objects > 0, "pack.selection_max_objects must be greater than zero", ), check( self.pack.selection_time_budget_secs > 0, "pack.selection_time_budget_secs must be greater than zero", ), self.lfs .store_path .as_ref() .filter(|path| !path.is_absolute()) .map(|_| "lfs.store_path must be absolute path".to_string()), self.lfs .store_path .as_ref() .filter(|path| { path.starts_with(&self.repo.scan_path) || self.repo.scan_path.starts_with(path) }) .map(|_| "lfs.store_path mustn't overlap repo.scan_path".to_string()), check( self.lfs.max_object_bytes > 0, "lfs.max_object_bytes must be greater than zero", ), check( self.lfs.gc_interval_secs > 0, "lfs.gc_interval_secs must be greater than zero", ), check( self.lfs.max_ssh_transfers > 0, "lfs.max_ssh_transfers must be greater than zero", ), check( self.lfs.max_http_downloads > 0, "lfs.max_http_downloads must be greater than zero", ), self.xrpc .trusted_proxy_header .as_ref() .filter(|header| !is_http_token(header)) .map(|_| "xrpc.trusted_proxy_header isn't valid HTTP header name".to_string()), self.acl .legacy_admin_secret_env .as_deref() .filter(|name| !is_env_var_name(name)) .map(|_| { "acl.legacy_admin_secret_env must be valid environment variable name" .to_string() }), match self.homepage.source() { HomepageSource::File(path) if !path.is_absolute() => { Some("homepage.path must be absolute path".to_string()) } _ => None, }, ] .into_iter() .flatten() .chain(self.port_collisions()) .collect();
if errors.is_empty() { Ok(()) } else { Err(ConfigError { errors }) } }
fn port_collisions(&self) -> Vec<String> { let binds = [ ("server.listen_addr", self.server.listen_addr), ( "server.internal_listen_addr", self.server.internal_listen_addr, ), ("server.ssh_listen_addr", self.server.ssh_listen_addr), ]; [(0, 1), (0, 2), (1, 2)] .into_iter() .filter(|&(a, b)| binds[a].1.port() == binds[b].1.port()) .map(|(a, b)| { format!( "{} and {} cannot bind same port {}", binds[a].0, binds[b].0, binds[a].1.port() ) }) .collect() }}
fn check(ok: bool, message: &str) -> Option<String> { (!ok).then(|| message.to_string())}
fn is_http_token(value: &str) -> bool { !value.is_empty() && value .bytes() .all(|byte| byte.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&byte))}
fn is_contact_email(value: &str) -> bool { let mut parts = value.splitn(2, '@'); matches!( (parts.next(), parts.next()), (Some(local), Some(domain)) if !local.is_empty() && domain.contains('.') && !domain.starts_with('.') && !domain.ends_with('.') && !value.chars().any(char::is_whitespace) )}
fn is_spki_pin(value: &str) -> bool { base64::engine::general_purpose::STANDARD .decode(value) .is_ok_and(|bytes| bytes.len() == 32)}
fn is_env_var_name(value: &str) -> bool { !value.is_empty() && !value.starts_with(|c: char| c.is_ascii_digit()) && value .chars() .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')}
#[derive(Debug, thiserror::Error)]pub struct ConfigError { pub errors: Vec<String>,}
impl fmt::Display for ConfigError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { writeln!(f, "{} configuration problem(s):", self.errors.len())?; self.errors .iter() .try_for_each(|error| writeln!(f, " - {error}")) }}
pub struct Validated(KnotConfig);
impl Validated { pub fn verify_environment(&self) -> Result<(), EnvError> { verify_writable_dir("repo.scan_path", &self.0.repo.scan_path)?; self.0 .lfs .store_path .as_deref() .map_or(Ok(()), |path| verify_writable_dir("lfs.store_path", path))?; verify_homepage(self.0.homepage.source())?; verify_master_key(&self.0.secrets.master_key_env) }
pub fn into_inner(self) -> KnotConfig { self.0 }}
impl std::ops::Deref for Validated { type Target = KnotConfig;
fn deref(&self) -> &Self::Target { &self.0 }}
#[derive(Debug, thiserror::Error)]pub enum EnvError { #[error("{field} {path} isn't accessible")] DirInaccessible { field: &'static str, path: PathBuf, #[source] source: std::io::Error, }, #[error("{field} {path} isn't directory")] DirNotDir { field: &'static str, path: PathBuf }, #[error("{field} {path} isn't writable")] DirNotWritable { field: &'static str, path: PathBuf, #[source] source: std::io::Error, }, #[error("homepage.path {path} isn't accessible")] HomepageInaccessible { path: PathBuf, #[source] source: std::io::Error, }, #[error("homepage.path {path} isn't a regular file")] HomepageNotFile { path: PathBuf }, #[error("master key env var {name} isn't set")] MasterKeyUnset { name: String }, #[error("master key env var {name} is empty")] MasterKeyEmpty { name: String }, #[error("master key env var {name} isn't valid base64")] MasterKeyNotBase64 { name: String, #[source] source: base64::DecodeError, }, #[error( "master key env var {name} decodes to only {len} of minimum {MASTER_KEY_MIN_BYTES} bytes" )] MasterKeyTooShort { name: String, len: usize },}
const MASTER_KEY_MIN_BYTES: usize = 32;
fn verify_writable_dir(field: &'static str, path: &Path) -> Result<(), EnvError> { let metadata = std::fs::metadata(path).map_err(|source| EnvError::DirInaccessible { field, path: path.to_path_buf(), source, })?; if !metadata.is_dir() { return Err(EnvError::DirNotDir { field, path: path.to_path_buf(), }); } tempfile::Builder::new() .prefix(".knot-write-probe") .tempfile_in(path) .map(drop) .map_err(|source| EnvError::DirNotWritable { field, path: path.to_path_buf(), source, })}
fn verify_homepage(source: HomepageSource) -> Result<(), EnvError> { let HomepageSource::File(path) = source else { return Ok(()); }; let file = std::fs::File::open(&path).map_err(|source| EnvError::HomepageInaccessible { path: path.clone(), source, })?; let is_file = file .metadata() .map_err(|source| EnvError::HomepageInaccessible { path: path.clone(), source, })? .is_file(); is_file .then_some(()) .ok_or(EnvError::HomepageNotFile { path })}
fn verify_master_key(name: &str) -> Result<(), EnvError> { let value = std::env::var(name).ok(); validate_master_key(name, value.as_deref())}
fn validate_master_key(name: &str, value: Option<&str>) -> Result<(), EnvError> { let raw = value.ok_or_else(|| EnvError::MasterKeyUnset { name: name.to_string(), })?; let trimmed = raw.trim(); if trimmed.is_empty() { return Err(EnvError::MasterKeyEmpty { name: name.to_string(), }); } let decoded = base64::engine::general_purpose::STANDARD .decode(trimmed) .map_err(|source| EnvError::MasterKeyNotBase64 { name: name.to_string(), source, })?; (decoded.len() >= MASTER_KEY_MIN_BYTES) .then_some(()) .ok_or(EnvError::MasterKeyTooShort { name: name.to_string(), len: decoded.len(), })}
static CONFIG: OnceLock<KnotConfig> = OnceLock::new();
#[derive(Debug, thiserror::Error)]pub enum LoadError { #[error("config file not found: {0}")] Missing(PathBuf), #[error(transparent)] Confique(#[from] confique::Error), #[error(transparent)] Invalid(#[from] ConfigError),}
pub fn load(path: Option<&Path>) -> Result<Validated, LoadError> { if let Some(path) = path && !path.exists() { return Err(LoadError::Missing(path.to_path_buf())); } let mut builder = KnotConfig::builder().env(); if let Some(path) = path { builder = builder.file(path); } let config = builder.file("/etc/knot/config.toml").load()?; config.validate()?; Ok(Validated(config))}
pub fn template() -> String { confique::toml::template::<KnotConfig>(confique::toml::FormatOptions::default())}
pub fn init(config: Validated) { CONFIG .set(config.into_inner()) .expect("knot-config: configuration already initialized");}
pub fn get() -> &'static KnotConfig { CONFIG .get() .expect("knot-config: not initialized, call knot_config::init first")}
pub fn try_get() -> Option<&'static KnotConfig> { CONFIG.get()}
#[cfg(test)]mod tests { use super::*;
#[test] fn example_toml_is_the_generated_template() { assert_eq!( template(), include_str!("../../../example.toml"), "regenerate example.toml with `just gen-config` after changing config" ); }
fn sample() -> KnotConfig { KnotConfig { server: ServerConfig { hostname: "oyster.cafe".to_string(), admins: vec![AccountDid::new("did:plc:nel").unwrap()], listen_addr: "[::]:5555".parse().unwrap(), listen_header_timeout_ms: 10_000, listen_idle_timeout_ms: 60_000, listen_max_connections: 1_024, listen_rate_limit_per_second: 50, listen_rate_limit_burst: 200, listen_max_inflight_requests: 1_024, listen_request_timeout_ms: 60_000, listen_body_timeout_ms: 30_000, listen_write_request_timeout_ms: 1_800_000, internal_listen_addr: "[::1]:5444".parse().unwrap(), ssh_listen_addr: "[::]:2222".parse().unwrap(), ssh_host_key_file: PathBuf::from("/var/lib/knot/ssh_host_key"), ssh_max_pack_bytes: 8_589_934_592, appview_endpoint: AppviewEndpoint::new("https://tangled.org").unwrap(), }, tls: TlsConfig { cert_path: None, key_path: None, http3: true, acme_enabled: false, acme_cache_dir: None, acme_contact: None, acme_staging: false, mtls_enabled: false, mtls_client_ca_path: None, mtls_admin_spki_pin: None, }, acl: AclConfig { admission: AdmissionPolicy::Closed, legacy_admin_secret_env: None, }, repo: RepoConfig { scan_path: PathBuf::from("/srv/git"), default_branch: "main".to_string(), }, git: GitConfig { user_name: "Tangled".to_string(), user_email: "noreply@tangled.sh".to_string(), object_format: "sha1".to_string(), }, secrets: SecretsConfig { sealed_key_file: PathBuf::from("/var/lib/knot/keys.sealed"), master_key_env: "KNOT_MASTER_KEY".to_string(), }, http: HttpConfig { connect_timeout_ms: 5_000, read_timeout_ms: 30_000, request_timeout_ms: 60_000, max_response_bytes: 16_777_216, }, atproto: AtprotoConfig { plc_directory: Url::parse("https://plc.nel.pet/").unwrap(), }, xrpc: XrpcConfig { max_body_bytes: 65_536, max_response_bytes: 5_242_880, max_archive_bytes: 1_073_741_824, tree_last_commit_budget_ms: 300, blob_last_commit_budget_ms: 2_000, languages_budget_ms: 1_000, languages_push_budget_ms: 2_000, max_patch_bytes: 16_777_216, max_patch_decompressed_bytes: 134_217_728, preauth_burst: 20, preauth_refill_ms: 100, per_peer_inflight: 8, global_inflight: 64, max_pending_reservations: 256, per_actor_reservations: 32, reservation_ttl_secs: 3_600, fork_max_pack_bytes: 1_073_741_824, fork_fetch_timeout_ms: 600_000, trusted_proxy_header: None, events_replay_buffer: 4_096, events_replay_bytes: 67_108_864, events_max_subscribers: 256, events_max_per_peer: 8, }, maintenance: MaintenanceConfig { enabled: true, commit_graph: true, multi_pack_index: true, bitmap: true, interval_secs: 21_600, repack_max_objects: 16_000_000, repack_geometric_factor: 2, prune_grace_secs: 1_209_600, reflog_expire_secs: 7_776_000, large_push_bytes: 52_428_800, }, pack_cache: PackCacheConfig { enabled: true, ttl_secs: 60, max_entry_bytes: 67_108_864, max_total_bytes: 536_870_912, }, pack: PackConfig { max_objects: 16_000_000, max_total_bytes: 68_719_476_736, selection_max_objects: 16_000_000, selection_time_budget_secs: 600, }, lfs: LfsConfig { store_path: None, max_object_bytes: 5_368_709_120, free_space_floor_bytes: 1_073_741_824, gc_grace_secs: 1_209_600, gc_interval_secs: 21_600, max_ssh_transfers: 16, max_http_downloads: 64, }, resources: ResourcesConfig { max_threads: 0, max_memory_bytes: 0, }, messages: knot_messages::MessagesConfig::defaults(), homepage: HomepageConfig { enabled: true, path: None, }, ci: CiConfig { logs_addr: Some("logs.oyster.cafe:3333".to_string()), }, } }
fn apply_acme(config: &mut KnotConfig) { config.tls.acme_enabled = true; config.tls.acme_cache_dir = Some(PathBuf::from("/var/lib/knot/acme")); config.tls.acme_contact = Some("nel@oyster.cafe".to_string()); }
fn apply_mtls(config: &mut KnotConfig) { config.tls.cert_path = Some(PathBuf::from("/etc/knot/tls/cert.pem")); config.tls.key_path = Some(PathBuf::from("/etc/knot/tls/key.pem")); config.tls.mtls_enabled = true; config.tls.mtls_client_ca_path = Some(PathBuf::from("/etc/knot/tls/admin-ca.pem")); config.tls.mtls_admin_spki_pin = Some(base64::engine::general_purpose::STANDARD.encode([7u8; 32])); }
#[test] fn accepts_valid_config() { type Case = (&'static str, fn(&mut KnotConfig), bool, bool); let cases: &[Case] = &[ ("valid_config_passes", |_| {}, false, false), ( "matched_absolute_tls_paths", |config| { config.tls.cert_path = Some(PathBuf::from("/etc/knot/tls/cert.pem")); config.tls.key_path = Some(PathBuf::from("/etc/knot/tls/key.pem")); }, true, true, ), ("acme_enables_tls", apply_acme, true, false), ("mtls_with_server_cert_and_pin", apply_mtls, true, true), ( "an_immediate_prune_grace", |config| config.maintenance.prune_grace_secs = 0, false, false, ), ( "a_legacy_admin_secret_env_var", |config| { config.acl.legacy_admin_secret_env = Some("KNOT_LEGACY_ADMIN_SECRET".to_string()) }, false, false, ), ]; cases .iter() .for_each(|(label, mutate, tls_enabled, static_cert)| { let mut config = sample(); mutate(&mut config); assert!(config.validate().is_ok(), "{label}"); assert_eq!(config.tls_enabled(), *tls_enabled, "{label} tls_enabled"); assert_eq!( config.static_cert_enabled(), *static_cert, "{label} static_cert_enabled" ); }); }
#[test] fn rejects_invalid_config() { type Case = (&'static str, fn(&mut KnotConfig), &'static str); let cases: &[Case] = &[ ( "a_cert_without_a_key", |config| config.tls.cert_path = Some(PathBuf::from("/etc/knot/tls/cert.pem")), "both be set or both unset", ), ( "a_relative_cert_path", |config| { config.tls.cert_path = Some(PathBuf::from("tls/cert.pem")); config.tls.key_path = Some(PathBuf::from("tls/key.pem")); }, "tls.cert_path", ), ( "acme_cannot_combine_with_static", |config| { apply_acme(config); config.tls.cert_path = Some(PathBuf::from("/etc/knot/tls/cert.pem")); config.tls.key_path = Some(PathBuf::from("/etc/knot/tls/key.pem")); }, "cannot combine", ), ( "acme_without_a_cache_dir", |config| { apply_acme(config); config.tls.acme_cache_dir = None; }, "tls.acme_cache_dir is required", ), ( "acme_without_a_valid_contact", |config| { apply_acme(config); config.tls.acme_contact = Some("not-an-email".to_string()); }, "tls.acme_contact", ), ( "mtls_without_a_server_certificate", |config| { apply_mtls(config); config.tls.cert_path = None; config.tls.key_path = None; }, "tls.mtls_enabled requires a server certificate", ), ( "mtls_with_a_malformed_pin", |config| { apply_mtls(config); config.tls.mtls_admin_spki_pin = Some(base64::engine::general_purpose::STANDARD.encode([0u8; 16])); }, "tls.mtls_admin_spki_pin", ), ( "empty_admin_list", |config| config.server.admins = Vec::new(), "admins", ), ( "a_malformed_legacy_admin_secret_env_var_name", |config| config.acl.legacy_admin_secret_env = Some("9_NOT_A_VAR".to_string()), "acl.legacy_admin_secret_env", ), ( "a_zero_maintenance_interval", |config| config.maintenance.interval_secs = 0, "maintenance.interval_secs", ), ( "a_zero_repack_object_limit", |config| config.maintenance.repack_max_objects = 0, "maintenance.repack_max_objects", ), ( "a_geometric_factor_below_two", |config| config.maintenance.repack_geometric_factor = 1, "maintenance.repack_geometric_factor", ), ( "a_zero_large_push_threshold", |config| config.maintenance.large_push_bytes = 0, "maintenance.large_push_bytes", ), ( "a_zero_pack_cache_ttl", |config| config.pack_cache.ttl_secs = 0, "pack_cache.ttl_secs", ), ( "a_zero_pack_cache_entry_limit", |config| config.pack_cache.max_entry_bytes = 0, "pack_cache.max_entry_bytes", ), ( "a_zero_pack_cache_total_limit", |config| config.pack_cache.max_total_bytes = 0, "pack_cache.max_total_bytes", ), ( "a_pack_cache_total_below_one_entry", |config| { config.pack_cache.max_entry_bytes = 1_000; config.pack_cache.max_total_bytes = 500; }, "at least pack_cache.max_entry_bytes", ), ( "relative_scan_path", |config| config.repo.scan_path = PathBuf::from("relative/git"), "scan_path", ), ( "a_relative_lfs_store_path", |config| config.lfs.store_path = Some(PathBuf::from("relative/lfs")), "lfs.store_path must be absolute path", ), ( "an_lfs_store_inside_the_scan_path", |config| config.lfs.store_path = Some(config.repo.scan_path.join("lfs")), "lfs.store_path mustn't overlap repo.scan_path", ), ( "a_scan_path_inside_the_lfs_store", |config| { config.lfs.store_path = Some(PathBuf::from("/srv/media")); config.repo.scan_path = PathBuf::from("/srv/media/git"); }, "lfs.store_path mustn't overlap repo.scan_path", ), ( "a_zero_lfs_object_limit", |config| config.lfs.max_object_bytes = 0, "lfs.max_object_bytes", ), ( "a_zero_lfs_gc_interval", |config| config.lfs.gc_interval_secs = 0, "lfs.gc_interval_secs", ), ( "a_zero_lfs_ssh_transfer_limit", |config| config.lfs.max_ssh_transfers = 0, "lfs.max_ssh_transfers", ), ( "a_zero_lfs_http_download_limit", |config| config.lfs.max_http_downloads = 0, "lfs.max_http_downloads", ), ( "bad_master_key_env_name", |config| config.secrets.master_key_env = "9 bad name".to_string(), "master_key_env", ), ( "zero_http_timeout", |config| config.http.request_timeout_ms = 0, "request_timeout_ms", ), ( "zero_tree_last_commit_budget", |config| config.xrpc.tree_last_commit_budget_ms = 0, "tree_last_commit_budget_ms", ), ( "zero_blob_last_commit_budget", |config| config.xrpc.blob_last_commit_budget_ms = 0, "blob_last_commit_budget_ms", ), ( "zero_languages_budget", |config| config.xrpc.languages_budget_ms = 0, "languages_budget_ms", ), ( "zero_languages_push_budget", |config| config.xrpc.languages_push_budget_ms = 0, "languages_push_budget_ms", ), ( "zero_events_replay_buffer", |config| config.xrpc.events_replay_buffer = 0, "events_replay_buffer", ), ( "zero_events_replay_bytes", |config| config.xrpc.events_replay_bytes = 0, "events_replay_bytes", ), ( "zero_events_max_subscribers", |config| config.xrpc.events_max_subscribers = 0, "events_max_subscribers", ), ( "zero_events_max_per_peer", |config| config.xrpc.events_max_per_peer = 0, "events_max_per_peer", ), ( "a_per_peer_limit_above_the_global_limit", |config| { config.xrpc.events_max_subscribers = 4; config.xrpc.events_max_per_peer = 8; }, "events_max_subscribers must be at least", ), ( "a_non_https_plc_directory", |config| { config.atproto.plc_directory = Url::parse("http://plc.nel.pet/").unwrap(); }, "plc_directory", ), ( "an_idle_timeout_below_the_header_timeout", |config| { config.server.listen_header_timeout_ms = 10_000; config.server.listen_idle_timeout_ms = 5_000; }, "listen_idle_timeout_ms must be at least", ), ( "colliding_bind_ports", |config| config.server.internal_listen_addr = config.server.listen_addr, "same port", ), ( "a_relative_homepage_path", |config| config.homepage.path = Some(PathBuf::from("homepage.html")), "homepage.path must be absolute path", ), ]; cases.iter().for_each(|(label, mutate, expected)| { let mut config = sample(); mutate(&mut config); let errors = config.validate().unwrap_err().errors; assert!( errors.iter().any(|error| error.contains(expected)), "{label}: expected an error containing {expected}, got {errors:?}" ); }); }
#[test] fn homepage_source_resolves_states() { let disabled = HomepageConfig { enabled: false, path: Some(PathBuf::from("/etc/knot/home.html")), }; assert!(matches!(disabled.source(), HomepageSource::Disabled));
let default = HomepageConfig { enabled: true, path: None, }; assert!(matches!(default.source(), HomepageSource::Default));
let file = HomepageConfig { enabled: true, path: Some(PathBuf::from("/etc/knot/home.html")), }; match file.source() { HomepageSource::File(path) => assert_eq!(path, PathBuf::from("/etc/knot/home.html")), other => panic!("expected File, got {other:?}"), } }
#[test] fn validates_master_key() { let short = base64::engine::general_purpose::STANDARD.encode([0u8; 16]); let key = base64::engine::general_purpose::STANDARD.encode([7u8; 32]); type Case<'a> = (Option<&'a str>, fn(&Result<(), EnvError>) -> bool); let cases: Vec<Case<'_>> = vec![ (None, |result| { matches!(result, Err(EnvError::MasterKeyUnset { .. })) }), (Some(" "), |result| { matches!(result, Err(EnvError::MasterKeyEmpty { .. })) }), (Some("not base64 *** value"), |result| { matches!(result, Err(EnvError::MasterKeyNotBase64 { .. })) }), (Some(short.as_str()), |result| { matches!(result, Err(EnvError::MasterKeyTooShort { .. })) }), (Some(key.as_str()), |result| result.is_ok()), ]; cases.iter().for_each(|(input, expect)| { assert!(expect(&validate_master_key("KNOT_MASTER_KEY", *input))); }); }
#[test] fn admins_parse_from_comma_separated_env() { let parsed = parse_admins("did:plc:nel, did:plc:olaren").unwrap(); assert_eq!(parsed.len(), 2); assert!(parse_admins("not-a-did").is_err()); }
#[test] fn http_limits_map_from_config() { let limits = sample().http_limits(); assert_eq!(limits.connect_timeout, Duration::from_millis(5_000)); assert_eq!(limits.request_timeout, Duration::from_millis(60_000)); assert_eq!(limits.max_response_bytes, 16_777_216); }
#[test] fn missing_dir_is_rejected() { let dir = tempfile::tempdir().unwrap(); let absent = dir.path().join("no-such-dir"); assert!(matches!( verify_writable_dir("repo.scan_path", &absent), Err(EnvError::DirInaccessible { .. }) )); }
#[test] fn file_in_place_of_dir_is_rejected() { let file = tempfile::NamedTempFile::new().unwrap(); assert!(matches!( verify_writable_dir("lfs.store_path", file.path()), Err(EnvError::DirNotDir { .. }) )); }
#[test] fn writable_dir_passes() { let dir = tempfile::tempdir().unwrap(); assert!(verify_writable_dir("repo.scan_path", dir.path()).is_ok()); }
#[test] fn verify_homepage_accepts_absent_and_default_sources() { assert!(verify_homepage(HomepageSource::Disabled).is_ok()); assert!(verify_homepage(HomepageSource::Default).is_ok()); }
#[test] fn verify_homepage_rejects_missing_file() { let dir = tempfile::tempdir().unwrap(); let absent = dir.path().join("no-such-page.html"); assert!(matches!( verify_homepage(HomepageSource::File(absent)), Err(EnvError::HomepageInaccessible { .. }) )); }
#[test] fn verify_homepage_rejects_directory() { let dir = tempfile::tempdir().unwrap(); assert!(matches!( verify_homepage(HomepageSource::File(dir.path().to_path_buf())), Err(EnvError::HomepageNotFile { .. }) )); }
#[test] fn verify_homepage_accepts_readable_file() { let file = tempfile::NamedTempFile::new().unwrap(); assert!(verify_homepage(HomepageSource::File(file.path().to_path_buf())).is_ok()); }
#[test] fn disabled_homepage_ignores_relative_path() { let mut config = sample(); config.homepage.enabled = false; config.homepage.path = Some(PathBuf::from("homepage.html")); assert!(config.validate().is_ok()); }}