Skip to main content

kellnr_web_ui/
ui.rs

1use std::collections::HashMap;
2use std::path::PathBuf;
3use std::sync::Arc;
4
5use axum::Json;
6use axum::extract::{Path, Query, State};
7use axum::http::{StatusCode, header};
8use kellnr_common::canonical_name::CanonicalName;
9use kellnr_common::crate_data::{CRATESIO_INDEX, CrateData};
10use kellnr_common::crate_dependents::CrateDependents;
11use kellnr_common::crate_overview::CrateOverview;
12use kellnr_common::name_or_description::NameOrDescription;
13use kellnr_common::original_name::OriginalName;
14use kellnr_common::version::Version;
15use kellnr_db::error::DbError;
16use kellnr_db::{CrateSort, CrateTagFilter, DbProvider};
17use kellnr_docs::upload::upload_dir_and_prune;
18use kellnr_http::authz::{self, can_read_crate, crate_visibility};
19use kellnr_http::error::ApiError;
20use kellnr_http::identity::{AdminUser, AnyUser, Identity, SessionUser};
21use kellnr_http::state::{AppState, DbState, ProxyClientState, SettingsProvState, SettingsState};
22use kellnr_settings::{
23    ConfigSource, Provenance, Settings, SettingsProv, SourceMap, cli_flag_map, compile_time_config,
24    erased_serde, leaf_label, sources_from_prov,
25};
26use kellnr_storage::docs_storage::DocsName;
27use serde::{Deserialize, Serialize};
28use tracing::{error, warn};
29use utoipa::ToSchema;
30
31use crate::pagination::{PageParams, PageRequest};
32
33/// Settings response, the flattened `Settings`, the per-leaf source map
34/// derived from the `SettingsProv`, and the compiled-in defaults the UI
35/// uses to show "default: X" next to overridden values.
36///
37/// Secret leaves are redacted in `settings`, `defaults` and `leaves`: a set
38/// secret is replaced by [`REDACTED`], an unset one is `null`.
39///
40/// `leaves` is the new dynamic schema: one entry per `SettingsProv` leaf with
41/// the value, default, source, type, secret flag, optional CLI flag string
42/// (auto-derived from clap) and optional label override. The Vue UI iterates
43/// over this list instead of hand-maintaining a parallel per-leaf table.
44#[derive(Serialize, Deserialize, ToSchema)]
45pub struct SettingsResponse {
46    #[serde(flatten)]
47    #[schema(value_type = Settings)]
48    pub settings: serde_json::Map<String, serde_json::Value>,
49    #[schema(inline)]
50    pub sources: SourceMap,
51    #[schema(value_type = Settings)]
52    pub defaults: serde_json::Value,
53    pub leaves: Vec<LeafMeta>,
54}
55
56/// Placeholder sent instead of the value of a configured secret.
57pub const REDACTED: &str = "********";
58
59/// Per-leaf metadata for the dynamic UI. All fields except `value`/`source`
60/// are stable per build (or, in the case of `default`, per binary), so the UI
61/// can treat the response as both data and schema.
62#[derive(Serialize, Deserialize, Clone, Debug, ToSchema)]
63pub struct LeafMeta {
64    /// Dotted path, e.g. `"registry.data_dir"`.
65    pub key: String,
66    /// Active value as a JSON value (boolean, number, string, array, null).
67    pub value: serde_json::Value,
68    /// Compiled-in default value in the same shape as `value`.
69    pub default: serde_json::Value,
70    /// Where the active value came from.
71    pub source: ConfigSource,
72    /// JSON-flavoured type tag the UI uses to pick a renderer.
73    #[serde(rename = "type")]
74    pub kind: LeafKind,
75    /// `true` for `#[configurable(secret)]` leaves, the UI masks the value.
76    pub secret: bool,
77    /// CLI flag string (e.g. `"--registry-data-dir, -d"`) when one exists.
78    pub cli_flag: Option<String>,
79    /// Optional display label override; `None` means the UI humanizes the key.
80    pub label: Option<String>,
81}
82
83#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, ToSchema)]
84#[serde(rename_all = "lowercase")]
85pub enum LeafKind {
86    Boolean,
87    Number,
88    String,
89    Array,
90}
91
92/// Build the leaf metadata vector by walking `SettingsProv` once and joining
93/// with `Settings::default()` (for defaults) and `cli_flag_map()` (for flags).
94fn build_leaves(prov: &SettingsProv) -> Vec<LeafMeta> {
95    let defaults = Settings::default();
96    let defaults_json =
97        serde_json::to_value(&defaults).expect("Settings serializes as JSON for the defaults map");
98    let flag_map = cli_flag_map();
99
100    let mut leaves: Vec<LeafMeta> = Vec::new();
101    prov.walk_leaves("", &mut |path, value, category, secret| {
102        // Re-serialize the type-erased value to JSON so the UI gets the same
103        // shape as the surrounding `settings` block.
104        let Ok(value_json) = json_from_erased(value) else {
105            return;
106        };
107        let mut leaf_default = lookup_default(&defaults_json, path);
108        let mut value_json = value_json;
109        if secret {
110            value_json = redact_secret(&value_json);
111            leaf_default = redact_secret(&leaf_default);
112        }
113        let kind = LeafKind::from_json(&value_json);
114        leaves.push(LeafMeta {
115            key: path.to_string(),
116            value: value_json,
117            default: leaf_default,
118            source: category.into(),
119            kind,
120            secret,
121            cli_flag: flag_map.get(path).cloned(),
122            label: leaf_label(path).map(String::from),
123        });
124    });
125    // Stable order: alphabetic by dotted path. The UI groups by section
126    // prefix anyway, so any consistent order is fine, alphabetic keeps the
127    // response diff-friendly across runs.
128    leaves.sort_by(|a, b| a.key.cmp(&b.key));
129    leaves
130}
131
132/// Replace a secret value by [`REDACTED`], or by `null` if it is unset or empty,
133/// so the UI can still tell "configured" from "not configured".
134fn redact_secret(value: &serde_json::Value) -> serde_json::Value {
135    match value {
136        serde_json::Value::Null => serde_json::Value::Null,
137        serde_json::Value::String(s) if s.is_empty() => serde_json::Value::Null,
138        _ => serde_json::Value::String(REDACTED.to_string()),
139    }
140}
141
142/// Serialize `settings` to JSON and redact every secret leaf of `leaves`.
143/// Secrets that are not serialized at all (`skip_serializing`) are skipped.
144fn redacted_settings_json(
145    settings: &Settings,
146    leaves: &[LeafMeta],
147) -> Result<serde_json::Value, serde_json::Error> {
148    let mut json = serde_json::to_value(settings)?;
149    for leaf in leaves.iter().filter(|leaf| leaf.secret) {
150        let pointer = format!("/{}", leaf.key.replace('.', "/"));
151        if let Some(value) = json.pointer_mut(&pointer) {
152            *value = redact_secret(value);
153        }
154    }
155    Ok(json)
156}
157
158/// Serialize an erased value into a `serde_json::Value` via the same shim
159/// pattern used by `kellnr/src/config_printer.rs::toml_from_erased`.
160fn json_from_erased(
161    value: &dyn erased_serde::Serialize,
162) -> Result<serde_json::Value, serde_json::Error> {
163    struct Erased<'a>(&'a dyn erased_serde::Serialize);
164    impl Serialize for Erased<'_> {
165        fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
166            erased_serde::serialize(self.0, ser)
167        }
168    }
169    serde_json::to_value(Erased(value))
170}
171
172/// Look up `dotted_path` inside a JSON-serialized `Settings` and return the
173/// matching leaf. Returns `Null` if the path is missing (shouldn't happen for
174/// any walked leaf, but the JSON null keeps the UI's renderer happy).
175fn lookup_default(defaults_json: &serde_json::Value, dotted_path: &str) -> serde_json::Value {
176    let mut current = defaults_json;
177    for segment in dotted_path.split('.') {
178        let Some(next) = current.get(segment) else {
179            return serde_json::Value::Null;
180        };
181        current = next;
182    }
183    current.clone()
184}
185
186impl LeafKind {
187    fn from_json(value: &serde_json::Value) -> Self {
188        match value {
189            serde_json::Value::Bool(_) => Self::Boolean,
190            serde_json::Value::Number(_) => Self::Number,
191            serde_json::Value::Array(_) => Self::Array,
192            // String, null, object → fall back to string. `null` represents
193            // `Option::None`; objects shouldn't appear (only leaves are
194            // visited), but if they do the UI prints them via String(_) too.
195            _ => Self::String,
196        }
197    }
198}
199
200/// Get Kellnr settings (admin only)
201#[utoipa::path(
202    get,
203    path = "/settings",
204    tag = "ui",
205    responses(
206        (status = 200, description = "Kellnr settings with source tracking", body = SettingsResponse),
207        (status = 403, description = "Admin access required")
208    ),
209    security(("session_cookie" = []))
210)]
211#[allow(clippy::unused_async)] // part of the router
212pub async fn settings(
213    _user: AdminUser,
214    State(settings): SettingsState,
215    State(prov): SettingsProvState,
216) -> Result<Json<SettingsResponse>, ApiError> {
217    let leaves = build_leaves(&prov);
218    let serialization_error = |e: serde_json::Error| {
219        error!("Failed to serialize settings: {e}");
220        ApiError::from(StatusCode::INTERNAL_SERVER_ERROR)
221    };
222    let serde_json::Value::Object(settings) =
223        redacted_settings_json(&settings, &leaves).map_err(serialization_error)?
224    else {
225        return Err(ApiError::from(StatusCode::INTERNAL_SERVER_ERROR));
226    };
227    let defaults =
228        redacted_settings_json(&Settings::default(), &leaves).map_err(serialization_error)?;
229
230    Ok(Json(SettingsResponse {
231        sources: sources_from_prov(&prov),
232        settings,
233        defaults,
234        leaves,
235    }))
236}
237
238#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, ToSchema)]
239pub struct DocsEnabledResponse {
240    pub enabled: bool,
241}
242
243/// Check if documentation generation is enabled
244#[utoipa::path(
245    get,
246    path = "/docs_enabled",
247    tag = "ui",
248    responses(
249        (status = 200, description = "Documentation generation status", body = DocsEnabledResponse)
250    )
251)]
252#[allow(clippy::unused_async)] // part of the router
253pub async fn docs_enabled(State(settings): SettingsState) -> Json<DocsEnabledResponse> {
254    Json(DocsEnabledResponse {
255        enabled: settings.docs.enabled,
256    })
257}
258
259#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, ToSchema)]
260pub struct SponsorLinksEnabledResponse {
261    pub enabled: bool,
262}
263
264/// Check if the web UI shows links to sponsor Kellnr
265#[utoipa::path(
266    get,
267    path = "/sponsor_links_enabled",
268    tag = "ui",
269    responses(
270        (status = 200, description = "Sponsor links status", body = SponsorLinksEnabledResponse)
271    )
272)]
273#[allow(clippy::unused_async)] // part of the router
274pub async fn sponsor_links_enabled(
275    State(settings): SettingsState,
276) -> Json<SponsorLinksEnabledResponse> {
277    Json(SponsorLinksEnabledResponse {
278        enabled: settings.registry.sponsor_links_enabled,
279    })
280}
281
282#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, ToSchema)]
283pub struct KellnrVersion {
284    pub version: String,
285}
286
287/// Get Kellnr version
288#[utoipa::path(
289    get,
290    path = "/version",
291    tag = "ui",
292    responses(
293        (status = 200, description = "Kellnr version", body = KellnrVersion)
294    )
295)]
296#[allow(clippy::unused_async)] // part of the router
297pub async fn kellnr_version() -> Json<KellnrVersion> {
298    Json(KellnrVersion {
299        version: compile_time_config::KELLNR_COMPTIME__VERSION.to_string(),
300    })
301}
302
303#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, ToSchema, utoipa::IntoParams)]
304pub struct CratesParams {
305    cache: Option<bool>,
306    sort: Option<CrateSort>,
307    /// Only crates with this keyword
308    keyword: Option<String>,
309    /// Only crates in this category
310    category: Option<String>,
311}
312
313/// Logs a failed database query and returns a 500.
314fn db_error(e: &DbError) -> ApiError {
315    error!("Database query failed: {e}");
316    ApiError::from(StatusCode::INTERNAL_SERVER_ERROR)
317}
318
319#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, ToSchema)]
320pub struct Pagination {
321    crates: Vec<CrateOverview>,
322    page_size: u64,
323    /// Number of the page, starting at 0
324    page: u64,
325    /// Number of crates on all pages
326    total: u64,
327}
328
329/// Get paginated list of crates
330#[utoipa::path(
331    get,
332    path = "/crates",
333    tag = "ui",
334    params(CratesParams, PageParams),
335    responses(
336        (status = 200, description = "Paginated crate list", body = Pagination),
337        (status = 400, description = "Page out of range")
338    )
339)]
340pub async fn crates(
341    Query(params): Query<CratesParams>,
342    page: PageRequest,
343    State(db): DbState,
344    user: Option<AnyUser>,
345) -> Result<Json<Pagination>, ApiError> {
346    let cache = params.cache.unwrap_or(false);
347    let visibility = crate_visibility(user.as_deref());
348    let sort = params.sort.unwrap_or_default();
349    let tags = CrateTagFilter {
350        keyword: params.keyword.filter(|k| !k.is_empty()),
351        category: params.category.filter(|c| !c.is_empty()),
352    };
353    let crates = db
354        .get_crate_overview_list(
355            page.size().get(),
356            page.offset(),
357            cache,
358            &visibility,
359            sort,
360            &tags,
361        )
362        .await
363        .map_err(|e| db_error(&e))?;
364    let total = db
365        .count_crate_overview_list(cache, &visibility, &tags)
366        .await
367        .map_err(|e| db_error(&e))?;
368
369    Ok(Json(Pagination {
370        crates,
371        page_size: page.size().get(),
372        page: page.page(),
373        total,
374    }))
375}
376
377#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, ToSchema, utoipa::IntoParams)]
378pub struct SearchParams {
379    name: NameOrDescription,
380    cache: Option<bool>,
381}
382
383/// Search for crates by name and description
384#[utoipa::path(
385    get,
386    path = "/search",
387    tag = "ui",
388    params(SearchParams, PageParams),
389    responses(
390        (status = 200, description = "Paginated search results", body = Pagination),
391        (status = 400, description = "Page out of range")
392    )
393)]
394pub async fn search(
395    Query(params): Query<SearchParams>,
396    page: PageRequest,
397    State(db): DbState,
398    user: Option<AnyUser>,
399) -> Result<Json<Pagination>, ApiError> {
400    let cache = params.cache.unwrap_or(false);
401    let visibility = crate_visibility(user.as_deref());
402    let crates = db
403        .search_in_crate_name_and_description(
404            params.name.as_str(),
405            page.size().get(),
406            page.offset(),
407            cache,
408            &visibility,
409        )
410        .await
411        .map_err(|e| db_error(&e))?;
412    let total = db
413        .count_by_crate_name_and_description(params.name.as_str(), cache, &visibility)
414        .await
415        .map_err(|e| db_error(&e))?;
416
417    Ok(Json(Pagination {
418        crates,
419        page_size: page.size().get(),
420        page: page.page(),
421        total,
422    }))
423}
424
425#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, ToSchema, utoipa::IntoParams)]
426pub struct CrateDataParams {
427    name: OriginalName,
428}
429
430/// Get detailed crate data
431#[utoipa::path(
432    get,
433    path = "/crate_data",
434    tag = "ui",
435    params(CrateDataParams),
436    responses(
437        (status = 200, description = "Crate details", body = CrateData),
438        (status = 404, description = "Crate not found or download-restricted without access")
439    )
440)]
441pub async fn crate_data(
442    Query(params): Query<CrateDataParams>,
443    State(db): DbState,
444    user: Option<AnyUser>,
445) -> Result<Json<CrateData>, ApiError> {
446    let index_name = CanonicalName::from(params.name);
447    // Same response as a missing crate to not reveal that a restricted crate exists.
448    match can_read_crate(&index_name, user.as_deref(), &db).await {
449        Ok(true) => {}
450        Ok(false) => return Err(StatusCode::NOT_FOUND.into()),
451        Err(e) => {
452            error!("Failed to check access to crate {index_name}: {e}");
453            return Err(StatusCode::INTERNAL_SERVER_ERROR.into());
454        }
455    }
456    let mut cd = db.get_crate_data(&index_name).await.map_err(|e| match e {
457        DbError::CrateNotFound(_) => StatusCode::NOT_FOUND,
458        _ => StatusCode::INTERNAL_SERVER_ERROR,
459    })?;
460    hide_restricted_dep_info(&mut cd, user.as_deref(), &db)
461        .await
462        .map_err(|e| {
463            error!("Failed to check access to the dependencies of {index_name}: {e}");
464            StatusCode::INTERNAL_SERVER_ERROR
465        })?;
466    Ok(Json(cd))
467}
468
469/// Removes the description and latest version of dependencies that are
470/// download-restricted crates `user` cannot access, so that a crate page does
471/// not reveal them.
472async fn hide_restricted_dep_info(
473    cd: &mut CrateData,
474    user: Option<&Identity>,
475    db: &Arc<dyn DbProvider>,
476) -> Result<(), DbError> {
477    let mut readable: HashMap<String, bool> = HashMap::new();
478    for dep in cd
479        .versions
480        .iter_mut()
481        .flat_map(|v| v.dependencies.iter_mut())
482    {
483        if dep.registry.as_deref() == Some(CRATESIO_INDEX) {
484            continue;
485        }
486        // A name no crate can have cannot be restricted
487        let Ok(name) = CanonicalName::try_from(dep.name.as_str()) else {
488            continue;
489        };
490        let can_read = if let Some(can_read) = readable.get(name.as_str()) {
491            *can_read
492        } else {
493            let can_read = can_read_crate(&name, user, db).await?;
494            readable.insert(name.to_string(), can_read);
495            can_read
496        };
497        if !can_read {
498            dep.description = None;
499            dep.latest_version = None;
500            dep.outdated = false;
501        }
502    }
503    Ok(())
504}
505
506#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, ToSchema, utoipa::IntoParams)]
507pub struct CrateDependentsParams {
508    name: OriginalName,
509}
510
511/// Get the crates whose latest version depends on a crate
512#[utoipa::path(
513    get,
514    path = "/crate_dependents",
515    tag = "ui",
516    params(CrateDependentsParams),
517    responses(
518        (status = 200, description = "Dependents of the crate", body = CrateDependents),
519        (status = 404, description = "Crate not found or download-restricted without access")
520    )
521)]
522pub async fn crate_dependents(
523    Query(params): Query<CrateDependentsParams>,
524    State(db): DbState,
525    user: Option<AnyUser>,
526) -> Result<Json<CrateDependents>, ApiError> {
527    let index_name = CanonicalName::from(params.name);
528    // Same response as a missing crate to not reveal that a restricted crate exists.
529    match can_read_crate(&index_name, user.as_deref(), &db).await {
530        Ok(true) => {}
531        Ok(false) => return Err(StatusCode::NOT_FOUND.into()),
532        Err(e) => {
533            error!("Failed to check access to crate {index_name}: {e}");
534            return Err(StatusCode::INTERNAL_SERVER_ERROR.into());
535        }
536    }
537    let mut cd = db
538        .get_crate_dependents(&index_name)
539        .await
540        .map_err(|e| match e {
541            DbError::CrateNotFound(_) => StatusCode::NOT_FOUND,
542            _ => StatusCode::INTERNAL_SERVER_ERROR,
543        })?;
544    hide_restricted_dependents(&mut cd, user.as_deref(), &db)
545        .await
546        .map_err(|e| {
547            error!("Failed to check access to the dependents of {index_name}: {e}");
548            StatusCode::INTERNAL_SERVER_ERROR
549        })?;
550    Ok(Json(cd))
551}
552
553/// Removes the dependents that are download-restricted crates `user` cannot
554/// access and only counts them, so that the crate page shows that they exist
555/// but nothing about them.
556async fn hide_restricted_dependents(
557    cd: &mut CrateDependents,
558    user: Option<&Identity>,
559    db: &Arc<dyn DbProvider>,
560) -> Result<(), DbError> {
561    let mut visible = Vec::with_capacity(cd.dependents.len());
562    for dependent in std::mem::take(&mut cd.dependents) {
563        let can_read = match CanonicalName::try_from(dependent.name.as_str()) {
564            Ok(name) => can_read_crate(&name, user, db).await?,
565            Err(_) => false,
566        };
567        if can_read {
568            visible.push(dependent);
569        } else {
570            cd.restricted += 1;
571        }
572    }
573    cd.dependents = visible;
574    Ok(())
575}
576
577#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, ToSchema, utoipa::IntoParams)]
578pub struct CratesIoDataParams {
579    name: OriginalName,
580}
581
582/// Get crate data from crates.io
583#[utoipa::path(
584    get,
585    path = "/cratesio_data",
586    tag = "ui",
587    params(CratesIoDataParams),
588    responses(
589        (
590            status = 200,
591            description = "The JSON answer of the crates.io API for the crate \
592                (`GET https://crates.io/api/v1/crates/{name}`), passed through unchanged",
593            body = Object,
594            content_type = "application/json"
595        ),
596        (status = 404, description = "Crate not found"),
597        (status = 502, description = "crates.io failed or cannot be reached")
598    )
599)]
600pub async fn cratesio_data(
601    State(settings): SettingsState,
602    State(client): ProxyClientState,
603    Query(params): Query<CratesIoDataParams>,
604) -> Result<([(header::HeaderName, &'static str); 1], String), ApiError> {
605    let url = settings
606        .proxy
607        .api
608        .join(&params.name)
609        .map_err(internal_error)?;
610
611    let resp = client
612        .get(url)
613        .header("Accept", "application/json")
614        .send()
615        .await
616        .map_err(|e| {
617            error!("Failed to get crates.io data: {e}");
618            StatusCode::BAD_GATEWAY
619        })?;
620
621    match resp.status() {
622        StatusCode::OK => match resp.text().await {
623            Ok(body) => Ok(([(header::CONTENT_TYPE, "application/json")], body)),
624            Err(e) => {
625                error!("Failed to read crates.io data: {e}");
626                Err(StatusCode::BAD_GATEWAY.into())
627            }
628        },
629        StatusCode::NOT_FOUND => Err(StatusCode::NOT_FOUND.into()),
630        status => {
631            error!("Failed to get crates.io data: {status}");
632            Err(StatusCode::BAD_GATEWAY.into())
633        }
634    }
635}
636
637/// Helper function to delete crate versions from storage, db, and docs.
638/// If `versions` is `None`, all versions of the crate are deleted.
639///
640/// The crate file is deleted before the database entry, so that a failure
641/// cannot leave a file behind that no database entry refers to. A failed
642/// version does not stop the deletion of the others.
643async fn delete_crate_versions_impl(
644    state: &kellnr_http::state::AppStateData,
645    name: &OriginalName,
646    versions: Option<Vec<Version>>,
647) -> Result<(), ApiError> {
648    let canonical_name = name.to_canonical();
649    // Crate files are stored under the name the crate was first published
650    // with, which may differ in case or separators from the requested name.
651    let original_name = state
652        .db
653        .get_crate_original_name(&canonical_name)
654        .await?
655        .ok_or_else(|| ApiError::not_found(format!("Crate not found: {name}")))?;
656
657    let versions_to_delete = if let Some(v) = versions {
658        v
659    } else {
660        state
661            .db
662            .get_crate_meta_list(&canonical_name)
663            .await?
664            .iter()
665            .filter_map(|cm| {
666                Version::try_from(cm.version.as_str())
667                    .inspect_err(|_| {
668                        warn!("Skipping invalid version {} of crate {name}", cm.version);
669                    })
670                    .ok()
671            })
672            .collect()
673    };
674
675    let mut first_error = None;
676    for version in &versions_to_delete {
677        if let Err(e) = delete_one_version(state, &original_name, version).await {
678            first_error.get_or_insert(e);
679        }
680    }
681
682    first_error.map_or(Ok(()), Err)
683}
684
685async fn delete_one_version(
686    state: &kellnr_http::state::AppStateData,
687    name: &OriginalName,
688    version: &Version,
689) -> Result<(), ApiError> {
690    let canonical_name = name.to_canonical();
691
692    // A missing file is fine, e.g. when an earlier deletion failed after it.
693    let exists = state
694        .crate_storage
695        .exists(name, version)
696        .await
697        .map_err(|e| {
698            error!("Failed to check crate {name}-{version} in storage: {e}");
699            ApiError::from(StatusCode::INTERNAL_SERVER_ERROR)
700        })?;
701    if exists && let Err(e) = state.crate_storage.delete(name, version).await {
702        error!("Failed to delete crate {name}-{version} from storage: {e}");
703        return Err(ApiError::from(StatusCode::INTERNAL_SERVER_ERROR));
704    }
705
706    state
707        .db
708        .delete_crate(&canonical_name, version)
709        .await
710        .inspect_err(|e| error!("Failed to delete crate {name}-{version} from database: {e}"))?;
711
712    // The version may be published again with another license
713    state
714        .download_filters
715        .registry
716        .licenses()
717        .forget(&canonical_name, version);
718
719    if let Err(e) = kellnr_docs::delete(name, version, &state.docs_storage).await {
720        error!("Failed to delete docs of crate {name}-{version}: {e}");
721        return Err(ApiError::from(StatusCode::INTERNAL_SERVER_ERROR));
722    }
723
724    Ok(())
725}
726
727/// Delete a specific version of a crate (path parameter version)
728#[utoipa::path(
729    delete,
730    path = "/crates/{name}/{version}",
731    tag = "ui",
732    params(
733        ("name" = String, Path, description = "Crate name"),
734        ("version" = String, Path, description = "Version to delete")
735    ),
736    responses(
737        (status = 200, description = "Crate version deleted"),
738        (status = 400, description = "Invalid parameters"),
739        (status = 403, description = "Admin access required")
740    ),
741    security(("session_cookie" = []))
742)]
743pub async fn delete_crate_version(
744    Path((name, version)): Path<(String, String)>,
745    _user: AdminUser,
746    State(state): AppState,
747) -> Result<(), ApiError> {
748    let name = OriginalName::try_from(name.as_str())?;
749    let version = Version::try_from(version.as_str())?;
750
751    delete_crate_versions_impl(&state, &name, Some(vec![version])).await
752}
753
754/// Delete all versions of a crate (path parameter version)
755#[utoipa::path(
756    delete,
757    path = "/crates/{name}",
758    tag = "ui",
759    params(
760        ("name" = String, Path, description = "Crate name")
761    ),
762    responses(
763        (status = 200, description = "All crate versions deleted"),
764        (status = 400, description = "Invalid parameters"),
765        (status = 403, description = "Admin access required")
766    ),
767    security(("session_cookie" = []))
768)]
769pub async fn delete_crate_all(
770    Path(name): Path<String>,
771    _user: AdminUser,
772    State(state): AppState,
773) -> Result<(), ApiError> {
774    let name = OriginalName::try_from(name.as_str())?;
775
776    delete_crate_versions_impl(&state, &name, None).await
777}
778
779#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, ToSchema)]
780pub struct Statistic {
781    pub num_crates: u64,
782    pub num_crate_versions: u64,
783    pub num_crate_downloads: u64,
784    pub num_proxy_crates: u64,
785    pub num_proxy_crate_versions: u64,
786    pub num_proxy_crate_downloads: u64,
787    pub top_crates: TopCrates,
788    pub last_updated_crate: Option<(OriginalName, Version)>,
789    pub proxy_enabled: bool,
790}
791
792/// The most downloaded crates, `None` if there are fewer crates.
793#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, ToSchema)]
794pub struct TopCrates {
795    pub first: Option<(String, u64)>,
796    pub second: Option<(String, u64)>,
797    pub third: Option<(String, u64)>,
798}
799
800/// Get registry statistics
801#[utoipa::path(
802    get,
803    path = "/statistics",
804    tag = "ui",
805    responses(
806        (status = 200, description = "Registry statistics", body = Statistic)
807    )
808)]
809pub async fn statistic(
810    State(db): DbState,
811    State(settings): SettingsState,
812    user: Option<AnyUser>,
813) -> Result<Json<Statistic>, ApiError> {
814    let num_crates = db
815        .get_total_unique_crates()
816        .await
817        .map_err(|e| db_error(&e))?;
818    let num_crate_versions = db
819        .get_total_crate_versions()
820        .await
821        .map_err(|e| db_error(&e))?;
822    let num_crate_downloads = db.get_total_downloads().await.map_err(|e| db_error(&e))?;
823    // Crate names are only listed if the caller may see them. The totals
824    // are aggregates and do not reveal individual crates.
825    let visibility = crate_visibility(user.as_deref());
826    let mut tops = db
827        .get_top_crates_downloads(3, &visibility)
828        .await
829        .map_err(|e| db_error(&e))?
830        .into_iter();
831    let num_proxy_crates = db
832        .get_total_unique_cached_crates()
833        .await
834        .map_err(|e| db_error(&e))?;
835    let num_proxy_crate_versions = db
836        .get_total_cached_crate_versions()
837        .await
838        .map_err(|e| db_error(&e))?;
839    let num_proxy_crate_downloads = db
840        .get_total_cached_downloads()
841        .await
842        .map_err(|e| db_error(&e))?;
843    let last_updated_crate = db
844        .get_last_updated_crate(&visibility)
845        .await
846        .map_err(|e| db_error(&e))?;
847
848    Ok(Json(Statistic {
849        num_crates,
850        num_crate_versions,
851        num_crate_downloads,
852        num_proxy_crates,
853        num_proxy_crate_versions,
854        num_proxy_crate_downloads,
855        top_crates: TopCrates {
856            first: tops.next(),
857            second: tops.next(),
858            third: tops.next(),
859        },
860        last_updated_crate,
861        proxy_enabled: settings.proxy.enabled,
862    }))
863}
864
865/// Parameters for triggering a documentation build
866#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, ToSchema, utoipa::IntoParams)]
867pub struct BuildParams {
868    /// Package name
869    package: OriginalName,
870    /// Package version
871    version: Version,
872}
873
874/// Trigger documentation build for a crate
875///
876/// Add a crate version to the documentation build queue.
877/// Requires ownership of the crate without read-only restriction or admin access.
878#[utoipa::path(
879    post,
880    path = "/builds",
881    tag = "docs",
882    params(BuildParams),
883    responses(
884        (status = 200, description = "Build queued successfully"),
885        (status = 400, description = "Crate or version does not exist"),
886        (status = 401, description = "Not logged in"),
887        (status = 403, description = "Not an owner of the crate or read-only")
888    ),
889    security(("session_cookie" = []))
890)]
891pub async fn build_rustdoc(
892    Query(params): Query<BuildParams>,
893    State(state): AppState,
894    user: SessionUser,
895) -> Result<(), ApiError> {
896    if !state.settings.docs.enabled {
897        return Err(ApiError::bad_request(
898            "Documentation generation is disabled",
899        ));
900    }
901
902    let canonical_name = CanonicalName::from(params.package);
903    let db = state.db;
904    let version = params.version;
905
906    // If the user is a non-read-only owner of the crate or any admin user,
907    // the build operation is allowed. This comes before the version check, so
908    // that others cannot tell which crates and versions exist: they get the
909    // same error for a missing crate.
910    authz::require_owner(&canonical_name, &user, &db).await?;
911    authz::require_writable(&user)?;
912
913    // Check if crate with the version exists.
914    let Some(original_name) = db
915        .get_original_name(&canonical_name, &version)
916        .await
917        .map_err(internal_error)?
918    else {
919        return Err(ApiError::bad_request(format!(
920            "Crate with version does not exist: {canonical_name}-{version}"
921        )));
922    };
923
924    // Add to build queue
925    db.add_doc_queue(
926        &original_name,
927        &version,
928        &state
929            .crate_storage
930            .create_rand_doc_queue_path()
931            .await
932            .map_err(internal_error)?,
933    )
934    .await
935    .map_err(internal_error)?;
936
937    Ok(())
938}
939
940/// Logs `e` and returns a 500 without leaking details to the client.
941fn internal_error(e: impl std::fmt::Display) -> StatusCode {
942    error!("Internal server error: {e}");
943    StatusCode::INTERNAL_SERVER_ERROR
944}
945
946/// Response for [`migrate_docs_storage`].
947#[derive(Serialize, Deserialize, ToSchema)]
948pub struct MigrateDocsStorageResponse {
949    /// `false` when there was no local docs directory to migrate from.
950    pub started: bool,
951    /// Number of crate/version pairs found under the local docs directory.
952    pub crate_versions_found: usize,
953}
954
955/// Copy locally-stored docs into the configured S3/GCS docs bucket
956///
957/// One-time backfill for existing deployments that switch `s3.docs_bucket`/
958/// `gcs.docs_bucket` from unset to a bucket name: copies every file already
959/// on local disk (both auto-generated and manually-published docs use the
960/// same on-disk layout) into the now-configured backend. Non-destructive —
961/// local files are left in place, so this is safe to re-run. Runs in the
962/// background since a large registry can take minutes to copy; admin-only.
963#[utoipa::path(
964    post,
965    path = "/migrate-storage",
966    tag = "docs",
967    responses(
968        (status = 200, description = "No local docs found to migrate", body = MigrateDocsStorageResponse),
969        (status = 202, description = "Migration started in the background", body = MigrateDocsStorageResponse),
970        (status = 400, description = "Docs are still backed by local disk, nothing to migrate to"),
971        (status = 401, description = "Not authorized"),
972        (status = 403, description = "Not an admin")
973    ),
974    security(("session_cookie" = []))
975)]
976pub async fn migrate_docs_storage(
977    State(state): AppState,
978    _admin: AdminUser,
979) -> Result<(StatusCode, Json<MigrateDocsStorageResponse>), ApiError> {
980    if state.settings.docs_backend_is_local() {
981        return Err(ApiError::bad_request(
982            "Docs are stored locally, there is nothing to migrate",
983        ));
984    }
985
986    let docs_path = PathBuf::from(state.settings.docs_path());
987    let crate_versions = match list_local_crate_versions(&docs_path).await {
988        Ok(crate_versions) => crate_versions,
989        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
990        Err(e) => {
991            error!("Failed to read local docs directory {docs_path:?}: {e}");
992            return Err(ApiError::from(StatusCode::INTERNAL_SERVER_ERROR));
993        }
994    };
995
996    if crate_versions.is_empty() {
997        return Ok((
998            StatusCode::OK,
999            Json(MigrateDocsStorageResponse {
1000                started: false,
1001                crate_versions_found: 0,
1002            }),
1003        ));
1004    }
1005
1006    let crate_versions_found = crate_versions.len();
1007    let docs_storage = state.docs_storage.clone();
1008    state.background_tasks.spawn_once("docs storage migration", async move {
1009        for (crate_name, version, dir) in crate_versions {
1010            // Keep the folder names, the docs links in the database point to them.
1011            let name = DocsName::from_folder(crate_name.clone());
1012            match upload_dir_and_prune(&dir, "", &name, &version, &docs_storage).await {
1013                Ok(()) => {
1014                    tracing::info!(
1015                        "Migrated local docs for {crate_name} {version} to configured storage backend"
1016                    );
1017                }
1018                Err(e) => {
1019                    error!(
1020                        "Failed to migrate local docs for {crate_name} {version} to configured storage backend: {e}"
1021                    );
1022                }
1023            }
1024        }
1025        tracing::info!(
1026            "Finished docs storage migration for {crate_versions_found} crate/version(s)"
1027        );
1028    });
1029
1030    Ok((
1031        StatusCode::ACCEPTED,
1032        Json(MigrateDocsStorageResponse {
1033            started: true,
1034            crate_versions_found,
1035        }),
1036    ))
1037}
1038
1039/// Enumerate `{docs_path}/{crate_name}/{version}/` two levels deep. Cheap —
1040/// only lists directory entries, doesn't read any doc file contents.
1041async fn list_local_crate_versions(
1042    docs_path: &std::path::Path,
1043) -> std::io::Result<Vec<(String, Version, PathBuf)>> {
1044    let mut result = Vec::new();
1045    let mut crate_dirs = tokio::fs::read_dir(docs_path).await?;
1046    while let Some(crate_entry) = crate_dirs.next_entry().await? {
1047        if !crate_entry.file_type().await?.is_dir() {
1048            continue;
1049        }
1050        let crate_name = crate_entry.file_name().to_string_lossy().into_owned();
1051
1052        let mut version_dirs = tokio::fs::read_dir(crate_entry.path()).await?;
1053        while let Some(version_entry) = version_dirs.next_entry().await? {
1054            if !version_entry.file_type().await?.is_dir() {
1055                continue;
1056            }
1057            let folder = version_entry.file_name().to_string_lossy().into_owned();
1058            let Ok(version) = Version::try_from(folder.as_str()) else {
1059                warn!("Skipping local docs of {crate_name} {folder}, the version is invalid");
1060                continue;
1061            };
1062            result.push((crate_name.clone(), version, version_entry.path()));
1063        }
1064    }
1065    Ok(result)
1066}
1067
1068#[cfg(test)]
1069mod tests {
1070    use std::sync::Arc;
1071    use std::time::Duration;
1072
1073    use axum::Router;
1074    use axum::body::Body;
1075    use axum::http::Response;
1076    use axum::routing::{get, post};
1077    use bytes::Bytes;
1078    use http_body_util::BodyExt;
1079    use hyper::{Request, header};
1080    use kellnr_common::account_name::UserName;
1081    use kellnr_common::background_tasks::BackgroundTasks;
1082    use kellnr_common::crate_data::{CrateRegistryDep, CrateVersionData};
1083    use kellnr_common::crate_dependents::CrateDependent;
1084    use kellnr_common::publish_metadata::{PublishMetadata, RegistryDep};
1085    use kellnr_db::UserFlags;
1086    use kellnr_db::test_utils::{
1087        TestCrateBuilder, default_created, test_add_cached_crate_with_downloads, test_drop_table,
1088    };
1089    use kellnr_http::state::AppStateData;
1090    use kellnr_http::test_support::{
1091        TestServer, session_cookie, session_identity, sqlite_test_state, test_state,
1092    };
1093    use kellnr_settings::{Postgresql, Settings, Setup};
1094    use kellnr_storage::cached_crate_storage::DynStorage;
1095    use kellnr_storage::docs_storage::DocsStorage;
1096    use kellnr_storage::fs_storage::FSStorage;
1097    use kellnr_storage::kellnr_crate_storage::KellnrCrateStorage;
1098    use kellnr_storage::object_storage::MemoryStorage;
1099    use kellnr_storage::storage_error::StorageError;
1100    use kellnr_storage::storage_key::StorageKey;
1101    use kellnr_test_db::TestDb;
1102    use serde::de::DeserializeOwned;
1103    use tower::ServiceExt;
1104
1105    use super::*;
1106
1107    fn app(state: AppStateData) -> Router {
1108        Router::new()
1109            .route("/search", get(search))
1110            .route("/crates", get(crates))
1111            .route("/crate_data", get(crate_data))
1112            .route("/crate_dependents", get(crate_dependents))
1113            .route("/version", get(kellnr_version))
1114            .route("/statistic", get(statistic))
1115            .route("/build", post(build_rustdoc))
1116            .route("/cratesio_data", get(cratesio_data))
1117            .route("/settings", get(settings))
1118            .route("/docs_enabled", get(docs_enabled))
1119            .route("/sponsor_links_enabled", get(sponsor_links_enabled))
1120            .route("/migrate-storage", post(migrate_docs_storage))
1121            .with_state(state)
1122    }
1123
1124    /// `state` with `settings` and an empty crate storage that uses them.
1125    fn with_settings(state: AppStateData, settings: Settings) -> AppStateData {
1126        let crate_storage = KellnrCrateStorage::new(&settings, Box::new(MemoryStorage::new()));
1127        AppStateData {
1128            settings: Arc::new(settings),
1129            crate_storage: Arc::new(crate_storage),
1130            ..state
1131        }
1132    }
1133
1134    /// Sends a GET request, with a session if `cookie` is not empty.
1135    async fn get_with(state: AppStateData, uri: &str, cookie: &str) -> Response<Body> {
1136        send(state, Request::get(uri), cookie).await
1137    }
1138
1139    async fn post_with(state: AppStateData, uri: &str, cookie: &str) -> Response<Body> {
1140        send(state, Request::post(uri), cookie).await
1141    }
1142
1143    async fn send(
1144        state: AppStateData,
1145        request: hyper::http::request::Builder,
1146        cookie: &str,
1147    ) -> Response<Body> {
1148        let request = if cookie.is_empty() {
1149            request
1150        } else {
1151            request.header(header::COOKIE, cookie)
1152        };
1153        app(state)
1154            .oneshot(request.body(Body::empty()).unwrap())
1155            .await
1156            .unwrap()
1157    }
1158
1159    async fn json<T: DeserializeOwned>(response: Response<Body>) -> T {
1160        assert_eq!(StatusCode::OK, response.status());
1161        let body = response.into_body().collect().await.unwrap().to_bytes();
1162        serde_json::from_slice(&body).unwrap()
1163    }
1164
1165    async fn add_crate(db: &TestDb, name: &str, version: &str) {
1166        TestCrateBuilder::new(db)
1167            .name(name)
1168            .version(version)
1169            .build()
1170            .await
1171            .unwrap();
1172    }
1173
1174    async fn add_crate_with_downloads(db: &TestDb, name: &str, downloads: i64) {
1175        TestCrateBuilder::new(db)
1176            .name(name)
1177            .downloads(downloads)
1178            .build()
1179            .await
1180            .unwrap();
1181    }
1182
1183    /// Publishes `pm` with `admin` as owner.
1184    async fn publish(db: &TestDb, pm: PublishMetadata) {
1185        db.add_crate(
1186            &pm,
1187            "cksum",
1188            1024,
1189            &default_created(),
1190            &UserName::new("admin").unwrap(),
1191            None,
1192        )
1193        .await
1194        .unwrap();
1195    }
1196
1197    async fn add_user(db: &TestDb, name: &str) {
1198        db.add_user(&UserName::new(name).unwrap(), "pwd", UserFlags::normal())
1199            .await
1200            .unwrap();
1201    }
1202
1203    /// Adds the download-restricted crate `name`, owned by `admin`.
1204    async fn add_restricted_crate(db: &TestDb, name: &str) {
1205        add_crate(db, name, "1.0.0").await;
1206        db.change_download_restricted(&canonical(name), true)
1207            .await
1208            .unwrap();
1209    }
1210
1211    async fn add_crate_user(db: &TestDb, name: &str, user: &str) {
1212        db.add_crate_user(&canonical(name), &UserName::new(user).unwrap())
1213            .await
1214            .unwrap();
1215    }
1216
1217    fn canonical(name: &str) -> CanonicalName {
1218        CanonicalName::try_from(name).unwrap()
1219    }
1220
1221    async fn versions(db: &TestDb, name: &str) -> Vec<String> {
1222        db.get_crate_versions(&canonical(name))
1223            .await
1224            .unwrap()
1225            .iter()
1226            .map(ToString::to_string)
1227            .collect()
1228    }
1229
1230    fn names(crates: &[CrateOverview]) -> Vec<&str> {
1231        crates.iter().map(|c| c.name.as_str()).collect()
1232    }
1233
1234    #[tokio::test]
1235    async fn deleting_a_version_forgets_its_known_license() {
1236        let data_dir = tempfile::tempdir().unwrap();
1237        let (state, db) = delete_state(data_dir.path()).await;
1238        add_crate(&db, "licensed", "1.0.0").await;
1239        let name = OriginalName::try_from("licensed").unwrap();
1240        let version = Version::try_from("1.0.0").unwrap();
1241        state
1242            .crate_storage
1243            .put(&name, &version, Bytes::from_static(b"crate"))
1244            .await
1245            .unwrap();
1246        let licenses = state.download_filters.registry.licenses();
1247        licenses.insert(
1248            &name.to_canonical(),
1249            &version,
1250            kellnr_filter::KnownLicense::Declared("MIT".to_string()),
1251        );
1252
1253        delete_crate_versions_impl(&state, &name, Some(vec![version.clone()]))
1254            .await
1255            .unwrap();
1256
1257        // A version published again with another license is looked up anew
1258        assert_eq!(None, licenses.get(&name.to_canonical(), &version));
1259    }
1260
1261    async fn delete_state(data_dir: &std::path::Path) -> (AppStateData, TestDb) {
1262        let (state, db) = sqlite_test_state().await;
1263        let settings = kellnr_settings::test_settings_in(data_dir);
1264        let storage = Box::new(FSStorage::new(&settings.crates_path()).unwrap()) as DynStorage;
1265        let docs_storage = Arc::new(DocsStorage::new(Box::new(
1266            FSStorage::new(&settings.docs_path()).unwrap(),
1267        ) as DynStorage));
1268        let state = AppStateData {
1269            crate_storage: Arc::new(KellnrCrateStorage::new(&settings, storage)),
1270            settings: Arc::new(settings),
1271            docs_storage,
1272            ..state
1273        };
1274        (state, db)
1275    }
1276
1277    /// Storage that records deleted keys. Every key exists, deleting
1278    /// `fail_key` fails.
1279    struct RecordingStorage {
1280        deleted: Arc<std::sync::Mutex<Vec<String>>>,
1281        fail_key: Option<&'static str>,
1282    }
1283
1284    #[async_trait::async_trait]
1285    impl kellnr_storage::storage::Storage for RecordingStorage {
1286        async fn get(&self, _key: &StorageKey) -> Result<Bytes, StorageError> {
1287            unimplemented!()
1288        }
1289        async fn get_with_meta(
1290            &self,
1291            _key: &StorageKey,
1292        ) -> Result<kellnr_storage::storage::ObjectWithMeta, StorageError> {
1293            unimplemented!()
1294        }
1295        async fn put(&self, _key: &StorageKey, _object: Bytes) -> Result<(), StorageError> {
1296            unimplemented!()
1297        }
1298        async fn put_overwrite(
1299            &self,
1300            _key: &StorageKey,
1301            _object: Bytes,
1302        ) -> Result<(), StorageError> {
1303            unimplemented!()
1304        }
1305        async fn delete(&self, key: &StorageKey) -> Result<(), StorageError> {
1306            if self.fail_key == Some(key.to_string().as_str()) {
1307                return Err(StorageError::GenericError("delete failed".to_string()));
1308            }
1309            self.deleted.lock().unwrap().push(key.to_string());
1310            Ok(())
1311        }
1312        async fn exists(&self, _key: &StorageKey) -> Result<bool, StorageError> {
1313            Ok(true)
1314        }
1315        async fn size(&self, _key: &StorageKey) -> Result<u64, StorageError> {
1316            unimplemented!()
1317        }
1318        async fn list(&self, _prefix: &str) -> Result<Vec<StorageKey>, StorageError> {
1319            Ok(vec![])
1320        }
1321    }
1322
1323    fn with_recording_storage(
1324        mut state: AppStateData,
1325        fail_key: Option<&'static str>,
1326    ) -> (AppStateData, Arc<std::sync::Mutex<Vec<String>>>) {
1327        let deleted = Arc::new(std::sync::Mutex::new(Vec::new()));
1328        let storage = Box::new(RecordingStorage {
1329            deleted: deleted.clone(),
1330            fail_key,
1331        }) as DynStorage;
1332        state.crate_storage = Arc::new(KellnrCrateStorage::new(&state.settings, storage));
1333        (state, deleted)
1334    }
1335
1336    /// Crate files are stored under the name the crate was first published
1337    /// with, not under the spelling used in the delete request.
1338    #[tokio::test]
1339    async fn deleting_a_crate_removes_the_file_of_its_original_name() {
1340        let data_dir = tempfile::tempdir().unwrap();
1341        let (state, db) = delete_state(data_dir.path()).await;
1342        add_crate(&db, "My_Crate", "1.0.0").await;
1343        let (state, deleted) = with_recording_storage(state, None);
1344
1345        let requested = OriginalName::try_from("my_crate").unwrap();
1346        delete_crate_versions_impl(&state, &requested, None)
1347            .await
1348            .unwrap();
1349
1350        assert_eq!(*deleted.lock().unwrap(), vec!["My_Crate-1.0.0.crate"]);
1351        assert!(versions(&db, "my_crate").await.is_empty());
1352    }
1353
1354    /// If the crate file cannot be deleted, the database entry must stay so
1355    /// that the crate is not left without an entry that refers to its file.
1356    #[tokio::test]
1357    async fn failed_storage_deletion_keeps_the_database_entry() {
1358        let data_dir = tempfile::tempdir().unwrap();
1359        let (state, db) = delete_state(data_dir.path()).await;
1360        add_crate(&db, "foo", "1.0.0").await;
1361        let (state, _) = with_recording_storage(state, Some("foo-1.0.0.crate"));
1362
1363        let result = delete_crate_versions_impl(
1364            &state,
1365            &OriginalName::try_from("foo").unwrap(),
1366            Some(vec![Version::try_from("1.0.0").unwrap()]),
1367        )
1368        .await;
1369
1370        assert!(result.is_err());
1371        assert_eq!(versions(&db, "foo").await, ["1.0.0"]);
1372    }
1373
1374    #[tokio::test]
1375    async fn deleting_an_unknown_crate_is_not_found() {
1376        let data_dir = tempfile::tempdir().unwrap();
1377        let (state, _db) = delete_state(data_dir.path()).await;
1378
1379        let result =
1380            delete_crate_versions_impl(&state, &OriginalName::try_from("nope").unwrap(), None)
1381                .await;
1382
1383        assert!(
1384            result
1385                .as_ref()
1386                .is_err_and(|e| e.status() == StatusCode::NOT_FOUND)
1387        );
1388    }
1389
1390    /// A failing version must not keep the remaining versions from being
1391    /// deleted.
1392    #[tokio::test]
1393    async fn deleting_all_versions_continues_after_a_failure() {
1394        let data_dir = tempfile::tempdir().unwrap();
1395        let (state, db) = delete_state(data_dir.path()).await;
1396        add_crate(&db, "foo", "1.0.0").await;
1397        add_crate(&db, "foo", "2.0.0").await;
1398        let (state, deleted) = with_recording_storage(state, Some("foo-1.0.0.crate"));
1399
1400        let result =
1401            delete_crate_versions_impl(&state, &OriginalName::try_from("foo").unwrap(), None).await;
1402
1403        assert!(result.is_err());
1404        assert_eq!(*deleted.lock().unwrap(), vec!["foo-2.0.0.crate"]);
1405        assert_eq!(versions(&db, "foo").await, ["1.0.0"]);
1406    }
1407
1408    #[tokio::test]
1409    async fn settings_returns_from_settings() {
1410        let (state, db) = sqlite_test_state().await;
1411        let cookie = session_cookie(&db, "admin", UserFlags::admin()).await;
1412
1413        let response: SettingsResponse = json(get_with(state, "/settings", &cookie).await).await;
1414
1415        // Set the password to empty string because it is not serialized
1416        // and expect the configured admin password to be redacted.
1417        let tmp = kellnr_settings::test_settings();
1418        let psq = Postgresql {
1419            pwd: String::default(),
1420            ..tmp.postgresql
1421        };
1422        let setup = Setup {
1423            admin_pwd: REDACTED.to_string(),
1424            ..tmp.setup
1425        };
1426        let expected_state = Settings {
1427            postgresql: psq,
1428            setup,
1429            ..tmp
1430        };
1431        let result_settings: Settings =
1432            serde_json::from_value(serde_json::Value::Object(response.settings)).unwrap();
1433
1434        assert_eq!(result_settings, expected_state);
1435        // `sources` is structurally part of `SettingsResponse`, successful
1436        // deserialization above is sufficient proof it round-trips.
1437        // The companion test below asserts that an override actually flows
1438        // into the `sources` map.
1439    }
1440
1441    #[tokio::test]
1442    async fn settings_response_sources_reflect_prov_overrides() {
1443        use kellnr_settings::{Config, ConfigSource, SettingsProv};
1444
1445        let (state, db) = sqlite_test_state().await;
1446        let cookie = session_cookie(&db, "admin", UserFlags::admin()).await;
1447
1448        // Build a real `SettingsProv` with one non-default leaf so the
1449        // handler must emit a non-empty `sources` entry for it. Using only
1450        // TOML keeps the test independent of any `KELLNR_*` process env.
1451        let prov: SettingsProv = Config::new()
1452            .add_toml_str(
1453                "override.toml",
1454                "[registry]\ndata_dir = \"/tmp/from-toml\"\n",
1455            )
1456            .build::<SettingsProv>()
1457            .unwrap();
1458        let state = AppStateData {
1459            settings_prov: Arc::new(prov),
1460            ..state
1461        };
1462
1463        let response: SettingsResponse = json(get_with(state, "/settings", &cookie).await).await;
1464
1465        assert_eq!(
1466            response.sources.get("registry.data_dir"),
1467            Some(&ConfigSource::Toml),
1468            "TOML-set leaf must report ConfigSource::Toml"
1469        );
1470        // An untouched leaf must surface as Default, confirms the map covers
1471        // every leaf and that File→Toml mapping doesn't bleed across keys.
1472        assert_eq!(
1473            response.sources.get("docs.enabled"),
1474            Some(&ConfigSource::Default),
1475        );
1476
1477        // The new `leaves` field must mirror the same data with the schema
1478        // metadata the dynamic UI consumes.
1479        let data_dir_leaf = response
1480            .leaves
1481            .iter()
1482            .find(|l| l.key == "registry.data_dir")
1483            .expect("registry.data_dir leaf must be present");
1484        assert_eq!(
1485            data_dir_leaf.value,
1486            serde_json::json!("/tmp/from-toml"),
1487            "leaf value matches the TOML override"
1488        );
1489        assert_eq!(data_dir_leaf.source, ConfigSource::Toml);
1490        assert_eq!(data_dir_leaf.kind, LeafKind::String);
1491        assert!(!data_dir_leaf.secret);
1492        assert_eq!(
1493            data_dir_leaf.cli_flag.as_deref(),
1494            Some("--registry-data-dir, -d"),
1495            "cli_flag comes from clap reflection, long + short"
1496        );
1497
1498        let docs_enabled = response
1499            .leaves
1500            .iter()
1501            .find(|l| l.key == "docs.enabled")
1502            .expect("docs.enabled leaf must be present");
1503        assert_eq!(docs_enabled.kind, LeafKind::Boolean);
1504        assert_eq!(docs_enabled.source, ConfigSource::Default);
1505        assert_eq!(docs_enabled.default, serde_json::json!(false));
1506
1507        // Secret leaves carry the secret flag, the UI uses it to mask the
1508        // value before rendering.
1509        let secret_leaf = response
1510            .leaves
1511            .iter()
1512            .find(|l| l.key == "s3.secret_key")
1513            .expect("s3.secret_key leaf must be present");
1514        assert!(secret_leaf.secret, "s3.secret_key must be flagged secret");
1515    }
1516
1517    #[tokio::test]
1518    async fn settings_response_redacts_secrets() {
1519        use kellnr_settings::{Config, SettingsProv};
1520
1521        const SECRETS: [&str; 3] = ["s3-secret-value", "admin-pwd-value", "admin-token-value"];
1522        let toml = format!(
1523            "[s3]\nsecret_key = \"{}\"\n[setup]\nadmin_pwd = \"{}\"\nadmin_token = \"{}\"\n",
1524            SECRETS[0], SECRETS[1], SECRETS[2]
1525        );
1526        let prov: SettingsProv = Config::new()
1527            .add_toml_str("secrets.toml", &toml)
1528            .build::<SettingsProv>()
1529            .unwrap();
1530        let mut settings = kellnr_settings::test_settings();
1531        settings.s3.secret_key = Some(SECRETS[0].to_string());
1532        settings.setup.admin_pwd = SECRETS[1].to_string();
1533        settings.setup.admin_token = Some(SECRETS[2].to_string());
1534
1535        let (state, db) = sqlite_test_state().await;
1536        let cookie = session_cookie(&db, "admin", UserFlags::admin()).await;
1537        let state = AppStateData {
1538            settings_prov: Arc::new(prov),
1539            ..with_settings(state, settings)
1540        };
1541
1542        let r = get_with(state, "/settings", &cookie).await;
1543
1544        assert_eq!(r.status(), StatusCode::OK);
1545        let body = r.into_body().collect().await.unwrap().to_bytes();
1546        let raw = String::from_utf8(body.to_vec()).unwrap();
1547        for secret in SECRETS {
1548            assert!(!raw.contains(secret), "response leaks secret {secret}");
1549        }
1550        // The default admin password must not leak via `defaults` either.
1551        assert!(!raw.contains("\"admin_pwd\":\"admin\""));
1552
1553        let response: SettingsResponse = serde_json::from_slice(&body).unwrap();
1554        let redacted = serde_json::json!(REDACTED);
1555        assert_eq!(response.settings["s3"]["secret_key"], redacted);
1556        assert_eq!(response.settings["setup"]["admin_pwd"], redacted);
1557        assert_eq!(response.settings["setup"]["admin_token"], redacted);
1558        assert_eq!(response.defaults["setup"]["admin_pwd"], redacted);
1559        assert_eq!(
1560            response.defaults["setup"]["admin_token"],
1561            serde_json::Value::Null
1562        );
1563
1564        for leaf in response.leaves.iter().filter(|leaf| leaf.secret) {
1565            assert_eq!(leaf.kind, LeafKind::String, "{}", leaf.key);
1566            assert!(
1567                leaf.value.is_null() || leaf.value == redacted,
1568                "secret leaf {} is not redacted",
1569                leaf.key
1570            );
1571        }
1572        let unset_secret = response
1573            .leaves
1574            .iter()
1575            .find(|l| l.key == "oauth2.client_secret")
1576            .expect("oauth2.client_secret leaf must be present");
1577        assert!(unset_secret.value.is_null(), "unset secret must be null");
1578    }
1579
1580    #[tokio::test]
1581    async fn docs_enabled_returns_false_by_default() {
1582        let response: DocsEnabledResponse =
1583            json(get_with(test_state(), "/docs_enabled", "").await).await;
1584
1585        assert!(!response.enabled);
1586    }
1587
1588    #[tokio::test]
1589    async fn docs_enabled_returns_true_when_enabled() {
1590        let mut settings = kellnr_settings::test_settings();
1591        settings.docs.enabled = true;
1592        let state = with_settings(test_state(), settings);
1593
1594        let response: DocsEnabledResponse = json(get_with(state, "/docs_enabled", "").await).await;
1595
1596        assert!(response.enabled);
1597    }
1598
1599    #[tokio::test]
1600    async fn sponsor_links_enabled_returns_true_by_default() {
1601        let response: SponsorLinksEnabledResponse =
1602            json(get_with(test_state(), "/sponsor_links_enabled", "").await).await;
1603
1604        assert!(response.enabled);
1605    }
1606
1607    #[tokio::test]
1608    async fn sponsor_links_enabled_returns_false_when_disabled() {
1609        let mut settings = kellnr_settings::test_settings();
1610        settings.registry.sponsor_links_enabled = false;
1611        let state = with_settings(test_state(), settings);
1612
1613        let response: SponsorLinksEnabledResponse =
1614            json(get_with(state, "/sponsor_links_enabled", "").await).await;
1615
1616        assert!(!response.enabled);
1617    }
1618
1619    /// State with docs enabled. Queueing a build writes the crate to the doc
1620    /// queue directory in `data_dir`.
1621    async fn docs_state(data_dir: &std::path::Path) -> (AppStateData, TestDb) {
1622        let (state, db) = sqlite_test_state().await;
1623        let mut settings = kellnr_settings::test_settings_in(data_dir);
1624        settings.docs.enabled = true;
1625        (with_settings(state, settings), db)
1626    }
1627
1628    async fn build_foobar_docs(state: AppStateData, cookie: &str) -> StatusCode {
1629        post_with(state, "/build?package=foobar&version=1.0.0", cookie)
1630            .await
1631            .status()
1632    }
1633
1634    async fn doc_queue_len(db: &TestDb) -> usize {
1635        db.get_doc_queue().await.unwrap().len()
1636    }
1637
1638    #[tokio::test]
1639    async fn build_rust_doc_crate_not_found() {
1640        let data_dir = tempfile::tempdir().unwrap();
1641        let (state, db) = docs_state(data_dir.path()).await;
1642        let cookie = session_cookie(&db, "admin", UserFlags::admin()).await;
1643
1644        let status = build_foobar_docs(state, &cookie).await;
1645
1646        assert_eq!(status, StatusCode::BAD_REQUEST);
1647    }
1648
1649    // A non-owner gets the same response for a missing crate as for a crate
1650    // of someone else, so that the existence of a crate is not revealed.
1651    #[tokio::test]
1652    async fn build_rust_doc_crate_not_found_for_non_owner() {
1653        let data_dir = tempfile::tempdir().unwrap();
1654        let (state, db) = docs_state(data_dir.path()).await;
1655        let cookie = session_cookie(&db, "user", UserFlags::normal()).await;
1656
1657        let status = build_foobar_docs(state, &cookie).await;
1658
1659        assert_eq!(status, StatusCode::FORBIDDEN);
1660    }
1661
1662    #[tokio::test]
1663    async fn build_rust_doc_version_not_found() {
1664        let data_dir = tempfile::tempdir().unwrap();
1665        let (state, db) = docs_state(data_dir.path()).await;
1666        let cookie = session_cookie(&db, "user", UserFlags::normal()).await;
1667        TestCrateBuilder::new(&db)
1668            .name("foobar")
1669            .owner("user")
1670            .version("2.0.0")
1671            .build()
1672            .await
1673            .unwrap();
1674
1675        let status = build_foobar_docs(state, &cookie).await;
1676
1677        assert_eq!(status, StatusCode::BAD_REQUEST);
1678    }
1679
1680    #[tokio::test]
1681    async fn build_rust_doc_not_owner() {
1682        let data_dir = tempfile::tempdir().unwrap();
1683        let (state, db) = docs_state(data_dir.path()).await;
1684        let cookie = session_cookie(&db, "user", UserFlags::normal()).await;
1685        add_crate(&db, "foobar", "1.0.0").await;
1686
1687        let status = build_foobar_docs(state, &cookie).await;
1688
1689        assert_eq!(status, StatusCode::FORBIDDEN);
1690        assert_eq!(doc_queue_len(&db).await, 0);
1691    }
1692
1693    #[tokio::test]
1694    async fn build_rust_doc_is_owner() {
1695        let data_dir = tempfile::tempdir().unwrap();
1696        let (state, db) = docs_state(data_dir.path()).await;
1697        let cookie = session_cookie(&db, "user", UserFlags::normal()).await;
1698        TestCrateBuilder::new(&db)
1699            .name("foobar")
1700            .owner("user")
1701            .build()
1702            .await
1703            .unwrap();
1704
1705        let status = build_foobar_docs(state, &cookie).await;
1706
1707        assert_eq!(status, StatusCode::OK);
1708        assert_eq!(doc_queue_len(&db).await, 1);
1709    }
1710
1711    #[tokio::test]
1712    async fn build_rust_doc_read_only_owner_is_rejected() {
1713        let data_dir = tempfile::tempdir().unwrap();
1714        let (state, db) = docs_state(data_dir.path()).await;
1715        let cookie = session_cookie(&db, "user", UserFlags::read_only()).await;
1716        TestCrateBuilder::new(&db)
1717            .name("foobar")
1718            .owner("user")
1719            .build()
1720            .await
1721            .unwrap();
1722
1723        let status = build_foobar_docs(state, &cookie).await;
1724
1725        assert_eq!(status, StatusCode::FORBIDDEN);
1726        assert_eq!(doc_queue_len(&db).await, 0);
1727    }
1728
1729    #[tokio::test]
1730    async fn build_rust_doc_not_owner_but_admin() {
1731        let data_dir = tempfile::tempdir().unwrap();
1732        let (state, db) = docs_state(data_dir.path()).await;
1733        let cookie = session_cookie(&db, "other_admin", UserFlags::admin()).await;
1734        add_crate(&db, "foobar", "1.0.0").await;
1735
1736        let status = build_foobar_docs(state, &cookie).await;
1737
1738        assert_eq!(status, StatusCode::OK);
1739        assert_eq!(doc_queue_len(&db).await, 1);
1740    }
1741
1742    fn fs_docs_storage(dir: &std::path::Path) -> DocsStorage {
1743        DocsStorage::new(Box::new(FSStorage::new(dir.to_str().unwrap()).unwrap()) as DynStorage)
1744    }
1745
1746    /// Settings with a bucket configured, i.e. `docs_backend_is_local() == false`.
1747    /// Only settings-level config is exercised here (no real S3 network access):
1748    /// the endpoint's actual copy destination is whatever `docs_storage` the
1749    /// caller wires into `AppStateData`, independent of this bucket name.
1750    fn settings_with_docs_bucket_configured(data_dir: &std::path::Path) -> Settings {
1751        let mut settings = kellnr_settings::test_settings();
1752        settings.registry.data_dir = data_dir.to_str().unwrap().to_string();
1753        settings.s3.enabled = true;
1754        settings.s3.docs_bucket = Some("kellnr-docs".to_string());
1755        settings
1756    }
1757
1758    /// Posts to `/migrate-storage` as `user`, with docs migrated to `target`.
1759    async fn migrate_storage(
1760        settings: Settings,
1761        target: &std::path::Path,
1762        background_tasks: BackgroundTasks,
1763        user: (&str, UserFlags),
1764    ) -> Response<Body> {
1765        let (state, db) = sqlite_test_state().await;
1766        let cookie = session_cookie(&db, user.0, user.1).await;
1767        let state = AppStateData {
1768            docs_storage: Arc::new(fs_docs_storage(target)),
1769            background_tasks,
1770            ..with_settings(state, settings)
1771        };
1772        post_with(state, "/migrate-storage", &cookie).await
1773    }
1774
1775    const ADMIN: (&str, UserFlags) = ("admin", UserFlags::admin());
1776
1777    #[tokio::test]
1778    async fn migrate_docs_storage_returns_403_for_non_admin() {
1779        let local = tempfile::tempdir().unwrap();
1780        let target = tempfile::tempdir().unwrap();
1781        let settings = settings_with_docs_bucket_configured(local.path());
1782
1783        let r = migrate_storage(
1784            settings,
1785            target.path(),
1786            BackgroundTasks::new(),
1787            ("user", UserFlags::normal()),
1788        )
1789        .await;
1790
1791        assert_eq!(r.status(), StatusCode::FORBIDDEN);
1792    }
1793
1794    #[tokio::test]
1795    async fn migrate_docs_storage_returns_400_when_backend_is_local_disk() {
1796        let local = tempfile::tempdir().unwrap();
1797        let target = tempfile::tempdir().unwrap();
1798        let mut settings = kellnr_settings::test_settings();
1799        settings.registry.data_dir = local.path().to_str().unwrap().to_string();
1800
1801        let r = migrate_storage(settings, target.path(), BackgroundTasks::new(), ADMIN).await;
1802
1803        assert_eq!(r.status(), StatusCode::BAD_REQUEST);
1804    }
1805
1806    #[tokio::test]
1807    async fn migrate_docs_storage_returns_zero_count_when_local_dir_missing() {
1808        let local = tempfile::tempdir().unwrap();
1809        let target = tempfile::tempdir().unwrap();
1810        // `local` exists but nothing was ever generated locally, so
1811        // `{data_dir}/docs` itself was never created.
1812        let settings = settings_with_docs_bucket_configured(local.path());
1813
1814        let r = migrate_storage(settings, target.path(), BackgroundTasks::new(), ADMIN).await;
1815
1816        let response: MigrateDocsStorageResponse = json(r).await;
1817        assert!(!response.started);
1818        assert_eq!(response.crate_versions_found, 0);
1819    }
1820
1821    #[tokio::test]
1822    async fn migrate_docs_storage_copies_local_docs_into_configured_backend() {
1823        let local = tempfile::tempdir().unwrap();
1824        let target = tempfile::tempdir().unwrap();
1825        let settings = settings_with_docs_bucket_configured(local.path());
1826
1827        // Both auto-generated and manually-published docs land in this exact
1828        // on-disk layout (see `upload_dir_and_prune`), so one local crate/
1829        // version dir stands in for either origin.
1830        let docs_path_string = settings.docs_path();
1831        let docs_path = std::path::Path::new(&docs_path_string);
1832        tokio::fs::create_dir_all(docs_path.join("crate-a/1.0.0/doc"))
1833            .await
1834            .unwrap();
1835        tokio::fs::write(
1836            docs_path.join("crate-a/1.0.0/doc/index.html"),
1837            b"crate-a docs",
1838        )
1839        .await
1840        .unwrap();
1841        tokio::fs::create_dir_all(docs_path.join("crate-b/2.0.0/doc/nested"))
1842            .await
1843            .unwrap();
1844        tokio::fs::write(
1845            docs_path.join("crate-b/2.0.0/doc/nested/page.html"),
1846            b"crate-b nested docs",
1847        )
1848        .await
1849        .unwrap();
1850
1851        let tasks = BackgroundTasks::new();
1852        let r = migrate_storage(settings, target.path(), tasks.clone(), ADMIN).await;
1853
1854        assert_eq!(r.status(), StatusCode::ACCEPTED);
1855        let body = r.into_body().collect().await.unwrap().to_bytes();
1856        let response: MigrateDocsStorageResponse = serde_json::from_slice(&body).unwrap();
1857        assert!(response.started);
1858        assert_eq!(response.crate_versions_found, 2);
1859
1860        let key_a = DocsStorage::file_key(
1861            &DocsName::from_folder("crate-a".to_string()),
1862            &Version::try_from("1.0.0").unwrap(),
1863            "doc/index.html",
1864        );
1865        let key_b = DocsStorage::file_key(
1866            &DocsName::from_folder("crate-b".to_string()),
1867            &Version::try_from("2.0.0").unwrap(),
1868            "doc/nested/page.html",
1869        );
1870        // Shutdown waits for the migration to finish.
1871        tasks.request_shutdown();
1872        tasks.wait_for_shutdown(Duration::from_secs(30)).await;
1873
1874        // A second storage on the same directory sees what the migration wrote.
1875        let target_docs_storage = fs_docs_storage(target.path());
1876        assert_eq!(
1877            target_docs_storage.get(&key_a).await.unwrap(),
1878            Bytes::from_static(b"crate-a docs")
1879        );
1880        assert_eq!(
1881            target_docs_storage.get(&key_b).await.unwrap(),
1882            Bytes::from_static(b"crate-b nested docs")
1883        );
1884    }
1885
1886    /// A failing database must not look like an empty registry.
1887    #[tokio::test]
1888    async fn statistic_database_error_is_internal_error() {
1889        let (state, db) = sqlite_test_state().await;
1890        test_drop_table(&db, "krate").await.unwrap();
1891
1892        let r = get_with(state, "/statistic", "").await;
1893
1894        assert_eq!(r.status(), StatusCode::INTERNAL_SERVER_ERROR);
1895    }
1896
1897    fn top(name: &str, downloads: u64) -> (String, u64) {
1898        (name.to_string(), downloads)
1899    }
1900
1901    fn last_updated(name: &str) -> (OriginalName, Version) {
1902        (
1903            OriginalName::from_unchecked(name.to_string()),
1904            Version::try_from("1.0.0").unwrap(),
1905        )
1906    }
1907
1908    #[tokio::test]
1909    async fn statistic_returns_sparse_statistics() {
1910        let (state, db) = sqlite_test_state().await;
1911        add_crate_with_downloads(&db, "top1", 1000).await;
1912
1913        let result: Statistic = json(get_with(state, "/statistic", "").await).await;
1914
1915        let expect = Statistic {
1916            num_crates: 1,
1917            num_crate_versions: 1,
1918            num_crate_downloads: 1000,
1919            num_proxy_crates: 0,
1920            num_proxy_crate_versions: 0,
1921            num_proxy_crate_downloads: 0,
1922            top_crates: TopCrates {
1923                first: Some(top("top1", 1000)),
1924                second: None,
1925                third: None,
1926            },
1927            last_updated_crate: Some(last_updated("top1")),
1928            proxy_enabled: false,
1929        };
1930        assert_eq!(expect, result);
1931    }
1932
1933    #[tokio::test]
1934    async fn statistic_returns_empty_statistics() {
1935        let (state, _db) = sqlite_test_state().await;
1936
1937        let result: Statistic = json(get_with(state, "/statistic", "").await).await;
1938
1939        let expect = Statistic {
1940            num_crates: 0,
1941            num_crate_versions: 0,
1942            num_crate_downloads: 0,
1943            num_proxy_crates: 0,
1944            num_proxy_crate_versions: 0,
1945            num_proxy_crate_downloads: 0,
1946            top_crates: TopCrates {
1947                first: None,
1948                second: None,
1949                third: None,
1950            },
1951            last_updated_crate: None,
1952            proxy_enabled: false,
1953        };
1954        assert_eq!(expect, result);
1955    }
1956
1957    #[tokio::test]
1958    async fn statistic_returns_crate_statistics() {
1959        let (state, db) = sqlite_test_state().await;
1960        add_crate_with_downloads(&db, "top1", 1000).await;
1961        add_crate_with_downloads(&db, "top2", 500).await;
1962        add_crate_with_downloads(&db, "top3", 100).await;
1963        TestCrateBuilder::new(&db)
1964            .name("foobar")
1965            .downloads(10)
1966            .created(default_created() + Duration::from_secs(60))
1967            .build()
1968            .await
1969            .unwrap();
1970        test_add_cached_crate_with_downloads(&db, "cached1", "1.0.0", 9)
1971            .await
1972            .unwrap();
1973        test_add_cached_crate_with_downloads(&db, "cached2", "1.0.0", 90)
1974            .await
1975            .unwrap();
1976
1977        let result: Statistic = json(get_with(state, "/statistic", "").await).await;
1978
1979        let expect = Statistic {
1980            num_crates: 4,
1981            num_crate_versions: 4,
1982            num_crate_downloads: 1610,
1983            num_proxy_crates: 2,
1984            num_proxy_crate_versions: 2,
1985            num_proxy_crate_downloads: 99,
1986            top_crates: TopCrates {
1987                first: Some(top("top1", 1000)),
1988                second: Some(top("top2", 500)),
1989                third: Some(top("top3", 100)),
1990            },
1991            last_updated_crate: Some(last_updated("foobar")),
1992            proxy_enabled: false,
1993        };
1994        assert_eq!(expect, result);
1995    }
1996
1997    #[tokio::test]
1998    async fn statistic_user_session_hides_inaccessible_crate_names() {
1999        let (state, db) = sqlite_test_state().await;
2000        let cookie = session_cookie(&db, "user", UserFlags::normal()).await;
2001        add_restricted_crate(&db, "secret").await;
2002
2003        let result: Statistic = json(get_with(state, "/statistic", &cookie).await).await;
2004
2005        assert_eq!(1, result.num_crates);
2006        assert_eq!(None, result.top_crates.first);
2007        assert_eq!(None, result.last_updated_crate);
2008    }
2009
2010    #[tokio::test]
2011    async fn kellnr_version_returns_version() {
2012        let result: KellnrVersion = json(get_with(test_state(), "/version", "").await).await;
2013
2014        assert_eq!("0.0.0-unknown", result.version);
2015    }
2016
2017    #[tokio::test]
2018    async fn search_not_hits_returns_nothing() {
2019        let (state, db) = sqlite_test_state().await;
2020        add_crate(&db, "hello", "1.0.0").await;
2021
2022        let result: Pagination = json(get_with(state, "/search?name=doesnotexist", "").await).await;
2023
2024        assert_eq!(0, result.crates.len());
2025        assert_eq!(0, result.page);
2026        assert_eq!(10, result.page_size);
2027    }
2028
2029    // The length bound lives on `NameOrDescription`. It has to hold here too,
2030    // where the value arrives through serde rather than an explicit `TryFrom`.
2031    #[tokio::test]
2032    async fn search_rejects_overlong_query() {
2033        let uri = format!("/search?name={}", "a".repeat(65));
2034
2035        let r = get_with(test_state(), &uri, "").await;
2036
2037        assert_eq!(StatusCode::BAD_REQUEST, r.status());
2038    }
2039
2040    #[tokio::test]
2041    async fn search_accepts_multi_word_query() {
2042        let (state, _db) = sqlite_test_state().await;
2043
2044        let r = get_with(state, "/search?name=vcard%20parser", "").await;
2045
2046        assert_eq!(StatusCode::OK, r.status());
2047    }
2048
2049    #[tokio::test]
2050    async fn search_returns_only_searched_results() {
2051        let (state, db) = sqlite_test_state().await;
2052        add_crate_with_downloads(&db, "hello", 2).await;
2053        add_crate(&db, "other", "1.0.0").await;
2054
2055        let result: Pagination = json(get_with(state, "/search?name=hello", "").await).await;
2056
2057        assert_eq!(names(&result.crates), ["hello"]);
2058        assert_eq!("1.0.0", result.crates[0].version);
2059        assert_eq!(2, result.crates[0].total_downloads);
2060        assert_eq!(0, result.page);
2061        assert_eq!(10, result.page_size);
2062    }
2063
2064    async fn add_hello_crates(db: &TestDb, count: usize) {
2065        for i in 0..count {
2066            add_crate(db, &format!("hello{i}"), "1.0.0").await;
2067        }
2068    }
2069
2070    // Search has to page through the database the same way the crate overview
2071    // does, instead of asking for a single fixed-size window. A page beyond the
2072    // first is only reachable if `page` is translated into an offset.
2073    #[tokio::test]
2074    async fn search_paginates_like_crate_overview() {
2075        let (state, db) = sqlite_test_state().await;
2076        add_hello_crates(&db, 5).await;
2077
2078        let result: Pagination =
2079            json(get_with(state, "/search?name=hello&page=1&page_size=2", "").await).await;
2080
2081        assert_eq!(names(&result.crates), ["hello2", "hello3"]);
2082        assert_eq!(1, result.page);
2083        assert_eq!(2, result.page_size);
2084    }
2085
2086    // The page size is whatever the caller asked for, so a full page cannot be
2087    // mistaken for the end of the results.
2088    #[tokio::test]
2089    async fn search_reports_requested_page_size_on_a_full_page() {
2090        let (state, db) = sqlite_test_state().await;
2091        add_hello_crates(&db, 3).await;
2092
2093        let result: Pagination =
2094            json(get_with(state, "/search?name=hello&page_size=2", "").await).await;
2095
2096        assert_eq!(2, result.crates.len());
2097        assert_eq!(2, result.page_size);
2098    }
2099
2100    #[tokio::test]
2101    async fn search_admin_session_lists_all_crates() {
2102        let (state, db) = sqlite_test_state().await;
2103        let cookie = session_cookie(&db, "admin", UserFlags::admin()).await;
2104        add_crate(&db, "foo", "1.0.0").await;
2105        add_restricted_crate(&db, "foo-secret").await;
2106
2107        let result: Pagination = json(get_with(state, "/search?name=foo", &cookie).await).await;
2108
2109        assert_eq!(names(&result.crates), ["foo", "foo-secret"]);
2110    }
2111
2112    #[tokio::test]
2113    async fn crate_get_crate_information() {
2114        let (state, db) = sqlite_test_state().await;
2115        publish(
2116            &db,
2117            PublishMetadata {
2118                name: "crate1".to_string(),
2119                vers: "1.0.0".to_string(),
2120                description: Some("description".to_string()),
2121                homepage: Some("homepage".to_string()),
2122                readme: Some("readme".to_string()),
2123                license: Some("MIT".to_string()),
2124                keywords: vec!["key1".to_string()],
2125                categories: vec!["cat1".to_string()],
2126                authors: Some(vec!["author1".to_string()]),
2127                ..PublishMetadata::default()
2128            },
2129        )
2130        .await;
2131
2132        let result: CrateData =
2133            json(get_with(state, "/crate_data?name=crate1&version=1.0.0", "").await).await;
2134
2135        assert_eq!(
2136            db.get_crate_data(&canonical("crate1")).await.unwrap(),
2137            result
2138        );
2139        assert_eq!("crate1", result.name);
2140        assert_eq!(Some("description".to_string()), result.description);
2141        assert_eq!(Some("readme".to_string()), result.versions[0].readme);
2142    }
2143
2144    #[tokio::test]
2145    async fn crate_data_hides_dependency_info_of_restricted_crates() {
2146        let db = TestDb::sqlite().await;
2147        add_restricted_crate(&db, "secret").await;
2148        add_crate(&db, "public", "1.0.0").await;
2149
2150        let dep = |name: &str, registry: Option<&str>| CrateRegistryDep {
2151            name: name.to_string(),
2152            description: Some("description".to_string()),
2153            version_req: "^1".to_string(),
2154            registry: registry.map(str::to_string),
2155            latest_version: Some("2.0.0".to_string()),
2156            outdated: true,
2157            ..CrateRegistryDep::default()
2158        };
2159        let mut cd = CrateData {
2160            versions: vec![CrateVersionData {
2161                dependencies: vec![
2162                    dep("secret", None),
2163                    dep("public", None),
2164                    dep("serde", Some(CRATESIO_INDEX)),
2165                ],
2166                ..CrateVersionData::default()
2167            }],
2168            ..CrateData::default()
2169        };
2170
2171        hide_restricted_dep_info(&mut cd, None, &db.provider())
2172            .await
2173            .unwrap();
2174
2175        let deps = &cd.versions[0].dependencies;
2176        assert_eq!(None, deps[0].description);
2177        assert_eq!(None, deps[0].latest_version);
2178        assert!(!deps[0].outdated);
2179        assert_eq!(dep("public", None), deps[1]);
2180        assert_eq!(dep("serde", Some(CRATESIO_INDEX)), deps[2]);
2181    }
2182
2183    fn dependent(name: &str) -> CrateDependent {
2184        CrateDependent {
2185            name: name.to_string(),
2186            version: "1.0.0".to_string(),
2187            outdated: true,
2188            ..CrateDependent::default()
2189        }
2190    }
2191
2192    #[tokio::test]
2193    async fn crate_dependents_counts_restricted_dependents_only() {
2194        let db = TestDb::sqlite().await;
2195        add_restricted_crate(&db, "secret").await;
2196        let mut cd = CrateDependents {
2197            latest_version: Some("2.0.0".to_string()),
2198            dependents: vec![dependent("Secret"), dependent("public")],
2199            restricted: 0,
2200        };
2201
2202        hide_restricted_dependents(&mut cd, None, &db.provider())
2203            .await
2204            .unwrap();
2205
2206        assert_eq!(vec![dependent("public")], cd.dependents);
2207        assert_eq!(1, cd.restricted);
2208    }
2209
2210    #[tokio::test]
2211    async fn crate_dependents_shows_restricted_dependents_with_access() {
2212        let db = TestDb::sqlite().await;
2213        add_user(&db, "user").await;
2214        add_restricted_crate(&db, "secret").await;
2215        add_crate_user(&db, "secret", "user").await;
2216        let user = session_identity("user", false);
2217        let mut cd = CrateDependents {
2218            dependents: vec![dependent("secret")],
2219            ..CrateDependents::default()
2220        };
2221
2222        hide_restricted_dependents(&mut cd, Some(&user), &db.provider())
2223            .await
2224            .unwrap();
2225
2226        assert_eq!(vec![dependent("secret")], cd.dependents);
2227        assert_eq!(0, cd.restricted);
2228    }
2229
2230    /// Publishes `name` 1.0.0 with a dependency on `foo`.
2231    async fn publish_foo_dependent(db: &TestDb, name: &str) {
2232        publish(
2233            db,
2234            PublishMetadata {
2235                name: name.to_string(),
2236                vers: "1.0.0".to_string(),
2237                deps: vec![RegistryDep {
2238                    name: "foo".to_string(),
2239                    version_req: "^1".to_string(),
2240                    features: None,
2241                    optional: false,
2242                    default_features: true,
2243                    target: None,
2244                    kind: Some("normal".to_string()),
2245                    registry: None,
2246                    explicit_name_in_toml: None,
2247                }],
2248                ..PublishMetadata::default()
2249            },
2250        )
2251        .await;
2252    }
2253
2254    #[tokio::test]
2255    async fn crate_dependents_returns_dependents() {
2256        let (state, db) = sqlite_test_state().await;
2257        add_crate(&db, "foo", "1.0.0").await;
2258        publish_foo_dependent(&db, "public").await;
2259        publish_foo_dependent(&db, "secret").await;
2260        db.change_download_restricted(&canonical("secret"), true)
2261            .await
2262            .unwrap();
2263
2264        let cd: CrateDependents =
2265            json(get_with(state, "/crate_dependents?name=Foo", "").await).await;
2266
2267        assert_eq!(Some("1.0.0".to_string()), cd.latest_version);
2268        assert_eq!(
2269            ["public"],
2270            cd.dependents
2271                .iter()
2272                .map(|d| d.name.as_str())
2273                .collect::<Vec<_>>()[..]
2274        );
2275        assert_eq!(1, cd.restricted);
2276    }
2277
2278    #[tokio::test]
2279    async fn crate_dependents_of_restricted_crate_anonymous_returns_not_found() {
2280        let (state, db) = sqlite_test_state().await;
2281        add_restricted_crate(&db, "secret").await;
2282
2283        let r = get_with(state, "/crate_dependents?name=secret", "").await;
2284
2285        assert_eq!(StatusCode::NOT_FOUND, r.status());
2286    }
2287
2288    #[tokio::test]
2289    async fn crate_dependents_of_unknown_crate_returns_not_found() {
2290        let (state, _db) = sqlite_test_state().await;
2291
2292        let r = get_with(state, "/crate_dependents?name=missing", "").await;
2293
2294        assert_eq!(StatusCode::NOT_FOUND, r.status());
2295    }
2296
2297    /// Status of `/crate_data` for the restricted crate `secret` as `user`,
2298    /// anonymous if `None`. `prepare` sets up access to the crate.
2299    async fn get_secret_crate_data(
2300        user: Option<(&str, UserFlags)>,
2301        prepare: impl AsyncFnOnce(&TestDb),
2302    ) -> StatusCode {
2303        let (state, db) = sqlite_test_state().await;
2304        add_restricted_crate(&db, "secret").await;
2305        let cookie = match user {
2306            Some((name, flags)) => session_cookie(&db, name, flags).await,
2307            None => String::new(),
2308        };
2309        prepare(&db).await;
2310        get_with(state, "/crate_data?name=secret", &cookie)
2311            .await
2312            .status()
2313    }
2314
2315    #[tokio::test]
2316    async fn crate_data_restricted_crate_anonymous_returns_not_found() {
2317        let status = get_secret_crate_data(None, async |_| {}).await;
2318
2319        assert_eq!(StatusCode::NOT_FOUND, status);
2320    }
2321
2322    #[tokio::test]
2323    async fn crate_data_restricted_crate_without_access_returns_not_found() {
2324        let status = get_secret_crate_data(Some(("user", UserFlags::normal())), async |_| {}).await;
2325
2326        assert_eq!(StatusCode::NOT_FOUND, status);
2327    }
2328
2329    #[tokio::test]
2330    async fn crate_data_restricted_crate_crate_user_returns_ok() {
2331        let status = get_secret_crate_data(Some(("user", UserFlags::normal())), async |db| {
2332            add_crate_user(db, "secret", "user").await;
2333        })
2334        .await;
2335
2336        assert_eq!(StatusCode::OK, status);
2337    }
2338
2339    #[tokio::test]
2340    async fn crate_data_restricted_crate_admin_returns_ok() {
2341        let status =
2342            get_secret_crate_data(Some(("other_admin", UserFlags::admin())), async |_| {}).await;
2343
2344        assert_eq!(StatusCode::OK, status);
2345    }
2346
2347    #[tokio::test]
2348    async fn crates_user_session_lists_accessible_restricted_crates() {
2349        let (state, db) = sqlite_test_state().await;
2350        let cookie = session_cookie(&db, "user", UserFlags::normal()).await;
2351        add_crate(&db, "open", "1.0.0").await;
2352        add_restricted_crate(&db, "secret").await;
2353        add_crate_user(&db, "secret", "user").await;
2354        add_restricted_crate(&db, "hidden").await;
2355
2356        let result: Pagination = json(get_with(state, "/crates", &cookie).await).await;
2357
2358        assert_eq!(names(&result.crates), ["open", "secret"]);
2359        assert_eq!(2, result.total);
2360    }
2361
2362    /// A failing database must not look like an empty registry.
2363    #[tokio::test]
2364    async fn crates_database_error_is_internal_error() {
2365        let (state, db) = sqlite_test_state().await;
2366        test_drop_table(&db, "krate").await.unwrap();
2367
2368        let r = get_with(state, "/crates", "").await;
2369
2370        assert_eq!(r.status(), StatusCode::INTERNAL_SERVER_ERROR);
2371    }
2372
2373    #[tokio::test]
2374    async fn crates_get_page() {
2375        let (state, db) = sqlite_test_state().await;
2376        for i in 0..12 {
2377            add_crate(&db, &format!("c{i:02}"), "1.0.0").await;
2378        }
2379
2380        let result: Pagination = json(get_with(state, "/crates?page=0", "").await).await;
2381
2382        let expected: Vec<String> = (0..10).map(|i| format!("c{i:02}")).collect();
2383        assert_eq!(names(&result.crates), expected);
2384        assert_eq!(0, result.page);
2385        assert_eq!(10, result.page_size);
2386        assert_eq!(12, result.total);
2387    }
2388
2389    #[tokio::test]
2390    async fn crates_get_all_crates() {
2391        let (state, db) = sqlite_test_state().await;
2392        add_crate(&db, "c1", "1.0.0").await;
2393        add_crate(&db, "c2", "2.0.0").await;
2394        add_crate(&db, "c3", "3.0.0").await;
2395
2396        let result: Pagination = json(get_with(state, "/crates", "").await).await;
2397
2398        assert_eq!(names(&result.crates), ["c1", "c2", "c3"]);
2399        assert_eq!(
2400            ["1.0.0", "2.0.0", "3.0.0"],
2401            result
2402                .crates
2403                .iter()
2404                .map(|c| c.version.as_str())
2405                .collect::<Vec<_>>()[..]
2406        );
2407        assert_eq!(0, result.page);
2408        assert_eq!(10, result.page_size);
2409        assert_eq!(3, result.total);
2410    }
2411
2412    #[tokio::test]
2413    async fn crates_sorts_by_requested_order() {
2414        let (state, db) = sqlite_test_state().await;
2415        add_crate_with_downloads(&db, "a", 1).await;
2416        add_crate_with_downloads(&db, "b", 3).await;
2417        add_crate_with_downloads(&db, "c", 2).await;
2418
2419        let result: Pagination =
2420            json(get_with(state, "/crates?page_size=2&sort=downloads", "").await).await;
2421
2422        assert_eq!(names(&result.crates), ["b", "c"]);
2423    }
2424
2425    /// Publishes `name` with `keywords` and `categories`.
2426    async fn publish_tagged(db: &TestDb, name: &str, keywords: &[&str], categories: &[&str]) {
2427        publish(
2428            db,
2429            PublishMetadata {
2430                name: name.to_string(),
2431                vers: "1.0.0".to_string(),
2432                keywords: keywords.iter().map(ToString::to_string).collect(),
2433                categories: categories.iter().map(ToString::to_string).collect(),
2434                ..PublishMetadata::default()
2435            },
2436        )
2437        .await;
2438    }
2439
2440    async fn add_tagged_crates(db: &TestDb) {
2441        publish_tagged(db, "cli-tool", &["cli"], &["development-tools"]).await;
2442        publish_tagged(db, "cli-only", &["cli"], &[]).await;
2443        publish_tagged(db, "plain", &[], &[]).await;
2444    }
2445
2446    #[tokio::test]
2447    async fn crates_filters_by_keyword_and_category() {
2448        let (state, db) = sqlite_test_state().await;
2449        add_tagged_crates(&db).await;
2450
2451        let result: Pagination =
2452            json(get_with(state, "/crates?keyword=cli&category=development-tools", "").await).await;
2453
2454        assert_eq!(names(&result.crates), ["cli-tool"]);
2455        assert_eq!(1, result.total);
2456    }
2457
2458    #[tokio::test]
2459    async fn crates_ignores_empty_keyword_and_category() {
2460        let (state, db) = sqlite_test_state().await;
2461        add_tagged_crates(&db).await;
2462
2463        let result: Pagination =
2464            json(get_with(state, "/crates?keyword=&category=", "").await).await;
2465
2466        assert_eq!(names(&result.crates), ["cli-only", "cli-tool", "plain"]);
2467    }
2468
2469    #[tokio::test]
2470    async fn crates_rejects_unknown_sort_order() {
2471        let r = get_with(test_state(), "/crates?sort=stars", "").await;
2472
2473        assert_eq!(StatusCode::BAD_REQUEST, r.status());
2474    }
2475
2476    #[tokio::test]
2477    async fn cratesio_data_passes_json_of_crates_io_through() {
2478        const DATA: &str = r#"{"crate":{"description":"Quasi-quoting"}}"#;
2479        let upstream = TestServer::start(Router::new().route(
2480            "/api/v1/crates/quote",
2481            get(|| async { ([(header::CONTENT_TYPE, "application/json")], DATA) }),
2482        ))
2483        .await;
2484        let mut settings = kellnr_settings::test_settings();
2485        settings.proxy.api = upstream.url("/api/v1/crates/").parse().unwrap();
2486
2487        let r = get_with(
2488            with_settings(test_state(), settings),
2489            "/cratesio_data?name=quote",
2490            "",
2491        )
2492        .await;
2493
2494        assert_eq!(r.status(), StatusCode::OK);
2495        assert_eq!(r.headers()[header::CONTENT_TYPE], "application/json");
2496        let body = r.into_body().collect().await.unwrap().to_bytes();
2497        assert_eq!(body, DATA.as_bytes());
2498    }
2499
2500    #[tokio::test]
2501    async fn cratesio_data_not_found() {
2502        let upstream =
2503            TestServer::start(Router::new().fallback(|| async { StatusCode::NOT_FOUND })).await;
2504        let mut settings = kellnr_settings::test_settings();
2505        settings.proxy.api = upstream.url("/api/v1/crates/").parse().unwrap();
2506
2507        let r = get_with(
2508            with_settings(test_state(), settings),
2509            "/cratesio_data?name=thisdoesnotevenexist",
2510            "",
2511        )
2512        .await;
2513
2514        assert_eq!(r.status(), StatusCode::NOT_FOUND);
2515    }
2516
2517    /// Errors of crates.io like rate limits are not a missing crate.
2518    #[tokio::test]
2519    async fn cratesio_data_upstream_error_is_bad_gateway() {
2520        let upstream =
2521            TestServer::start(Router::new().fallback(|| async { StatusCode::TOO_MANY_REQUESTS }))
2522                .await;
2523        let mut settings = kellnr_settings::test_settings();
2524        settings.proxy.api = upstream.url("/api/v1/crates/").parse().unwrap();
2525
2526        let r = get_with(
2527            with_settings(test_state(), settings),
2528            "/cratesio_data?name=quote",
2529            "",
2530        )
2531        .await;
2532
2533        assert_eq!(r.status(), StatusCode::BAD_GATEWAY);
2534    }
2535}