Skip to main content

lfsx_server/
config.rs

1use axum::http::{HeaderMap, header};
2
3use std::net::SocketAddr;
4use std::path::PathBuf;
5use std::time::Duration;
6
7use crate::auth::Namespaces;
8use crate::model::Action;
9use crate::namespace::Namespace;
10
11mod forges;
12
13pub use forges::Forge;
14
15#[derive(Debug, Clone)]
16pub struct Config {
17    pub bind: SocketAddr,
18    pub storage_root: PathBuf,
19    pub public_url: Option<String>,
20    pub action_lifetime: u32,
21    pub gc_grace: Duration,
22    pub staging_max_age: Duration,
23    // How long a lock may go untouched before anyone can take it. Unset means
24    // never, which is what happened before this existed and what a team that has
25    // not thought about it yet should keep getting.
26    pub lock_max_age: Option<Duration>,
27    pub max_object_size: Option<u64>,
28    // How many uploads and downloads may hold this server's disk and network
29    // open at once. A backstop for the bare deployment with nothing in front:
30    // the expensive thing here is a transfer held open, not a request counted,
31    // and anything smarter belongs to the reverse proxy.
32    pub max_concurrent_transfers: usize,
33    pub repo_quota: Option<u64>,
34    pub compression: Option<i32>,
35    // Never the key itself: a key in the environment is in the pod spec, in
36    // `docker inspect`, and in every log that dumps the environment. A file
37    // comes from a Kubernetes Secret mount without any of that, and a command
38    // is the one interface every KMS, Vault and SOPS already speaks, for the
39    // operator whose keys must never rest on disk at all.
40    pub encryption_key: Option<KeySource>,
41    pub storage: Storage,
42    pub auth: Auth,
43    pub dashboard: Option<Dashboard>,
44    pub forges: Vec<Forge>,
45}
46
47#[derive(Debug, Clone)]
48pub struct Dashboard {
49    pub dir: PathBuf,
50    pub admins: Option<Namespace>,
51}
52
53const DASHBOARD_DIR: &str = "/usr/share/lfsx/dashboard";
54
55fn dashboard(
56    enabled: Option<&str>,
57    dir: Option<&str>,
58    repo: Option<&str>,
59    auth: &Auth,
60) -> Option<Dashboard> {
61    if enabled != Some("true") {
62        return None;
63    }
64
65    let admins = repo.filter(|repo| !repo.is_empty()).map(|repo| {
66        repo.split_once('/')
67            .and_then(|(org, name)| Namespace::new(org, name).ok())
68            .unwrap_or_else(|| panic!("LFSX_DASHBOARD_REPO is not org/repo: {repo}"))
69    });
70    if admins.is_none() && matches!(auth, Auth::Forge { .. }) {
71        panic!(
72            "LFSX_DASHBOARD=true needs LFSX_DASHBOARD_REPO: the dashboard is shown to the admins of \
73             that repository, and nobody else"
74        );
75    }
76
77    Some(Dashboard {
78        dir: dir
79            .filter(|dir| !dir.is_empty())
80            .unwrap_or(DASHBOARD_DIR)
81            .into(),
82        admins,
83    })
84}
85
86#[derive(Debug, Clone)]
87pub enum Storage {
88    Local,
89    // Endpoint, bucket and credentials all have to be there: a bucket the server
90    // cannot reach is a server that answers every push with an error, and
91    // discovering that on the first upload rather than at boot is the wrong
92    // order.
93    Bucket {
94        dialect: Dialect,
95        // Whether a download is redirected to the bucket instead of streamed
96        // through this server. Off by default: the streamed path is the one
97        // that counts bytes, serves ranges and holds the ceiling, and an
98        // operator should choose to give those up rather than discover it.
99        presign: bool,
100        // A local copy of what the bucket holds, so a second reader does not
101        // pay the round trip again. None is no cache at all, which is what a
102        // deployment that never set it keeps getting.
103        cache: Option<DiskCache>,
104        // Whether locks can be taken here. Not read from the environment: it
105        // starts true and the startup probes turn it off, the same way they turn
106        // `presign` off, when the store will not prove it can arbitrate between
107        // two writers racing for the same key.
108        locking: bool,
109    },
110}
111
112#[derive(Debug, Clone)]
113pub enum Dialect {
114    S3 {
115        endpoint: String,
116        bucket: String,
117        region: String,
118        access_key: String,
119        secret_key: String,
120        path_style: bool,
121    },
122    Azure {
123        endpoint: String,
124        account: String,
125        container: String,
126        credential: AzureCredential,
127    },
128    Gcs {
129        endpoint: String,
130        bucket: String,
131        credential: GcsCredential,
132    },
133}
134
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub enum GcsCredential {
137    ServiceAccount(PathBuf),
138    Metadata,
139    Anonymous,
140}
141
142fn gcs_credential(value: Option<&str>) -> GcsCredential {
143    match value.filter(|value| !value.is_empty()) {
144        None => GcsCredential::Metadata,
145        Some("none") => GcsCredential::Anonymous,
146        Some(path) => GcsCredential::ServiceAccount(path.into()),
147    }
148}
149
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub enum AzureCredential {
152    AccountKey(String),
153    Sas(String),
154    Identity,
155}
156
157fn azure_credential(key: Option<&str>, sas: Option<&str>) -> AzureCredential {
158    let key = key.filter(|key| !key.is_empty());
159    let sas = sas.filter(|sas| !sas.is_empty());
160
161    match (key, sas) {
162        (Some(_), Some(_)) => panic!(
163            "LFSX_AZURE_ACCOUNT_KEY and LFSX_AZURE_SAS_TOKEN are both set: pick one, or neither \
164             to authenticate with the pod's managed or workload identity"
165        ),
166        (Some(key), None) => AzureCredential::AccountKey(key.to_owned()),
167        (None, Some(sas)) => AzureCredential::Sas(sas.to_owned()),
168        (None, None) => AzureCredential::Identity,
169    }
170}
171
172impl Storage {
173    fn from_env() -> Self {
174        let kind = std::env::var("LFSX_STORAGE").unwrap_or_default();
175        if !matches!(kind.as_str(), "s3" | "azure" | "gcs") {
176            return Self::Local;
177        }
178
179        let required = |name: &str| {
180            std::env::var(name)
181                .ok()
182                .filter(|value| !value.is_empty())
183                .unwrap_or_else(|| panic!("LFSX_STORAGE={kind} needs {name}"))
184        };
185
186        let dialect = if kind == "gcs" {
187            Dialect::Gcs {
188                endpoint: std::env::var("LFSX_GCS_ENDPOINT")
189                    .ok()
190                    .filter(|value| !value.is_empty())
191                    .unwrap_or_else(|| "https://storage.googleapis.com".into()),
192                bucket: required("LFSX_GCS_BUCKET"),
193                credential: gcs_credential(std::env::var("LFSX_GCS_CREDENTIALS").ok().as_deref()),
194            }
195        } else if kind == "azure" {
196            let account = required("LFSX_AZURE_ACCOUNT");
197            Dialect::Azure {
198                endpoint: std::env::var("LFSX_AZURE_ENDPOINT")
199                    .ok()
200                    .filter(|value| !value.is_empty())
201                    .unwrap_or_else(|| format!("https://{account}.blob.core.windows.net")),
202                container: required("LFSX_AZURE_CONTAINER"),
203                credential: azure_credential(
204                    std::env::var("LFSX_AZURE_ACCOUNT_KEY").ok().as_deref(),
205                    std::env::var("LFSX_AZURE_SAS_TOKEN").ok().as_deref(),
206                ),
207                account,
208            }
209        } else {
210            Dialect::S3 {
211                endpoint: required("LFSX_S3_ENDPOINT"),
212                bucket: required("LFSX_S3_BUCKET"),
213                region: std::env::var("LFSX_S3_REGION").unwrap_or_else(|_| "us-east-1".into()),
214                access_key: required("LFSX_S3_ACCESS_KEY"),
215                secret_key: required("LFSX_S3_SECRET_KEY"),
216                path_style: std::env::var("LFSX_S3_PATH_STYLE").as_deref() != Ok("false"),
217            }
218        };
219
220        Self::Bucket {
221            dialect,
222            presign: std::env::var("LFSX_S3_PRESIGN").as_deref() == Ok("true"),
223            cache: disk_cache(
224                std::env::var("LFSX_S3_CACHE_DIR").ok().as_deref(),
225                std::env::var("LFSX_S3_CACHE_MAX_BYTES").ok().as_deref(),
226            ),
227            locking: true,
228        }
229    }
230}
231
232#[derive(Debug, Clone)]
233pub enum Auth {
234    Forge {
235        provider: Provider,
236        api_url: String,
237        // A GitHub App identity for the server's own calls, so the anonymous
238        // lookup spends the App installation's quota instead of the 60-an-hour
239        // unauthenticated one. A file path for the key, never the key itself,
240        // same discipline as the encryption key.
241        github_app: Option<GithubApp>,
242        cache_ttl: Duration,
243        rejection_ttl: Duration,
244        // Lookups a minute this server will spend on the forge, counted only
245        // when neither cache could answer. None is no ceiling at all.
246        lookup_budget: Option<u32>,
247        // Whether a request with no credentials is resolved against the forge
248        // instead of refused. Off unless asked for, because a server that serves
249        // strangers should be a decision somebody made.
250        anonymous_read: bool,
251        // Namespaces whose objects take write access to read, so a repository the
252        // forge serves publicly can still keep its assets to the people who could
253        // push them.
254        restricted: Namespaces,
255        allowed: Option<Namespaces>,
256    },
257    Disabled,
258}
259
260#[derive(Debug, Clone, PartialEq, Eq)]
261pub struct DiskCache {
262    pub dir: PathBuf,
263    pub max_bytes: u64,
264}
265
266// A directory and a ceiling, together or not at all. A cache with nowhere to
267// live is nothing, and one with no ceiling fills the disk the server also
268// stages uploads on, which is a worse outage than the bucket round trips it
269// was meant to save.
270fn disk_cache(dir: Option<&str>, max_bytes: Option<&str>) -> Option<DiskCache> {
271    let dir = dir.filter(|dir| !dir.is_empty())?;
272
273    let Some(max_bytes) = max_bytes
274        .map(str::trim)
275        .and_then(|raw| raw.parse::<u64>().ok())
276        .filter(|ceiling| *ceiling > 0)
277    else {
278        tracing::warn!(
279            "LFSX_S3_CACHE_DIR is set without a usable LFSX_S3_CACHE_MAX_BYTES, so nothing is \
280             cached: a cache with no ceiling would fill the volume this server stages uploads on"
281        );
282        return None;
283    };
284
285    Some(DiskCache {
286        dir: PathBuf::from(dir),
287        max_bytes,
288    })
289}
290
291#[derive(Debug, Clone, PartialEq, Eq)]
292pub enum KeySource {
293    File(PathBuf),
294    Command(String),
295}
296
297// One source or none. Both is a configuration that says two things, and which
298// of them the operator trusts with the store is not a guess this server makes.
299fn encryption_key(file: Option<&str>, command: Option<&str>) -> Option<KeySource> {
300    let file = file.filter(|path| !path.is_empty());
301    let command = command.filter(|hook| !hook.is_empty());
302
303    match (file, command) {
304        (None, None) => None,
305        (Some(path), None) => Some(KeySource::File(PathBuf::from(path))),
306        (None, Some(hook)) => Some(KeySource::Command(hook.to_owned())),
307        (Some(_), Some(_)) => panic!(
308            "LFSX_ENCRYPTION_KEY_FILE and LFSX_ENCRYPTION_KEY_COMMAND are both set: they are two \
309             answers to where the keys live, and picking one for you is how the wrong keys get used"
310        ),
311    }
312}
313
314#[derive(Debug, Clone, PartialEq, Eq)]
315pub struct GithubApp {
316    pub app_id: String,
317    pub key_file: PathBuf,
318}
319
320#[derive(Debug, Clone, Copy, PartialEq, Eq)]
321pub enum Provider {
322    Github,
323    Gitlab,
324    // Gitea and Forgejo, which are one API: Forgejo is a fork of Gitea and
325    // answers the same routes, so the only thing that tells two instances apart
326    // is the root they are reached at.
327    Gitea,
328}
329
330impl Provider {
331    // None where there is no such thing as the instance.
332    //
333    // github.com and gitlab.com are where a repository is unless the operator
334    // says otherwise, so defaulting there is nearly always right. Gitea is
335    // software rather than a place. gitea.com exists, but an operator who names
336    // this provider is almost certainly running their own, and quietly resolving
337    // their namespaces against a stranger's forge is worse than not starting: a
338    // public repository there that happens to share a name would hand out
339    // anonymous read on objects it has nothing to do with.
340    fn default_api_url(self) -> Option<&'static str> {
341        match self {
342            Self::Github => Some("https://api.github.com"),
343            Self::Gitlab => Some("https://gitlab.com/api/v4"),
344            Self::Gitea => None,
345        }
346    }
347
348    fn api_url_variable(self) -> &'static str {
349        match self {
350            Self::Github => "LFSX_GITHUB_API_URL",
351            Self::Gitlab => "LFSX_GITLAB_API_URL",
352            Self::Gitea => "LFSX_GITEA_API_URL",
353        }
354    }
355}
356
357const CACHE_TTL: Duration = Duration::from_secs(60);
358const REJECTION_TTL: Duration = Duration::from_secs(10);
359// Generous enough that a busy server never meets it, since a lookup is one
360// distinct token against one repository per cache lifetime rather than one per
361// request, and tight enough that a flood costs ten a second instead of whatever
362// the network will carry.
363const LOOKUP_BUDGET: u32 = 600;
364const TRANSFER_CAP: usize = 128;
365const GC_GRACE: Duration = Duration::from_secs(14 * 24 * 60 * 60);
366const STAGING_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
367
368impl Config {
369    pub fn from_env() -> Self {
370        let bind = std::env::var("LFSX_BIND")
371            .ok()
372            .and_then(|raw| raw.parse().ok())
373            .unwrap_or_else(|| SocketAddr::from(([0, 0, 0, 0], 8080)));
374
375        let storage_root = std::env::var("LFSX_STORAGE_ROOT")
376            .map(PathBuf::from)
377            .unwrap_or_else(|_| PathBuf::from("/var/lib/lfsx"));
378
379        let public_url = std::env::var("LFSX_PUBLIC_URL")
380            .ok()
381            .filter(|url| !url.is_empty())
382            .map(|url| url.trim_end_matches('/').to_owned());
383
384        Self {
385            bind,
386            storage_root,
387            public_url,
388            action_lifetime: 1800,
389            gc_grace: seconds("LFSX_GC_GRACE").unwrap_or(GC_GRACE),
390            staging_max_age: seconds("LFSX_STAGING_MAX_AGE").unwrap_or(STAGING_MAX_AGE),
391            lock_max_age: seconds("LFSX_LOCK_MAX_AGE"),
392            max_object_size: bytes("LFSX_MAX_OBJECT_SIZE"),
393            max_concurrent_transfers: transfer_cap(
394                std::env::var("LFSX_MAX_CONCURRENT_TRANSFERS")
395                    .ok()
396                    .as_deref(),
397            ),
398            repo_quota: bytes("LFSX_REPO_QUOTA"),
399            compression: compression(),
400            encryption_key: encryption_key(
401                std::env::var("LFSX_ENCRYPTION_KEY_FILE").ok().as_deref(),
402                std::env::var("LFSX_ENCRYPTION_KEY_COMMAND").ok().as_deref(),
403            ),
404            storage: Storage::from_env(),
405            dashboard: None,
406            forges: Vec::new(),
407            auth: Auth::from_env(),
408        }
409        .with_dashboard()
410        .with_forges()
411    }
412
413    fn with_forges(self) -> Self {
414        Self {
415            forges: forges::from_env(&self.auth),
416            ..self
417        }
418    }
419
420    fn with_dashboard(self) -> Self {
421        Self {
422            dashboard: dashboard(
423                std::env::var("LFSX_DASHBOARD").ok().as_deref(),
424                std::env::var("LFSX_DASHBOARD_DIR").ok().as_deref(),
425                std::env::var("LFSX_DASHBOARD_REPO").ok().as_deref(),
426                &self.auth,
427            ),
428            ..self
429        }
430    }
431
432    pub fn base_url(&self, headers: &HeaderMap) -> String {
433        if let Some(configured) = &self.public_url {
434            return configured.clone();
435        }
436
437        // Neither of these is this deployment speaking. They are what the caller
438        // sent, and what comes out of here is the URL that caller will send the
439        // object to, with its credential attached. So both are checked for being
440        // the thing they claim to be before either goes into a URL.
441        let scheme = headers
442            .get("x-forwarded-proto")
443            .and_then(|value| value.to_str().ok())
444            .and_then(|value| value.split(',').next())
445            .map(str::trim)
446            .filter(|scheme| matches!(*scheme, "http" | "https"))
447            .unwrap_or("http");
448
449        let authority = headers
450            .get(header::HOST)
451            .and_then(|value| value.to_str().ok())
452            .map(str::trim)
453            .filter(|host| is_an_authority(host))
454            .unwrap_or("localhost");
455
456        format!("{scheme}://{authority}")
457    }
458
459    pub fn object_url(&self, base: &str, ns: &Namespace, oid: &str) -> String {
460        format!("{base}/{}/objects/{oid}", ns.url_path())
461    }
462
463    pub fn verify_url(&self, base: &str, ns: &Namespace) -> String {
464        format!("{base}/{}/objects/verify", ns.url_path())
465    }
466
467    pub fn action(&self, href: String) -> Action {
468        Action {
469            href,
470            header: None,
471            expires_in: self.action_lifetime,
472        }
473    }
474
475    pub fn signed_action(&self, href: String, headers: Vec<(String, String)>) -> Action {
476        Action {
477            href,
478            header: Some(headers.into_iter().collect()),
479            expires_in: self.action_lifetime,
480        }
481    }
482}
483
484// Opt in, not opt out. Serving objects to a caller with no credentials at all is
485// a decision an operator should make on purpose: it costs them the bandwidth of
486// anyone who finds the endpoint, on a server whose whole job is to move files
487// measured in gigabytes. Nothing confidential is at stake, since a request with
488// no credentials is still resolved against the forge and a private repository is
489// still refused, but "anyone may pull from you" is not a sensible thing to
490// inherit by default.
491//
492// Only the exact string opens it. A typo, an empty value or a `1` leaves it
493// closed, because the failure that matters here is the one that opens the door
494// when nobody meant to.
495fn anonymous_read(value: Option<&str>) -> bool {
496    value == Some("true")
497}
498
499impl Auth {
500    fn from_env() -> Self {
501        if std::env::var("LFSX_AUTH").as_deref() == Ok("disabled") {
502            tracing::warn!(
503                "LFSX_AUTH=disabled: every request is accepted, run this on a trusted network only"
504            );
505            if is_set(std::env::var("LFSX_ALLOWED").ok().as_deref()) {
506                tracing::warn!(
507                    "LFSX_ALLOWED is set and LFSX_AUTH=disabled, so it does nothing: with no forge \
508                     to ask, every repository is served"
509                );
510            }
511            if is_set(std::env::var("LFSX_RESTRICTED").ok().as_deref()) {
512                tracing::warn!(
513                    "LFSX_RESTRICTED is set and LFSX_AUTH=disabled, so it does nothing: every \
514                     caller already holds every right"
515                );
516            }
517            return Self::Disabled;
518        }
519
520        let provider = provider(std::env::var("LFSX_AUTH").ok().as_deref());
521
522        Self::Forge {
523            provider,
524            api_url: api_url(
525                provider,
526                std::env::var(provider.api_url_variable()).ok().as_deref(),
527            ),
528            cache_ttl: seconds("LFSX_AUTH_CACHE_TTL").unwrap_or(CACHE_TTL),
529            rejection_ttl: seconds("LFSX_AUTH_REJECTION_TTL").unwrap_or(REJECTION_TTL),
530            lookup_budget: lookup_budget(std::env::var("LFSX_AUTH_LOOKUP_BUDGET").ok().as_deref()),
531            github_app: github_app(provider),
532            anonymous_read: anonymous_read(std::env::var("LFSX_ANONYMOUS_READ").ok().as_deref()),
533            restricted: Namespaces::parse(
534                "LFSX_RESTRICTED",
535                std::env::var("LFSX_RESTRICTED").ok().as_deref(),
536            ),
537            allowed: allowed(
538                "LFSX_ALLOWED",
539                std::env::var("LFSX_ALLOWED").ok().as_deref(),
540            ),
541        }
542    }
543}
544
545fn is_set(value: Option<&str>) -> bool {
546    value.is_some_and(|value| !value.trim().is_empty())
547}
548
549fn allowed(variable: &str, value: Option<&str>) -> Option<Namespaces> {
550    is_set(value).then(|| Namespaces::parse(variable, value))
551}
552
553// Both variables or neither. One without the other is a configuration that
554// says two things at once, and an operator who set up an App meant to have its
555// quota, so the mistake is refused at boot instead of quietly ignored.
556fn github_app(provider: Provider) -> Option<GithubApp> {
557    let id = std::env::var("LFSX_GITHUB_APP_ID")
558        .ok()
559        .filter(|id| !id.is_empty());
560    let key_file = std::env::var("LFSX_GITHUB_APP_KEY_FILE")
561        .ok()
562        .filter(|path| !path.is_empty());
563
564    match (id, key_file) {
565        (None, None) => None,
566        (Some(app_id), Some(key_file)) => {
567            if provider != Provider::Github {
568                tracing::warn!(
569                    "LFSX_GITHUB_APP_ID is set but LFSX_AUTH is not github, so it does nothing"
570                );
571                return None;
572            }
573            Some(GithubApp {
574                app_id,
575                key_file: PathBuf::from(key_file),
576            })
577        }
578        _ => panic!(
579            "LFSX_GITHUB_APP_ID and LFSX_GITHUB_APP_KEY_FILE come together: one without the \
580             other is half an identity, and guessing which half was meant is worse than stopping"
581        ),
582    }
583}
584
585// Zero is the one value that cannot mean what it says. A ceiling of no lookups
586// is a server that refuses every caller it has not already seen, so it is read as
587// the operator turning the ceiling off, which is the only other thing they could
588// have meant. Anything unparseable is the default rather than a refusal to start:
589// this bounds a cost, and getting it wrong should not take the server down.
590fn lookup_budget(value: Option<&str>) -> Option<u32> {
591    match value.map(str::trim).map(str::parse::<u32>) {
592        Some(Ok(0)) => None,
593        Some(Ok(budget)) => Some(budget),
594        Some(Err(_)) | None => Some(LOOKUP_BUDGET),
595    }
596}
597
598// Anything unrecognised is GitHub, which is what an operator who set nothing
599// almost certainly meant. Forgejo is named alongside Gitea because they are one
600// API, and somebody running Forgejo should not have to know it began as a fork.
601fn provider(value: Option<&str>) -> Provider {
602    match value {
603        Some("gitlab") => Provider::Gitlab,
604        Some("gitea") | Some("forgejo") => Provider::Gitea,
605        _ => Provider::Github,
606    }
607}
608
609// The trailing slash matters: every route is built by appending to this, so one
610// left on the end produces `//repos/...`, which some forges answer and others do
611// not, and the ones that do not answer 404 for a repository that is right there.
612fn api_url(provider: Provider, configured: Option<&str>) -> String {
613    api_url_from(provider, provider.api_url_variable(), configured)
614}
615
616fn api_url_from(provider: Provider, variable: &str, configured: Option<&str>) -> String {
617    configured
618        .map(str::to_owned)
619        .or_else(|| provider.default_api_url().map(str::to_owned))
620        .unwrap_or_else(|| {
621            panic!(
622                "{variable} must be set: a self-hosted forge has no default API root, and guessing \
623                 one would resolve your repositories against somebody else's"
624            )
625        })
626        .trim_end_matches('/')
627        .to_owned()
628}
629
630// Is this a host and a port, and nothing else?
631//
632// A `Host` carrying a `/` or an `@` is not one, and both change where the URL
633// built from it points. `real.example@evil.example` resolves to the second name
634// with the first read as a username, which turns a header somebody sent into a
635// redirect nobody wrote, and the client follows it carrying its token.
636//
637// Anything that fails this falls back to `localhost`, which is useless to
638// everybody and dangerous to nobody. `LFSX_PUBLIC_URL` is the fix, and startup
639// says so.
640fn is_an_authority(host: &str) -> bool {
641    !host.is_empty()
642        && host.len() <= 255
643        && host.bytes().all(|byte| {
644            byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_' | b':' | b'[' | b']')
645        })
646}
647
648// Unset means unlimited, which is what a server on its own volume wants. Zero
649// would refuse every push, so it is read as a typo rather than as a policy
650// nobody would choose deliberately.
651// zstd level 3 is the default because it is the one that costs nothing you can
652// measure: it compresses faster than a spinning disk writes, and the meshes and
653// uncompressed raster that make up most of an LFS store give most of their
654// ground at any level. Higher levels are there for a store that is short on
655// room rather than on time.
656fn compression() -> Option<i32> {
657    match std::env::var("LFSX_COMPRESSION").ok()?.trim() {
658        "" | "none" | "off" => None,
659        "zstd" => Some(3),
660        other => match other
661            .strip_prefix("zstd:")
662            .and_then(|level| level.parse().ok())
663        {
664            Some(level @ 1..=19) => Some(level),
665            _ => {
666                tracing::warn!(
667                    "LFSX_COMPRESSION={other} is not a codec this server knows, storing objects as they arrive"
668                );
669                None
670            }
671        },
672    }
673}
674
675// Same posture as the lookup budget: this bounds a cost, so an unparseable
676// value falls back to the default with a warning rather than refusing to start.
677fn transfer_cap(value: Option<&str>) -> usize {
678    match value.map(str::trim).map(str::parse) {
679        Some(Ok(cap)) => cap,
680        None => TRANSFER_CAP,
681        Some(Err(_)) => {
682            tracing::warn!(
683                "LFSX_MAX_CONCURRENT_TRANSFERS is not a number, keeping the default of {TRANSFER_CAP}"
684            );
685            TRANSFER_CAP
686        }
687    }
688}
689
690fn bytes(variable: &str) -> Option<u64> {
691    let configured = std::env::var(variable).ok()?.trim().parse().ok()?;
692
693    if configured == 0 {
694        tracing::warn!("{variable}=0 would refuse every upload, ignoring it");
695        return None;
696    }
697
698    Some(configured)
699}
700
701fn seconds(variable: &str) -> Option<Duration> {
702    std::env::var(variable)
703        .ok()
704        .and_then(|raw| raw.parse().ok())
705        .map(Duration::from_secs)
706}
707
708#[cfg(test)]
709mod tests;