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#[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
56pub const REDACTED: &str = "********";
58
59#[derive(Serialize, Deserialize, Clone, Debug, ToSchema)]
63pub struct LeafMeta {
64 pub key: String,
66 pub value: serde_json::Value,
68 pub default: serde_json::Value,
70 pub source: ConfigSource,
72 #[serde(rename = "type")]
74 pub kind: LeafKind,
75 pub secret: bool,
77 pub cli_flag: Option<String>,
79 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
92fn 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 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 leaves.sort_by(|a, b| a.key.cmp(&b.key));
129 leaves
130}
131
132fn 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
142fn 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
158fn 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
172fn 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 _ => Self::String,
196 }
197 }
198}
199
200#[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)] pub 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#[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)] pub 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#[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)] pub 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#[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)] pub 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 keyword: Option<String>,
309 category: Option<String>,
311}
312
313fn 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 page: u64,
325 total: u64,
327}
328
329#[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#[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#[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 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
469async 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 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#[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 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
553async 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#[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(¶ms.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
637async 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 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 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 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#[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#[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#[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#[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 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#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, ToSchema, utoipa::IntoParams)]
867pub struct BuildParams {
868 package: OriginalName,
870 version: Version,
872}
873
874#[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 authz::require_owner(&canonical_name, &user, &db).await?;
911 authz::require_writable(&user)?;
912
913 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 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
940fn internal_error(e: impl std::fmt::Display) -> StatusCode {
942 error!("Internal server error: {e}");
943 StatusCode::INTERNAL_SERVER_ERROR
944}
945
946#[derive(Serialize, Deserialize, ToSchema)]
948pub struct MigrateDocsStorageResponse {
949 pub started: bool,
951 pub crate_versions_found: usize,
953}
954
955#[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 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
1039async 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 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 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 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 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 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 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 #[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 #[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 #[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 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 }
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 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 assert_eq!(
1473 response.sources.get("docs.enabled"),
1474 Some(&ConfigSource::Default),
1475 );
1476
1477 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 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 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 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 #[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 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 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 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 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 tasks.request_shutdown();
1872 tasks.wait_for_shutdown(Duration::from_secs(30)).await;
1873
1874 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 #[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 #[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 #[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 #[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 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 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 #[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 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 #[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}