1use std::collections::HashMap;
2use std::fmt::Write as _;
3use std::fs::File;
4use std::io::{BufWriter, Read as _, Seek as _, Write};
5use std::sync::Arc;
6use std::time::Duration;
7
8use axum::Json;
9use axum::body::Body;
10use axum::extract::{Path, Query, State};
11use axum::http::{HeaderMap, StatusCode, header};
12use axum::response::{IntoResponse, Response};
13use bytes::Bytes;
14use kellnr_common::background_tasks::BackgroundTasks;
15use kellnr_common::binary_body::BinaryBody;
16use kellnr_db::error::DbError;
17use kellnr_db::{ChannelInfo, ToolchainTargetInfo, ToolchainTargetStatus, ToolchainWithTargets};
18use kellnr_http::error::{ApiError, ApiResult};
19use kellnr_http::identity::AdminUser;
20use kellnr_http::state::{BackgroundTasksState, DbState, SettingsState, ToolchainStorageState};
21use kellnr_storage::storage_error::StorageError;
22use kellnr_storage::storage_key::StorageKey;
23use kellnr_storage::toolchain_storage::{ToolchainArchiveId, ToolchainStorage};
24use serde::{Deserialize, Serialize};
25use tokio::sync::Semaphore;
26use tracing::trace;
27use utoipa::ToSchema;
28
29#[derive(Debug, Serialize, Deserialize, ToSchema)]
31pub struct ToolchainResponse {
32 pub success: bool,
34 #[serde(skip_serializing_if = "Option::is_none")]
36 pub message: Option<String>,
37}
38
39#[derive(Debug, Serialize, Deserialize, ToSchema)]
41pub struct SetChannelRequest {
42 pub name: String,
44 pub version: String,
46}
47
48#[derive(Debug, Deserialize, ToSchema, utoipa::IntoParams)]
50pub struct UploadQuery {
51 pub name: String,
53 pub version: String,
55 pub target: String,
57 pub date: String,
59 #[serde(default)]
61 pub channel: Option<String>,
62}
63
64fn internal_error(message: &str, error: impl std::fmt::Display) -> ApiError {
66 tracing::error!("{message}: {error}");
67 ApiError::new(message, StatusCode::INTERNAL_SERVER_ERROR)
68}
69
70fn storage_not_configured() -> ApiError {
71 ApiError::new(
72 "Toolchain storage not configured",
73 StatusCode::SERVICE_UNAVAILABLE,
74 )
75}
76
77#[utoipa::path(
82 get,
83 path = "/",
84 tag = "toolchains",
85 responses(
86 (status = 200, description = "List of toolchains", body = Vec<ToolchainWithTargets>),
87 (status = 401, description = "Not authenticated"),
88 (status = 403, description = "Admin access required")
89 ),
90 security(("session_cookie" = []))
91)]
92pub async fn list_toolchains(
93 _user: AdminUser,
94 State(db): DbState,
95) -> ApiResult<Json<Vec<ToolchainWithTargets>>> {
96 trace!("Listing toolchains");
97 db.list_toolchains()
98 .await
99 .map(Json)
100 .map_err(|e| internal_error("Failed to load toolchains", e))
101}
102
103#[utoipa::path(
109 put,
110 path = "/",
111 tag = "toolchains",
112 params(UploadQuery),
113 request_body(content = inline(BinaryBody), content_type = "application/octet-stream", description = "Combined toolchain archive (.tar.xz)"),
114 responses(
115 (status = 200, description = "Toolchain uploaded", body = ToolchainResponse),
116 (status = 401, description = "Not authenticated"),
117 (status = 403, description = "Admin access required"),
118 (status = 409, description = "Target or archive already exists"),
119 (status = 413, description = "Archive is too large"),
120 (status = 503, description = "Storage not configured")
121 ),
122 security(("session_cookie" = []))
123)]
124pub async fn upload_toolchain(
125 _user: AdminUser,
126 State(db): DbState,
127 State(storage): ToolchainStorageState,
128 State(tasks): BackgroundTasksState,
129 Query(params): Query<UploadQuery>,
130 body: Bytes,
131) -> ApiResult<Json<ToolchainResponse>> {
132 trace!(
133 name = %params.name,
134 version = %params.version,
135 target = %params.target,
136 "Uploading toolchain"
137 );
138
139 let storage = storage.as_ref().ok_or_else(storage_not_configured)?;
140
141 let toolchain = db
142 .get_toolchain_by_version(¶ms.name, ¶ms.version)
143 .await
144 .map_err(|e| internal_error("Failed to load toolchain", e))?;
145
146 if let Some(tc) = &toolchain
147 && tc.targets.iter().any(|t| t.target == params.target)
148 {
149 return Err(ApiError::conflict(format!(
150 "Target {} already exists for {}-{}",
151 params.target, params.name, params.version
152 )));
153 }
154
155 let size = body.len() as i64;
156
157 let archive_id = ToolchainArchiveId {
160 name: ¶ms.name,
161 version: ¶ms.version,
162 date: ¶ms.date,
163 target: ¶ms.target,
164 };
165 let (path, hash) = storage.put(&archive_id, body).await.map_err(|e| match e {
166 StorageError::ToolchainArchiveExists { .. } => ApiError::conflict(e.to_string()),
167 e => internal_error("Failed to store archive", e),
168 })?;
169
170 let target_id =
171 match add_target_to_db(&db, toolchain.as_ref(), ¶ms, &path, &hash, size).await {
172 Ok(id) => id,
173 Err(e) => {
174 if let Err(e) = storage.delete(&path).await {
175 tracing::warn!("Failed to delete archive {path} after failed upload: {e}");
176 }
177 return Err(e);
178 }
179 };
180
181 let bg_storage = storage.clone();
186 let bg_db = db.clone();
187 let bg_name = params.name.clone();
188 let bg_version = params.version.clone();
189 let bg_target = params.target.clone();
190 let bg_date = params.date.clone();
191 let bg_path = path.clone();
192 spawn_target_processing(&tasks, db.clone(), target_id, async move {
193 let archive_bytes = {
194 let mut archive = None;
195 for attempt in 1..=3 {
196 match bg_storage.get(&bg_path).await {
197 Ok(bytes) => {
198 archive = Some(bytes);
199 break;
200 }
201 Err(e) => {
202 if attempt == 3 {
203 tracing::warn!(
204 "Failed to read archive for component extraction after {attempt} attempts: {e}"
205 );
206 if let Err(status_err) = bg_db
207 .set_target_status(target_id, ToolchainTargetStatus::Failed)
208 .await
209 {
210 tracing::warn!(
211 "Failed to set target status to failed after archive read error: {status_err}"
212 );
213 }
214 return;
215 }
216 tokio::time::sleep(Duration::from_millis(200 * attempt as u64)).await;
217 }
218 }
219 }
220 let Some(bytes) = archive else {
221 tracing::warn!("Archive read retry loop finished without archive bytes");
222 return;
223 };
224 bytes
225 };
226
227 let archive_id = ToolchainArchiveId {
228 name: &bg_name,
229 version: &bg_version,
230 date: &bg_date,
231 target: &bg_target,
232 };
233 match extract_and_store_components(
234 &bg_storage,
235 &bg_db,
236 target_id,
237 &archive_id,
238 archive_bytes,
239 )
240 .await
241 {
242 Ok(()) => {
243 if let Err(e) = bg_db
244 .set_target_status(target_id, ToolchainTargetStatus::Ready)
245 .await
246 {
247 tracing::warn!("Failed to set target status to ready: {e}");
248 }
249 tracing::info!(
250 "Successfully extracted components for {}-{}-{}",
251 bg_name,
252 bg_version,
253 bg_target
254 );
255 }
256 Err(e) => {
257 if let Err(status_err) = bg_db
258 .set_target_status(target_id, ToolchainTargetStatus::Failed)
259 .await
260 {
261 tracing::warn!("Failed to set target status to failed: {status_err}");
262 }
263 tracing::warn!("Failed to extract components from archive: {e}");
264 }
265 }
266 });
267
268 Ok(Json(ToolchainResponse {
269 success: true,
270 message: Some(format!(
271 "Uploaded {}-{}-{} ({} bytes); components are being processed",
272 params.name, params.version, params.target, size
273 )),
274 }))
275}
276
277fn spawn_target_processing(
280 tasks: &BackgroundTasks,
281 db: Arc<dyn kellnr_db::DbProvider>,
282 target_id: i64,
283 processing: impl Future<Output = ()> + Send + 'static,
284) -> tokio::task::JoinHandle<()> {
285 tasks.spawn_tracked(async move {
286 if let Err(e) = tokio::spawn(processing).await {
287 tracing::error!("Processing of toolchain target {target_id} stopped: {e}");
288 if let Err(e) = db
289 .set_target_status(target_id, ToolchainTargetStatus::Failed)
290 .await
291 {
292 tracing::warn!("Failed to set target status to failed: {e}");
293 }
294 }
295 })
296}
297
298async fn add_target_to_db(
301 db: &Arc<dyn kellnr_db::DbProvider>,
302 toolchain: Option<&ToolchainWithTargets>,
303 params: &UploadQuery,
304 path: &StorageKey,
305 hash: &str,
306 size: i64,
307) -> ApiResult<i64> {
308 let toolchain_id = match toolchain {
309 Some(tc) => tc.id,
310 None => db
311 .add_toolchain(
312 ¶ms.name,
313 ¶ms.version,
314 ¶ms.date,
315 params.channel.clone(),
316 )
317 .await
318 .map_err(|e| internal_error("Failed to create toolchain", e))?,
319 };
320
321 match db
322 .add_toolchain_target(toolchain_id, ¶ms.target, path.as_str(), hash, size)
323 .await
324 {
325 Ok(target_id) => Ok(target_id),
326 Err(e) => {
327 if toolchain.is_none()
328 && let Err(e) = db.delete_toolchain(¶ms.name, ¶ms.version).await
329 {
330 tracing::warn!(
331 "Failed to delete toolchain {}-{} after failed upload: {e}",
332 params.name,
333 params.version
334 );
335 }
336 Err(internal_error("Failed to add target", e))
337 }
338 }
339}
340
341#[utoipa::path(
346 delete,
347 path = "/{name}/{version}",
348 tag = "toolchains",
349 params(
350 ("name" = String, Path, description = "Toolchain name"),
351 ("version" = String, Path, description = "Toolchain version")
352 ),
353 responses(
354 (status = 200, description = "Toolchain deleted", body = ToolchainResponse),
355 (status = 401, description = "Not authenticated"),
356 (status = 403, description = "Admin access required"),
357 (status = 404, description = "Toolchain not found"),
358 (status = 503, description = "Storage not configured")
359 ),
360 security(("session_cookie" = []))
361)]
362pub async fn delete_toolchain(
363 _user: AdminUser,
364 State(db): DbState,
365 State(storage): ToolchainStorageState,
366 Path((name, version)): Path<(String, String)>,
367) -> ApiResult<Json<ToolchainResponse>> {
368 trace!(name = %name, version = %version, "Deleting toolchain");
369
370 let storage = storage.as_ref().ok_or_else(storage_not_configured)?;
371
372 let toolchain = db
373 .get_toolchain_by_version(&name, &version)
374 .await
375 .map_err(|e| internal_error("Failed to load toolchain", e))?;
376
377 let Some(tc) = toolchain else {
378 return Err(ApiError::not_found(format!(
379 "Toolchain {name}-{version} not found"
380 )));
381 };
382
383 db.delete_toolchain(&name, &version)
386 .await
387 .map_err(|e| internal_error("Failed to delete toolchain", e))?;
388
389 for target in &tc.targets {
390 delete_target_archives(storage, target).await;
391 }
392
393 Ok(Json(ToolchainResponse {
394 success: true,
395 message: Some(format!(
396 "Deleted {name}-{version} with {} target(s)",
397 tc.targets.len()
398 )),
399 }))
400}
401
402#[utoipa::path(
408 delete,
409 path = "/{name}/{version}/targets/{target}",
410 tag = "toolchains",
411 params(
412 ("name" = String, Path, description = "Toolchain name"),
413 ("version" = String, Path, description = "Toolchain version"),
414 ("target" = String, Path, description = "Target triple")
415 ),
416 responses(
417 (status = 200, description = "Target deleted", body = ToolchainResponse),
418 (status = 401, description = "Not authenticated"),
419 (status = 403, description = "Admin access required"),
420 (status = 404, description = "Toolchain or target not found"),
421 (status = 503, description = "Storage not configured")
422 ),
423 security(("session_cookie" = []))
424)]
425pub async fn delete_toolchain_target(
426 _user: AdminUser,
427 State(db): DbState,
428 State(storage): ToolchainStorageState,
429 Path((name, version, target)): Path<(String, String, String)>,
430) -> ApiResult<Json<ToolchainResponse>> {
431 trace!(
432 name = %name,
433 version = %version,
434 target = %target,
435 "Deleting toolchain target"
436 );
437
438 let storage = storage.as_ref().ok_or_else(storage_not_configured)?;
439
440 let toolchain = db
441 .get_toolchain_by_version(&name, &version)
442 .await
443 .map_err(|e| internal_error("Failed to load toolchain", e))?;
444
445 let Some(tc) = toolchain else {
446 return Err(ApiError::not_found(format!(
447 "Toolchain {name}-{version} not found"
448 )));
449 };
450
451 let Some(t) = tc.targets.iter().find(|t| t.target == target) else {
452 return Err(ApiError::not_found(format!(
453 "Target {target} of {name}-{version} not found"
454 )));
455 };
456
457 let last_target = tc.targets.len() == 1;
461 if last_target {
462 db.delete_toolchain(&name, &version)
463 .await
464 .map_err(|e| internal_error("Failed to delete toolchain", e))?;
465 } else {
466 db.delete_toolchain_target(&name, &version, &target)
467 .await
468 .map_err(|e| internal_error("Failed to delete target", e))?;
469 }
470
471 delete_target_archives(storage, t).await;
472
473 if last_target {
474 return Ok(Json(ToolchainResponse {
475 success: true,
476 message: Some(format!("Deleted {name}-{version} (last target removed)")),
477 }));
478 }
479
480 Ok(Json(ToolchainResponse {
481 success: true,
482 message: Some(format!("Deleted {name}-{version}-{target}")),
483 }))
484}
485
486async fn delete_target_archives(storage: &ToolchainStorage, target: &ToolchainTargetInfo) {
489 if let Err(e) = storage
490 .delete(&StorageKey::from_stored(target.storage_path.clone()))
491 .await
492 {
493 tracing::warn!("Failed to delete archive from storage: {e}");
494 }
495 for component in &target.components {
496 if let Err(e) = storage
497 .delete(&StorageKey::from_stored(component.storage_path.clone()))
498 .await
499 {
500 tracing::warn!("Failed to delete component archive from storage: {e}");
501 }
502 }
503}
504
505#[utoipa::path(
511 get,
512 path = "/channels",
513 tag = "toolchains",
514 responses(
515 (status = 200, description = "List of channels", body = Vec<ChannelInfo>),
516 (status = 401, description = "Not authenticated"),
517 (status = 403, description = "Admin access required")
518 ),
519 security(("session_cookie" = []))
520)]
521pub async fn list_channels(
522 _user: AdminUser,
523 State(db): DbState,
524) -> ApiResult<Json<Vec<ChannelInfo>>> {
525 trace!("Listing channels");
526 db.get_channels()
527 .await
528 .map(Json)
529 .map_err(|e| internal_error("Failed to load channels", e))
530}
531
532#[utoipa::path(
537 put,
538 path = "/channels/{channel}",
539 tag = "toolchains",
540 params(
541 ("channel" = String, Path, description = "Channel name (e.g., stable, beta, nightly)")
542 ),
543 request_body = SetChannelRequest,
544 responses(
545 (status = 200, description = "Channel updated", body = ToolchainResponse),
546 (status = 401, description = "Not authenticated"),
547 (status = 403, description = "Admin access required"),
548 (status = 404, description = "Toolchain not found")
549 ),
550 security(("session_cookie" = []))
551)]
552pub async fn set_channel(
553 _user: AdminUser,
554 State(db): DbState,
555 Path(channel): Path<String>,
556 Json(req): Json<SetChannelRequest>,
557) -> ApiResult<Json<ToolchainResponse>> {
558 trace!(
559 channel = %channel,
560 name = %req.name,
561 version = %req.version,
562 "Setting channel"
563 );
564
565 db.set_channel(&channel, &req.name, &req.version)
566 .await
567 .map_err(|e| match e {
568 DbError::ToolchainNotFound(..) => ApiError::not_found(e.to_string()),
569 e => internal_error("Failed to set channel", e),
570 })?;
571
572 Ok(Json(ToolchainResponse {
573 success: true,
574 message: Some(format!(
575 "Channel {} now points to {}-{}",
576 channel, req.name, req.version
577 )),
578 }))
579}
580
581#[utoipa::path(
587 get,
588 path = "/{manifest_file}",
589 tag = "toolchains",
590 params(
591 ("manifest_file" = String, Path, description = "Manifest filename (e.g., channel-rust-stable.toml)")
592 ),
593 responses(
594 (status = 200, description = "Channel manifest (TOML)", content_type = "text/toml"),
595 (status = 404, description = "Channel not found or no targets available"),
596 (status = 503, description = "Targets are still being processed")
597 ),
598 security(("session_cookie" = []))
599)]
600pub async fn get_channel_manifest(
601 State(db): DbState,
602 State(settings): SettingsState,
603 Path(manifest_file): Path<String>,
604) -> ApiResult<Response> {
605 trace!(manifest_file = %manifest_file, "Getting channel manifest");
606
607 let (channel, want_sha256) = manifest_file
611 .strip_prefix("channel-rust-")
612 .and_then(|s| {
613 s.strip_suffix(".toml.sha256")
614 .map(|c| (c, true))
615 .or_else(|| s.strip_suffix(".toml").map(|c| (c, false)))
616 })
617 .ok_or(StatusCode::NOT_FOUND)?;
618
619 let toolchain = match db
622 .get_toolchain_by_channel(channel)
623 .await
624 .map_err(|e| internal_error("Failed to load toolchains", e))?
625 {
626 Some(tc) => tc,
627 None => db
628 .get_toolchain_by_version("rust", channel)
629 .await
630 .map_err(|e| internal_error("Failed to load toolchains", e))?
631 .ok_or(StatusCode::NOT_FOUND)?,
632 };
633
634 let has_ready_targets = toolchain
637 .targets
638 .iter()
639 .any(|t| t.status == ToolchainTargetStatus::Ready);
640 if !has_ready_targets {
641 let has_processing = toolchain
642 .targets
643 .iter()
644 .any(|t| t.status == ToolchainTargetStatus::Processing);
645 if has_processing {
646 return Err(StatusCode::SERVICE_UNAVAILABLE.into());
647 }
648 return Err(StatusCode::NOT_FOUND.into());
649 }
650
651 let manifest = generate_manifest(&toolchain, &settings);
652
653 if want_sha256 {
654 let hash = sha256::digest(manifest.as_bytes());
655 Ok(hash.into_response())
656 } else {
657 let mut headers = HeaderMap::new();
658 headers.insert(
659 header::CONTENT_TYPE,
660 "text/toml; charset=utf-8".parse().unwrap(),
661 );
662 Ok((headers, manifest).into_response())
663 }
664}
665
666#[utoipa::path(
670 get,
671 path = "/{date}/{filename}",
672 tag = "toolchains",
673 params(
674 ("date" = String, Path, description = "Release date (e.g., 2024-01-15)"),
675 ("filename" = String, Path, description = "Archive filename")
676 ),
677 responses(
678 (status = 200, description = "Toolchain archive (xz, gzip, or raw)", content_type = "application/octet-stream"),
679 (status = 404, description = "Archive not found"),
680 (status = 503, description = "Storage not configured")
681 ),
682 security(("session_cookie" = []))
683)]
684pub async fn download_archive(
685 State(storage): ToolchainStorageState,
686 Path((date, filename)): Path<(String, String)>,
687) -> ApiResult<Response> {
688 trace!(date = %date, filename = %filename, "Downloading toolchain archive");
689
690 let storage = storage.as_ref().ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
691
692 let path = StorageKey::toolchain_file(&date, &filename);
693 let archive = storage.get(&path).await.map_err(|e| {
694 tracing::warn!("Failed to get toolchain archive {}: {}", path, e);
695 StatusCode::NOT_FOUND
696 })?;
697
698 let path_ref = std::path::Path::new(&filename);
699 let ext = path_ref.extension().and_then(|e| e.to_str()).unwrap_or("");
700 let content_type = if ext.eq_ignore_ascii_case("xz") {
701 "application/x-xz"
702 } else if ext.eq_ignore_ascii_case("gz") {
703 "application/gzip"
704 } else {
705 "application/octet-stream"
706 };
707
708 let mut headers = HeaderMap::new();
709 headers.insert(header::CONTENT_TYPE, content_type.parse().unwrap());
710 headers.insert(
711 header::CONTENT_DISPOSITION,
712 format!("attachment; filename=\"{filename}\"")
713 .parse()
714 .unwrap(),
715 );
716
717 Ok((headers, Body::from(archive)).into_response())
718}
719
720static EXTRACTION_PERMITS: Semaphore = Semaphore::const_new(1);
724
725async fn extract_and_store_components(
736 storage: &Arc<ToolchainStorage>,
737 db: &Arc<dyn kellnr_db::DbProvider>,
738 target_id: i64,
739 archive_id: &ToolchainArchiveId<'_>,
740 archive_bytes: Bytes,
741) -> Result<(), String> {
742 let _permit = EXTRACTION_PERMITS
743 .acquire()
744 .await
745 .map_err(|e| format!("Failed to wait for component extraction: {e}"))?;
746
747 let components = {
748 let (name, version, target) = (
749 archive_id.name.to_string(),
750 archive_id.version.to_string(),
751 archive_id.target.to_string(),
752 );
753 tokio::task::spawn_blocking(move || {
754 split_components(&archive_bytes, &name, &version, &target)
755 })
756 .await
757 .map_err(|e| format!("Component extraction stopped: {e}"))??
758 };
759
760 tracing::debug!(
761 "Extracted {} components in two streamed passes, compressing and storing",
762 components.len()
763 );
764
765 let mut stored_paths: Vec<StorageKey> = Vec::new();
769
770 for (component_name, component_tar) in components {
771 let component_xz = tokio::task::spawn_blocking(move || compress_component(component_tar))
772 .await
773 .map_err(|e| format!("Compression of {component_name} stopped: {e}"))?
774 .map_err(|e| format!("Failed to compress {component_name}: {e}"))?;
775
776 let component_hash = sha256::digest(&component_xz[..]);
777 let component_size = component_xz.len() as i64;
778 let component_storage_path = StorageKey::toolchain_component(archive_id, &component_name);
779
780 let in_use = db
783 .is_toolchain_component_stored_at(component_storage_path.as_str())
784 .await
785 .map_err(|e| format!("Failed to check component {component_name}: {e}"))?;
786 let component_xz = Bytes::from(component_xz);
787 let stored = if in_use {
788 storage.put_raw(&component_storage_path, component_xz).await
789 } else {
790 storage
791 .put_raw_overwrite(&component_storage_path, component_xz)
792 .await
793 };
794 stored.map_err(|e| format!("Failed to store component {component_name}: {e}"))?;
795 stored_paths.push(component_storage_path.clone());
796
797 if let Err(e) = db
798 .add_toolchain_component(
799 target_id,
800 &component_name,
801 component_storage_path.as_str(),
802 &component_hash,
803 component_size,
804 )
805 .await
806 {
807 for p in &stored_paths {
810 let _ = storage.delete(p).await;
811 }
812 return Err(format!(
813 "Failed to add component {component_name} to DB: {e}"
814 ));
815 }
816
817 tracing::debug!(
818 component = component_name,
819 path = component_storage_path.as_str(),
820 size = component_size,
821 "Stored component archive"
822 );
823 }
824
825 Ok(())
826}
827
828fn split_components(
836 archive_bytes: &[u8],
837 name: &str,
838 version: &str,
839 target: &str,
840) -> Result<Vec<(String, File)>, String> {
841 let prefix = format!("{name}-{version}-{target}/");
842 let meta_components_path = format!("{prefix}components");
843 let meta_installer_path = format!("{prefix}rust-installer-version");
844
845 let mut component_names: Vec<String> = Vec::new();
846 let mut installer_version = String::new();
847
848 let xz_decoder = xz2::read::XzDecoder::new(archive_bytes);
850 let mut archive = tar::Archive::new(xz_decoder);
851 for entry in archive
852 .entries()
853 .map_err(|e| format!("Failed to read tar entries: {e}"))?
854 {
855 let mut entry = entry.map_err(|e| format!("Failed to read tar entry: {e}"))?;
856 let entry_path = entry
857 .path()
858 .map_err(|e| format!("Failed to read entry path: {e}"))?
859 .to_string_lossy()
860 .to_string();
861
862 if entry_path == meta_components_path {
863 let mut contents = String::new();
864 entry
865 .read_to_string(&mut contents)
866 .map_err(|e| format!("Failed to read components file: {e}"))?;
867 component_names = contents
868 .lines()
869 .filter(|l| !l.is_empty())
870 .map(ToString::to_string)
871 .collect();
872 } else if entry_path == meta_installer_path {
873 entry
874 .read_to_string(&mut installer_version)
875 .map_err(|e| format!("Failed to read installer version: {e}"))?;
876 }
877
878 if !component_names.is_empty() && !installer_version.is_empty() {
879 break;
880 }
881 }
882
883 if component_names.is_empty() {
884 return Err("No components file found in archive".to_string());
885 }
886
887 let mut builders: HashMap<String, tar::Builder<BufWriter<File>>> = HashMap::new();
889 for cn in &component_names {
890 let file =
891 tempfile::tempfile().map_err(|e| format!("Failed to create temporary file: {e}"))?;
892 let mut builder = tar::Builder::new(BufWriter::new(file));
893 builder.mode(tar::HeaderMode::Deterministic);
894
895 if !installer_version.is_empty() {
896 append_metadata_file(
897 &mut builder,
898 &format!("{cn}-{version}-{target}/rust-installer-version"),
899 installer_version.as_bytes(),
900 )?;
901 }
902 append_metadata_file(
903 &mut builder,
904 &format!("{cn}-{version}-{target}/components"),
905 format!("{cn}\n").as_bytes(),
906 )?;
907 builders.insert(cn.clone(), builder);
908 }
909
910 let xz_decoder = xz2::read::XzDecoder::new(archive_bytes);
912 let mut archive = tar::Archive::new(xz_decoder);
913 for entry in archive
914 .entries()
915 .map_err(|e| format!("Failed to read tar entries: {e}"))?
916 {
917 let mut entry = entry.map_err(|e| format!("Failed to read tar entry: {e}"))?;
918 let entry_path = entry
919 .path()
920 .map_err(|e| format!("Failed to read entry path: {e}"))?
921 .to_string_lossy()
922 .to_string();
923
924 if entry_path == meta_components_path || entry_path == meta_installer_path {
926 continue;
927 }
928
929 if !entry_path.starts_with(&prefix) {
931 continue;
932 }
933 let rel_path = &entry_path[prefix.len()..];
934 let component_name = rel_path.split('/').next().unwrap_or("");
936 let Some(builder) = builders.get_mut(component_name) else {
937 continue;
938 };
939
940 let new_path = format!("{component_name}-{version}-{target}/{rel_path}");
941
942 let mut header = tar::Header::new_gnu();
943 header.set_size(entry.size());
944 header.set_mode(entry.header().mode().unwrap_or(0o644));
945 header.set_entry_type(entry.header().entry_type());
946 if let Ok(mtime) = entry.header().mtime() {
947 header.set_mtime(mtime);
948 }
949 if let Ok(Some(link)) = entry.header().link_name() {
950 header
951 .set_link_name(link)
952 .map_err(|e| format!("Failed to set link name: {e}"))?;
953 }
954 header.set_cksum();
955
956 builder
957 .append_data(&mut header, &new_path, &mut entry)
958 .map_err(|e| format!("Failed to append to tar: {e}"))?;
959 }
960
961 component_names
962 .into_iter()
963 .map(|component_name| {
964 let builder = builders
965 .remove(&component_name)
966 .ok_or_else(|| format!("Missing builder for {component_name}"))?;
967 let file = builder
968 .into_inner()
969 .and_then(|w| w.into_inner().map_err(std::io::IntoInnerError::into_error))
970 .and_then(|mut f| f.rewind().map(|()| f))
971 .map_err(|e| format!("Failed to finish tar for {component_name}: {e}"))?;
972 Ok((component_name, file))
973 })
974 .collect()
975}
976
977fn compress_component(mut component_tar: File) -> std::io::Result<Vec<u8>> {
978 let mut xz_encoder = xz2::write::XzEncoder::new(Vec::new(), 6);
979 std::io::copy(&mut component_tar, &mut xz_encoder)?;
980 xz_encoder.finish()
981}
982
983fn append_metadata_file<W: Write>(
985 builder: &mut tar::Builder<W>,
986 path: &str,
987 content: &[u8],
988) -> Result<(), String> {
989 let mut header = tar::Header::new_gnu();
990 header.set_size(content.len() as u64);
991 header.set_mode(0o644);
992 header.set_entry_type(tar::EntryType::Regular);
993 header.set_cksum();
994 builder
995 .append_data(&mut header, path, content)
996 .map_err(|e| format!("Failed to add {path}: {e}"))
997}
998
999fn generate_manifest(
1000 toolchain: &ToolchainWithTargets,
1001 settings: &Arc<kellnr_settings::Settings>,
1002) -> String {
1003 let base_url = format!("{}/api/v1/toolchains/dist", settings.origin.base_url());
1004
1005 let mut manifest = String::new();
1006 let _ = write!(
1007 manifest,
1008 r#"manifest-version = "2"
1009date = "{}"
1010"#,
1011 toolchain.date
1012 );
1013
1014 let _ = write!(
1016 manifest,
1017 r#"
1018[pkg.rust]
1019version = "{}"
1020"#,
1021 toolchain.version
1022 );
1023
1024 let ready_targets: Vec<_> = toolchain
1026 .targets
1027 .iter()
1028 .filter(|t| t.status == ToolchainTargetStatus::Ready)
1029 .collect();
1030
1031 for target_info in &ready_targets {
1032 let archive_url = format!("{}/{}", base_url, target_info.storage_path);
1033 let _ = write!(
1034 manifest,
1035 r#"
1036[pkg.rust.target.{}]
1037available = true
1038xz_url = "{}"
1039xz_hash = "{}"
1040"#,
1041 target_info.target, archive_url, target_info.hash
1042 );
1043
1044 for component in &target_info.components {
1046 let _ = write!(
1047 manifest,
1048 r#"
1049[[pkg.rust.target.{}.components]]
1050pkg = "{}"
1051target = "{}"
1052"#,
1053 target_info.target, component.name, target_info.target
1054 );
1055 }
1056 }
1057
1058 let mut component_pkgs: std::collections::BTreeMap<
1061 String,
1062 Vec<(&str, &kellnr_db::ToolchainComponentInfo)>,
1063 > = std::collections::BTreeMap::new();
1064 for target_info in &ready_targets {
1065 for component in &target_info.components {
1066 component_pkgs
1067 .entry(component.name.clone())
1068 .or_default()
1069 .push((&target_info.target, component));
1070 }
1071 }
1072
1073 for (pkg_name, targets) in &component_pkgs {
1074 let _ = write!(
1075 manifest,
1076 r#"
1077[pkg.{}]
1078version = "{}"
1079"#,
1080 pkg_name, toolchain.version
1081 );
1082
1083 for (target, comp) in targets {
1084 let comp_url = format!("{}/{}", base_url, comp.storage_path);
1085 let _ = write!(
1086 manifest,
1087 r#"
1088[pkg.{}.target.{}]
1089available = true
1090xz_url = "{}"
1091xz_hash = "{}"
1092"#,
1093 pkg_name, target, comp_url, comp.hash
1094 );
1095 }
1096 }
1097
1098 manifest
1099}
1100
1101#[cfg(test)]
1102mod tests {
1103 use std::io::Cursor;
1104 use std::sync::Arc;
1105 use std::sync::atomic::{AtomicBool, Ordering};
1106
1107 use axum::Router;
1108 use axum::body::{Body, to_bytes};
1109 use axum::http::{Request, StatusCode, header};
1110 use axum::response::Response;
1111 use axum::routing::{delete, get, put};
1112 use bytes::Bytes;
1113 use kellnr_common::account_name::UserName;
1114 use kellnr_common::plain_token::PlainToken;
1115 use kellnr_db::mock::MockDb;
1116 use kellnr_db::test_utils::test_drop_table;
1117 use kellnr_db::{DbProvider, ToolchainTargetInfo, ToolchainWithTargets, UserFlags};
1118 use kellnr_http::error::ErrorDetails;
1119 use kellnr_http::state::AppStateData;
1120 use kellnr_http::test_support::{
1121 encode_cookies, session_cookie, sqlite_test_state, test_state,
1122 };
1123 use kellnr_storage::cached_crate_storage::DynStorage;
1124 use kellnr_storage::fs_storage::FSStorage;
1125 use kellnr_storage::toolchain_storage::ToolchainStorage;
1126 use kellnr_test_db::TestDb;
1127 use mockall::predicate::*;
1128 use serde::de::DeserializeOwned;
1129 use tempfile::TempDir;
1130 use tower::ServiceExt;
1131
1132 use super::*;
1133
1134 const TARGET: &str = "x86_64-unknown-linux-gnu";
1135 const ARCHIVE_PATH: &str = "2024-01-15/rust-1.0.0-x86_64-unknown-linux-gnu.tar.xz";
1136 const UPLOAD_URL: &str = "/api/v1/toolchains?name=rust&version=1.0.0&target=x86_64-unknown-linux-gnu&date=2024-01-15";
1137
1138 async fn admin_state(
1141 toolchain_storage: Option<Arc<ToolchainStorage>>,
1142 ) -> (AppStateData, TestDb, String) {
1143 let (state, db) = sqlite_test_state().await;
1144 let cookie = session_cookie(&db, "admin", UserFlags::admin()).await;
1145 let state = AppStateData {
1146 toolchain_storage,
1147 ..state
1148 };
1149 (state, db, cookie)
1150 }
1151
1152 fn create_test_router(state: AppStateData) -> Router {
1154 let api_routes = Router::new()
1155 .route("/", get(list_toolchains))
1156 .route("/", put(upload_toolchain))
1157 .route("/{name}/{version}", delete(delete_toolchain))
1158 .route(
1159 "/{name}/{version}/targets/{target}",
1160 delete(delete_toolchain_target),
1161 )
1162 .route("/channels", get(list_channels))
1163 .route("/channels/{channel}", put(set_channel));
1164
1165 let dist_routes = Router::new()
1166 .route("/{manifest_file}", get(get_channel_manifest))
1167 .route("/{date}/{filename}", get(download_archive));
1168
1169 Router::new()
1170 .nest("/api/v1/toolchains", api_routes)
1171 .nest("/api/v1/toolchains/dist", dist_routes)
1172 .with_state(state)
1173 }
1174
1175 async fn send(state: AppStateData, request: Request<Body>) -> Response<Body> {
1176 create_test_router(state).oneshot(request).await.unwrap()
1177 }
1178
1179 async fn get_with(state: AppStateData, uri: &str, cookie: &str) -> Response<Body> {
1180 let mut request = Request::get(uri);
1181 if !cookie.is_empty() {
1182 request = request.header(header::COOKIE, cookie);
1183 }
1184 send(state, request.body(Body::empty()).unwrap()).await
1185 }
1186
1187 async fn parse_response<T: DeserializeOwned>(response: Response<Body>) -> T {
1188 let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1189 serde_json::from_slice(&bytes).unwrap()
1190 }
1191
1192 async fn error_detail(response: Response<Body>) -> String {
1193 let details: ErrorDetails = parse_response(response).await;
1194 details.errors[0].detail.clone()
1195 }
1196
1197 async fn text(response: Response<Body>) -> String {
1198 let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1199 String::from_utf8(body.to_vec()).unwrap()
1200 }
1201
1202 fn sample_archive() -> Bytes {
1203 Bytes::from(vec![0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00])
1205 }
1206
1207 fn upload_request(uri: &str, cookie: &str) -> Request<Body> {
1208 Request::put(uri)
1209 .header(header::COOKIE, cookie)
1210 .header(header::CONTENT_TYPE, "application/octet-stream")
1211 .body(Body::from(sample_archive()))
1212 .unwrap()
1213 }
1214
1215 fn delete_request(uri: &str, cookie: &str) -> Request<Body> {
1216 Request::delete(uri)
1217 .header(header::COOKIE, cookie)
1218 .body(Body::empty())
1219 .unwrap()
1220 }
1221
1222 fn set_channel_request(channel: &str, version: &str, cookie: &str) -> Request<Body> {
1223 Request::put(format!("/api/v1/toolchains/channels/{channel}"))
1224 .header(header::COOKIE, cookie)
1225 .header(header::CONTENT_TYPE, "application/json")
1226 .body(Body::from(format!(
1227 r#"{{"name": "rust", "version": "{version}"}}"#
1228 )))
1229 .unwrap()
1230 }
1231
1232 async fn add_toolchain(db: &TestDb, targets: &[&str]) -> Vec<i64> {
1235 let id = db
1236 .add_toolchain("rust", "1.0.0", "2024-01-15", Some("stable".to_string()))
1237 .await
1238 .unwrap();
1239 let mut ids = Vec::new();
1240 for target in targets {
1241 let target_id = db
1242 .add_toolchain_target(
1243 id,
1244 target,
1245 &format!("2024-01-15/rust-1.0.0-{target}.tar.xz"),
1246 "abc123",
1247 1024,
1248 )
1249 .await
1250 .unwrap();
1251 db.set_target_status(target_id, ToolchainTargetStatus::Ready)
1252 .await
1253 .unwrap();
1254 ids.push(target_id);
1255 }
1256 ids
1257 }
1258
1259 async fn toolchain(db: &TestDb) -> Option<ToolchainWithTargets> {
1261 db.get_toolchain_by_version("rust", "1.0.0").await.unwrap()
1262 }
1263
1264 fn target_names(toolchain: &ToolchainWithTargets) -> Vec<&str> {
1265 toolchain
1266 .targets
1267 .iter()
1268 .map(|t| t.target.as_str())
1269 .collect()
1270 }
1271
1272 fn stored(path: &str) -> StorageKey {
1273 StorageKey::from_stored(path.to_string())
1274 }
1275
1276 fn fs_toolchain_storage(dir: &TempDir) -> Arc<ToolchainStorage> {
1277 let storage: DynStorage = Box::new(FSStorage::new(dir.path().to_str().unwrap()).unwrap());
1278 Arc::new(ToolchainStorage::new(storage))
1279 }
1280
1281 #[tokio::test]
1284 async fn admin_endpoints_forbid_non_admin() {
1285 let (state, db) = sqlite_test_state().await;
1286 let cookie = session_cookie(&db, "user", UserFlags::normal()).await;
1287 let requests = [
1288 Request::get("/api/v1/toolchains")
1289 .header(header::COOKIE, &cookie)
1290 .body(Body::empty())
1291 .unwrap(),
1292 upload_request(UPLOAD_URL, &cookie),
1293 delete_request("/api/v1/toolchains/rust/1.0.0", &cookie),
1294 delete_request(
1295 "/api/v1/toolchains/rust/1.0.0/targets/x86_64-unknown-linux-gnu",
1296 &cookie,
1297 ),
1298 Request::get("/api/v1/toolchains/channels")
1299 .header(header::COOKIE, &cookie)
1300 .body(Body::empty())
1301 .unwrap(),
1302 set_channel_request("stable", "1.0.0", &cookie),
1303 ];
1304
1305 for request in requests {
1306 let uri = request.uri().to_string();
1307 let response = send(state.clone(), request).await;
1308 assert_eq!(StatusCode::FORBIDDEN, response.status(), "{uri}");
1309 }
1310 }
1311
1312 #[tokio::test]
1315 async fn test_list_toolchains_returns_data() {
1316 let (state, db, cookie) = admin_state(None).await;
1317 add_toolchain(&db, &[TARGET]).await;
1318
1319 let response = get_with(state, "/api/v1/toolchains", &cookie).await;
1320
1321 assert_eq!(StatusCode::OK, response.status());
1322
1323 let toolchains: Vec<ToolchainWithTargets> = parse_response(response).await;
1324 assert_eq!(toolchains.len(), 1);
1325 assert_eq!(toolchains[0].name, "rust");
1326 assert_eq!(toolchains[0].version, "1.0.0");
1327 assert_eq!(toolchains[0].targets.len(), 1);
1328 }
1329
1330 #[tokio::test]
1331 async fn test_list_channels_returns_data() {
1332 let (state, db, cookie) = admin_state(None).await;
1333 add_toolchain(&db, &[]).await;
1334
1335 let response = get_with(state, "/api/v1/toolchains/channels", &cookie).await;
1336
1337 assert_eq!(StatusCode::OK, response.status());
1338
1339 let channels: Vec<ChannelInfo> = parse_response(response).await;
1340 assert_eq!(channels.len(), 1);
1341 assert_eq!(channels[0].name, "stable");
1342 assert_eq!(channels[0].version, "1.0.0");
1343 }
1344
1345 #[tokio::test]
1346 async fn test_upload_toolchain_success() {
1347 let temp_dir = TempDir::new().unwrap();
1348 let (state, db, cookie) = admin_state(Some(fs_toolchain_storage(&temp_dir))).await;
1349
1350 let response = send(
1351 state,
1352 upload_request(&format!("{UPLOAD_URL}&channel=stable"), &cookie),
1353 )
1354 .await;
1355
1356 assert_eq!(StatusCode::OK, response.status());
1357
1358 let result: ToolchainResponse = parse_response(response).await;
1359 assert!(result.success);
1360 assert!(result.message.unwrap().contains("Uploaded"));
1361 let toolchain = toolchain(&db).await.unwrap();
1362 assert_eq!(toolchain.date, "2024-01-15");
1363 assert_eq!(toolchain.channel.as_deref(), Some("stable"));
1364 assert_eq!(target_names(&toolchain), vec![TARGET]);
1365 assert_eq!(toolchain.targets[0].storage_path, ARCHIVE_PATH);
1366 }
1367
1368 #[tokio::test]
1369 async fn test_set_channel_success() {
1370 let (state, db, cookie) = admin_state(None).await;
1371 add_toolchain(&db, &[]).await;
1372
1373 let response = send(state, set_channel_request("beta", "1.0.0", &cookie)).await;
1374
1375 assert_eq!(StatusCode::OK, response.status());
1376
1377 let result: ToolchainResponse = parse_response(response).await;
1378 assert!(result.success);
1379 assert_eq!(
1380 toolchain(&db).await.unwrap().channel.as_deref(),
1381 Some("beta")
1382 );
1383 }
1384
1385 #[tokio::test]
1386 async fn test_delete_toolchain_target_success() {
1387 let temp_dir = TempDir::new().unwrap();
1388 let (state, db, cookie) = admin_state(Some(fs_toolchain_storage(&temp_dir))).await;
1389 add_toolchain(&db, &[TARGET, "aarch64-unknown-linux-gnu"]).await;
1391
1392 let response = send(
1393 state,
1394 delete_request(
1395 "/api/v1/toolchains/rust/1.0.0/targets/x86_64-unknown-linux-gnu",
1396 &cookie,
1397 ),
1398 )
1399 .await;
1400
1401 assert_eq!(StatusCode::OK, response.status());
1402
1403 let result: ToolchainResponse = parse_response(response).await;
1404 assert!(result.success);
1405 let toolchain = toolchain(&db).await.unwrap();
1406 assert_eq!(target_names(&toolchain), vec!["aarch64-unknown-linux-gnu"]);
1407 }
1408
1409 #[tokio::test]
1410 async fn test_delete_last_target_auto_deletes_toolchain() {
1411 let temp_dir = TempDir::new().unwrap();
1412 let (state, db, cookie) = admin_state(Some(fs_toolchain_storage(&temp_dir))).await;
1413 add_toolchain(&db, &[TARGET]).await;
1414
1415 let response = send(
1416 state,
1417 delete_request(
1418 "/api/v1/toolchains/rust/1.0.0/targets/x86_64-unknown-linux-gnu",
1419 &cookie,
1420 ),
1421 )
1422 .await;
1423
1424 assert_eq!(StatusCode::OK, response.status());
1425
1426 let result: ToolchainResponse = parse_response(response).await;
1427 assert!(result.success);
1428 let msg = result.message.unwrap();
1430 assert!(
1431 msg.contains("last target removed"),
1432 "Expected 'last target removed' in message: {msg}"
1433 );
1434 assert!(toolchain(&db).await.is_none());
1435 }
1436
1437 #[tokio::test]
1438 async fn test_delete_toolchain_success() {
1439 let temp_dir = TempDir::new().unwrap();
1440 let (state, db, cookie) = admin_state(Some(fs_toolchain_storage(&temp_dir))).await;
1441 add_toolchain(&db, &[TARGET, "aarch64-unknown-linux-gnu"]).await;
1442
1443 let response = send(
1444 state,
1445 delete_request("/api/v1/toolchains/rust/1.0.0", &cookie),
1446 )
1447 .await;
1448
1449 assert_eq!(StatusCode::OK, response.status());
1450
1451 let result: ToolchainResponse = parse_response(response).await;
1452 assert!(result.success);
1453 assert!(result.message.unwrap().contains("Deleted"));
1454 assert!(toolchain(&db).await.is_none());
1455 }
1456
1457 #[tokio::test]
1458 async fn test_delete_toolchain_not_found() {
1459 let temp_dir = TempDir::new().unwrap();
1460 let (state, _db, cookie) = admin_state(Some(fs_toolchain_storage(&temp_dir))).await;
1461
1462 let response = send(
1463 state,
1464 delete_request("/api/v1/toolchains/rust/99.99.99", &cookie),
1465 )
1466 .await;
1467
1468 assert_eq!(StatusCode::NOT_FOUND, response.status());
1469
1470 assert!(error_detail(response).await.contains("not found"));
1471 }
1472
1473 #[tokio::test]
1476 async fn test_upload_toolchain_storage_not_configured() {
1477 let (state, _db, cookie) = admin_state(None).await;
1479
1480 let response = send(state, upload_request(UPLOAD_URL, &cookie)).await;
1481
1482 assert_eq!(StatusCode::SERVICE_UNAVAILABLE, response.status());
1483
1484 assert!(error_detail(response).await.contains("not configured"));
1485 }
1486
1487 #[tokio::test]
1488 async fn test_upload_duplicate_target_conflict() {
1489 let temp_dir = TempDir::new().unwrap();
1490 let (state, db, cookie) = admin_state(Some(fs_toolchain_storage(&temp_dir))).await;
1491 add_toolchain(&db, &[TARGET]).await;
1492
1493 let response = send(state, upload_request(UPLOAD_URL, &cookie)).await;
1494
1495 assert_eq!(StatusCode::CONFLICT, response.status());
1496
1497 assert!(error_detail(response).await.contains("already exists"));
1498 }
1499
1500 #[tokio::test]
1501 async fn test_set_channel_toolchain_not_found() {
1502 let (state, _db, cookie) = admin_state(None).await;
1503
1504 let response = send(state, set_channel_request("stable", "99.99.99", &cookie)).await;
1505
1506 assert_eq!(StatusCode::NOT_FOUND, response.status());
1507
1508 assert!(error_detail(response).await.contains("not found"));
1509 }
1510
1511 #[tokio::test]
1512 async fn test_get_manifest_invalid_format_not_found() {
1513 let response = get_with(test_state(), "/api/v1/toolchains/dist/stable.toml", "").await;
1515
1516 assert_eq!(StatusCode::NOT_FOUND, response.status());
1517 }
1518
1519 #[tokio::test]
1520 async fn test_get_manifest_channel_not_found() {
1521 let (state, _db) = sqlite_test_state().await;
1522
1523 let response = get_with(
1524 state,
1525 "/api/v1/toolchains/dist/channel-rust-nonexistent.toml",
1526 "",
1527 )
1528 .await;
1529
1530 assert_eq!(StatusCode::NOT_FOUND, response.status());
1531 }
1532
1533 #[tokio::test]
1534 async fn test_download_archive_not_found() {
1535 let temp_dir = TempDir::new().unwrap();
1536 let state = AppStateData {
1537 toolchain_storage: Some(fs_toolchain_storage(&temp_dir)),
1538 ..test_state()
1539 };
1540
1541 let response = get_with(
1542 state,
1543 "/api/v1/toolchains/dist/2024-01-15/nonexistent.tar.xz",
1544 "",
1545 )
1546 .await;
1547
1548 assert_eq!(StatusCode::NOT_FOUND, response.status());
1549 }
1550
1551 #[tokio::test]
1552 async fn test_download_archive_storage_not_configured() {
1553 let response = get_with(
1555 test_state(),
1556 "/api/v1/toolchains/dist/2024-01-15/rust-1.0.0-x86_64-unknown-linux-gnu.tar.xz",
1557 "",
1558 )
1559 .await;
1560
1561 assert_eq!(StatusCode::SERVICE_UNAVAILABLE, response.status());
1562 }
1563
1564 #[tokio::test]
1567 async fn test_get_manifest_returns_valid_toml() {
1568 let (state, db) = sqlite_test_state().await;
1569 add_toolchain(&db, &[TARGET]).await;
1570
1571 let response = get_with(
1572 state,
1573 "/api/v1/toolchains/dist/channel-rust-stable.toml",
1574 "",
1575 )
1576 .await;
1577
1578 assert_eq!(StatusCode::OK, response.status());
1579 assert_eq!(
1580 response.headers().get(header::CONTENT_TYPE).unwrap(),
1581 "text/toml; charset=utf-8"
1582 );
1583
1584 let manifest = text(response).await;
1585
1586 assert!(manifest.contains("manifest-version = \"2\""));
1588 assert!(manifest.contains("date = \"2024-01-15\""));
1589 assert!(manifest.contains("[pkg.rust]"));
1590 assert!(manifest.contains("version = \"1.0.0\""));
1591 assert!(manifest.contains("[pkg.rust.target.x86_64-unknown-linux-gnu]"));
1592 assert!(manifest.contains("available = true"));
1593 assert!(manifest.contains("xz_url = \""));
1594 assert!(manifest.contains("xz_hash = \"abc123\""));
1595 }
1596
1597 #[tokio::test]
1598 async fn test_get_manifest_by_version_falls_back_when_no_channel() {
1599 let (state, db) = sqlite_test_state().await;
1600 add_toolchain(&db, &[TARGET]).await;
1601
1602 let response = get_with(state, "/api/v1/toolchains/dist/channel-rust-1.0.0.toml", "").await;
1604
1605 assert_eq!(StatusCode::OK, response.status());
1606
1607 let manifest = text(response).await;
1608
1609 assert!(manifest.contains("manifest-version = \"2\""));
1610 assert!(manifest.contains("version = \"1.0.0\""));
1611 assert!(manifest.contains("[pkg.rust.target.x86_64-unknown-linux-gnu]"));
1612 }
1613
1614 #[tokio::test]
1615 async fn test_manifest_includes_component_packages() {
1616 let (state, db) = sqlite_test_state().await;
1617 let target_id = add_toolchain(&db, &[TARGET]).await[0];
1618 for (name, hash, size) in [("rustc", "rustc_hash", 512), ("cargo", "cargo_hash", 256)] {
1619 db.add_toolchain_component(
1620 target_id,
1621 name,
1622 &format!("2024-01-15/{name}-1.0.0-x86_64-unknown-linux-gnu.tar.xz"),
1623 hash,
1624 size,
1625 )
1626 .await
1627 .unwrap();
1628 }
1629
1630 let response = get_with(
1631 state,
1632 "/api/v1/toolchains/dist/channel-rust-stable.toml",
1633 "",
1634 )
1635 .await;
1636
1637 assert_eq!(StatusCode::OK, response.status());
1638
1639 let manifest = text(response).await;
1640
1641 assert!(manifest.contains("[[pkg.rust.target.x86_64-unknown-linux-gnu.components]]"));
1643 assert!(manifest.contains("pkg = \"rustc\""));
1644 assert!(manifest.contains("pkg = \"cargo\""));
1645
1646 assert!(manifest.contains("[pkg.rustc]"));
1648 assert!(manifest.contains("[pkg.rustc.target.x86_64-unknown-linux-gnu]"));
1649 assert!(manifest.contains("xz_hash = \"rustc_hash\""));
1650
1651 assert!(manifest.contains("[pkg.cargo]"));
1652 assert!(manifest.contains("[pkg.cargo.target.x86_64-unknown-linux-gnu]"));
1653 assert!(manifest.contains("xz_hash = \"cargo_hash\""));
1654 }
1655
1656 fn two_component_archive() -> Vec<u8> {
1659 use std::io::Write;
1660
1661 let mut tar_builder = tar::Builder::new(Vec::new());
1663
1664 let version_data = b"3\n";
1666 let mut header = tar::Header::new_gnu();
1667 header.set_size(version_data.len() as u64);
1668 header.set_mode(0o644);
1669 header.set_entry_type(tar::EntryType::Regular);
1670 header.set_cksum();
1671 tar_builder
1672 .append_data(
1673 &mut header,
1674 "rust-1.0.0-x86_64-unknown-linux-gnu/rust-installer-version",
1675 &version_data[..],
1676 )
1677 .unwrap();
1678
1679 let components_data = b"rustc\ncargo\n";
1681 let mut header = tar::Header::new_gnu();
1682 header.set_size(components_data.len() as u64);
1683 header.set_mode(0o644);
1684 header.set_entry_type(tar::EntryType::Regular);
1685 header.set_cksum();
1686 tar_builder
1687 .append_data(
1688 &mut header,
1689 "rust-1.0.0-x86_64-unknown-linux-gnu/components",
1690 &components_data[..],
1691 )
1692 .unwrap();
1693
1694 let manifest_data = b"file:bin/rustc\n";
1696 let mut header = tar::Header::new_gnu();
1697 header.set_size(manifest_data.len() as u64);
1698 header.set_mode(0o644);
1699 header.set_entry_type(tar::EntryType::Regular);
1700 header.set_cksum();
1701 tar_builder
1702 .append_data(
1703 &mut header,
1704 "rust-1.0.0-x86_64-unknown-linux-gnu/rustc/manifest.in",
1705 &manifest_data[..],
1706 )
1707 .unwrap();
1708
1709 let manifest_data = b"file:bin/cargo\n";
1711 let mut header = tar::Header::new_gnu();
1712 header.set_size(manifest_data.len() as u64);
1713 header.set_mode(0o644);
1714 header.set_entry_type(tar::EntryType::Regular);
1715 header.set_cksum();
1716 tar_builder
1717 .append_data(
1718 &mut header,
1719 "rust-1.0.0-x86_64-unknown-linux-gnu/cargo/manifest.in",
1720 &manifest_data[..],
1721 )
1722 .unwrap();
1723
1724 let tar_data = tar_builder.into_inner().unwrap();
1725
1726 let mut xz_encoder = xz2::write::XzEncoder::new(Vec::new(), 1);
1728 xz_encoder.write_all(&tar_data).unwrap();
1729 xz_encoder.finish().unwrap()
1730 }
1731
1732 const ARCHIVE_ID: ToolchainArchiveId<'static> = ToolchainArchiveId {
1733 name: "rust",
1734 version: "1.0.0",
1735 date: "2024-01-15",
1736 target: TARGET,
1737 };
1738
1739 #[tokio::test]
1740 async fn test_extract_components_from_archive() {
1741 let xz_data = two_component_archive();
1742
1743 let tmp_dir = TempDir::new().unwrap();
1745 let toolchain_dir = tmp_dir.path().join("toolchains");
1746 std::fs::create_dir_all(&toolchain_dir).unwrap();
1747 let fs = Box::new(FSStorage::new(toolchain_dir.to_str().unwrap()).unwrap()) as DynStorage;
1748 let storage = Arc::new(ToolchainStorage::new(fs));
1749 let db = TestDb::sqlite().await;
1750 let target_id = add_toolchain(&db, &[TARGET]).await[0];
1751
1752 let result = extract_and_store_components(
1753 &storage,
1754 &db.provider(),
1755 target_id,
1756 &ARCHIVE_ID,
1757 Bytes::from(xz_data),
1758 )
1759 .await;
1760
1761 assert!(result.is_ok(), "extraction failed: {result:?}");
1762
1763 let mut components: Vec<String> = toolchain(&db).await.unwrap().targets[0]
1764 .components
1765 .iter()
1766 .map(|c| c.name.clone())
1767 .collect();
1768 components.sort_unstable();
1769 assert_eq!(components, vec!["cargo", "rustc"]);
1770
1771 let rustc_exists = storage
1773 .exists(&stored(
1774 "2024-01-15/rustc-1.0.0-x86_64-unknown-linux-gnu.tar.xz",
1775 ))
1776 .await
1777 .unwrap();
1778 assert!(rustc_exists, "rustc component archive not found in storage");
1779
1780 let cargo_exists = storage
1781 .exists(&stored(
1782 "2024-01-15/cargo-1.0.0-x86_64-unknown-linux-gnu.tar.xz",
1783 ))
1784 .await
1785 .unwrap();
1786 assert!(cargo_exists, "cargo component archive not found in storage");
1787
1788 let rustc_archive = storage
1790 .get(&stored(
1791 "2024-01-15/rustc-1.0.0-x86_64-unknown-linux-gnu.tar.xz",
1792 ))
1793 .await
1794 .unwrap();
1795 let mut xz_dec = xz2::read::XzDecoder::new(&rustc_archive[..]);
1796 let mut tar_bytes = Vec::new();
1797 xz_dec.read_to_end(&mut tar_bytes).unwrap();
1798 let mut archive = tar::Archive::new(Cursor::new(&tar_bytes));
1799 let mut entry_names: Vec<String> = Vec::new();
1800 for entry in archive.entries().unwrap() {
1801 let mut entry = entry.unwrap();
1802 let path = entry.path().unwrap().to_string_lossy().to_string();
1803 if path.ends_with("manifest.in") {
1804 let mut contents = String::new();
1805 entry.read_to_string(&mut contents).unwrap();
1806 assert_eq!("file:bin/rustc\n", contents);
1807 }
1808 entry_names.push(path);
1809 }
1810 assert!(
1811 entry_names
1812 .iter()
1813 .any(|n| n.contains("rust-installer-version")),
1814 "missing rust-installer-version in component archive: {entry_names:?}"
1815 );
1816 assert!(
1817 entry_names.iter().any(|n| n.contains("components")),
1818 "missing components file in component archive: {entry_names:?}"
1819 );
1820 assert!(
1821 entry_names.iter().any(|n| n.contains("manifest.in")),
1822 "missing manifest.in in component archive: {entry_names:?}"
1823 );
1824 }
1825
1826 const RUSTC_COMPONENT: &str = "2024-01-15/rustc-1.0.0-x86_64-unknown-linux-gnu.tar.xz";
1827
1828 async fn extract_over_leftover(
1832 storage: &Arc<ToolchainStorage>,
1833 in_use: bool,
1834 ) -> Result<(), String> {
1835 storage
1836 .put_raw(&stored(RUSTC_COMPONENT), Bytes::from_static(b"leftover"))
1837 .await
1838 .unwrap();
1839 let db = TestDb::sqlite().await;
1840 let ids = add_toolchain(&db, &[TARGET, "aarch64-unknown-linux-gnu"]).await;
1841 if in_use {
1842 db.add_toolchain_component(ids[1], "rustc", RUSTC_COMPONENT, "hash", 8)
1843 .await
1844 .unwrap();
1845 }
1846
1847 extract_and_store_components(
1848 storage,
1849 &db.provider(),
1850 ids[0],
1851 &ARCHIVE_ID,
1852 Bytes::from(two_component_archive()),
1853 )
1854 .await
1855 }
1856
1857 #[tokio::test]
1858 async fn extract_components_replaces_leftover_component_archive() {
1859 let dir = TempDir::new().unwrap();
1860 let storage = fs_toolchain_storage(&dir);
1861
1862 let result = extract_over_leftover(&storage, false).await;
1863
1864 assert!(result.is_ok(), "extraction failed: {result:?}");
1865 let stored = storage.get(&stored(RUSTC_COMPONENT)).await.unwrap();
1866 assert_ne!(Bytes::from_static(b"leftover"), stored);
1867 }
1868
1869 #[tokio::test]
1870 async fn extract_components_keeps_component_archive_of_other_target() {
1871 let dir = TempDir::new().unwrap();
1872 let storage = fs_toolchain_storage(&dir);
1873
1874 let result = extract_over_leftover(&storage, true).await;
1875
1876 assert!(result.is_err());
1877 let stored = storage.get(&stored(RUSTC_COMPONENT)).await.unwrap();
1878 assert_eq!(Bytes::from_static(b"leftover"), stored);
1879 }
1880
1881 #[tokio::test]
1882 async fn test_get_manifest_sha256_returns_ok() {
1883 let (state, db) = sqlite_test_state().await;
1884 add_toolchain(&db, &[TARGET]).await;
1885
1886 let response = get_with(
1889 state,
1890 "/api/v1/toolchains/dist/channel-rust-stable.toml.sha256",
1891 "",
1892 )
1893 .await;
1894
1895 assert_eq!(StatusCode::OK, response.status());
1896
1897 let hash_content = text(response).await;
1898
1899 let hash = hash_content.trim();
1901 assert_eq!(
1902 hash.len(),
1903 64,
1904 "SHA256 hash should be 64 hex characters, got: {hash}"
1905 );
1906 assert!(
1907 hash.chars().all(|c| c.is_ascii_hexdigit()),
1908 "SHA256 hash should only contain hex characters"
1909 );
1910 }
1911
1912 fn ready_toolchain(targets: &[&str]) -> ToolchainWithTargets {
1913 ToolchainWithTargets {
1914 id: 1,
1915 name: "rust".to_string(),
1916 version: "1.0.0".to_string(),
1917 date: "2024-01-15".to_string(),
1918 channel: Some("stable".to_string()),
1919 created: "2024-01-15".to_string(),
1920 targets: targets
1921 .iter()
1922 .zip(1..)
1923 .map(|(target, id)| ToolchainTargetInfo {
1924 id,
1925 target: (*target).to_string(),
1926 storage_path: format!("2024-01-15/rust-1.0.0-{target}.tar.xz"),
1927 hash: "abc123".to_string(),
1928 size: 1024,
1929 components: vec![],
1930 status: ToolchainTargetStatus::Ready,
1931 })
1932 .collect(),
1933 }
1934 }
1935
1936 #[test]
1937 fn manifest_urls_use_the_origin_path_and_omit_the_default_port() {
1938 let mut settings = kellnr_settings::test_settings();
1939 settings.origin.hostname = "kellnr.example.com".to_string();
1940 settings.origin.protocol = kellnr_settings::Protocol::Https;
1941 settings.origin.port = 443;
1942 settings.origin.path = "/registry".to_string();
1943
1944 let manifest = generate_manifest(
1945 &ready_toolchain(&["x86_64-unknown-linux-gnu"]),
1946 &Arc::new(settings),
1947 );
1948
1949 assert!(
1950 manifest.contains(
1951 "xz_url = \"https://kellnr.example.com/registry/api/v1/toolchains/dist/2024-01-15/rust-1.0.0-x86_64-unknown-linux-gnu.tar.xz\""
1952 ),
1953 "unexpected manifest: {manifest}"
1954 );
1955 }
1956
1957 #[tokio::test]
1960 async fn delete_unknown_target_of_single_target_toolchain_is_not_found() {
1961 let temp_dir = TempDir::new().unwrap();
1962 let (state, db, cookie) = admin_state(Some(fs_toolchain_storage(&temp_dir))).await;
1963 add_toolchain(&db, &[TARGET]).await;
1964
1965 let response = send(
1966 state,
1967 delete_request(
1968 "/api/v1/toolchains/rust/1.0.0/targets/x86_64-unknown-linux-gnux",
1969 &cookie,
1970 ),
1971 )
1972 .await;
1973
1974 assert_eq!(StatusCode::NOT_FOUND, response.status());
1975 assert_eq!(target_names(&toolchain(&db).await.unwrap()), vec![TARGET]);
1976 }
1977
1978 #[tokio::test]
1979 async fn upload_does_not_create_toolchain_when_archive_cannot_be_stored() {
1980 let temp_dir = TempDir::new().unwrap();
1981 let storage = fs_toolchain_storage(&temp_dir);
1982 storage
1983 .put_raw(&stored(ARCHIVE_PATH), Bytes::from_static(b"orphan"))
1984 .await
1985 .unwrap();
1986 let (state, db, cookie) = admin_state(Some(storage)).await;
1987
1988 let response = send(state, upload_request(UPLOAD_URL, &cookie)).await;
1989
1990 assert_eq!(StatusCode::CONFLICT, response.status());
1991 assert!(toolchain(&db).await.is_none());
1992 }
1993
1994 #[tokio::test]
1995 async fn upload_removes_archive_and_new_toolchain_when_target_cannot_be_added() {
1996 let temp_dir = TempDir::new().unwrap();
1997 let storage = fs_toolchain_storage(&temp_dir);
1998 let (state, db, cookie) = admin_state(Some(storage.clone())).await;
1999 test_drop_table(&db, "toolchain_target").await.unwrap();
2000
2001 let response = send(state, upload_request(UPLOAD_URL, &cookie)).await;
2002
2003 assert_eq!(StatusCode::INTERNAL_SERVER_ERROR, response.status());
2004 assert!(!error_detail(response).await.contains("no such table"));
2005 assert!(!storage.exists(&stored(ARCHIVE_PATH)).await.unwrap());
2006 assert!(toolchain(&db).await.is_none());
2007 }
2008
2009 fn admin_cookie() -> String {
2014 encode_cookies([("kellnr_session_id", "admin_session")])
2015 }
2016
2017 fn admin_db(targets: &'static [&'static str]) -> MockDb {
2020 let mut mock_db = MockDb::new();
2021 mock_db
2022 .expect_validate_session()
2023 .with(eq(PlainToken::from("admin_session")))
2024 .returning(|_| {
2025 Ok(kellnr_db::SessionInfo {
2026 name: UserName::new("admin").unwrap(),
2027 is_admin: true,
2028 is_read_only: false,
2029 })
2030 });
2031 mock_db
2032 .expect_get_toolchain_by_version()
2033 .returning(|_, _| Ok(Some(ready_toolchain(targets))));
2034 mock_db
2035 }
2036
2037 fn db_error() -> DbError {
2039 DbError::InitializationError("secret detail".to_string())
2040 }
2041
2042 async fn state_with_archive(
2045 mock_db: MockDb,
2046 dir: &TempDir,
2047 ) -> (AppStateData, Arc<ToolchainStorage>) {
2048 let storage = fs_toolchain_storage(dir);
2049 storage
2050 .put_raw(&stored(ARCHIVE_PATH), Bytes::from_static(b"archive"))
2051 .await
2052 .unwrap();
2053 let state = AppStateData {
2054 db: Arc::new(mock_db),
2055 toolchain_storage: Some(storage.clone()),
2056 ..test_state()
2057 };
2058 (state, storage)
2059 }
2060
2061 #[tokio::test]
2062 async fn delete_toolchain_keeps_archives_when_database_delete_fails() {
2063 let temp_dir = TempDir::new().unwrap();
2064 let mut mock_db = admin_db(&[TARGET]);
2065 mock_db
2066 .expect_delete_toolchain()
2067 .returning(|_, _| Err(db_error()));
2068 let (state, storage) = state_with_archive(mock_db, &temp_dir).await;
2069
2070 let response = send(
2071 state,
2072 delete_request("/api/v1/toolchains/rust/1.0.0", &admin_cookie()),
2073 )
2074 .await;
2075
2076 assert_eq!(StatusCode::INTERNAL_SERVER_ERROR, response.status());
2077 assert!(!error_detail(response).await.contains("secret detail"));
2078 assert!(storage.exists(&stored(ARCHIVE_PATH)).await.unwrap());
2079 }
2080
2081 #[tokio::test]
2082 async fn delete_target_keeps_archives_when_database_delete_fails() {
2083 let temp_dir = TempDir::new().unwrap();
2084 let mut mock_db = admin_db(&[TARGET, "aarch64-unknown-linux-gnu"]);
2085 mock_db
2086 .expect_delete_toolchain_target()
2087 .returning(|_, _, _| Err(db_error()));
2088 let (state, storage) = state_with_archive(mock_db, &temp_dir).await;
2089
2090 let response = send(
2091 state,
2092 delete_request(
2093 "/api/v1/toolchains/rust/1.0.0/targets/x86_64-unknown-linux-gnu",
2094 &admin_cookie(),
2095 ),
2096 )
2097 .await;
2098
2099 assert_eq!(StatusCode::INTERNAL_SERVER_ERROR, response.status());
2100 assert!(storage.exists(&stored(ARCHIVE_PATH)).await.unwrap());
2101 }
2102
2103 #[tokio::test]
2104 async fn database_errors_are_not_returned_to_the_client() {
2105 let temp_dir = TempDir::new().unwrap();
2106 let (state, db, cookie) = admin_state(Some(fs_toolchain_storage(&temp_dir))).await;
2107 test_drop_table(&db, "toolchain").await.unwrap();
2108
2109 let response = send(
2110 state,
2111 delete_request("/api/v1/toolchains/rust/1.0.0", &cookie),
2112 )
2113 .await;
2114
2115 assert_eq!(StatusCode::INTERNAL_SERVER_ERROR, response.status());
2116 assert!(!error_detail(response).await.contains("no such table"));
2117 }
2118
2119 #[tokio::test]
2120 async fn target_is_marked_failed_when_processing_panics() {
2121 let db = TestDb::sqlite().await;
2122 let id = db
2123 .add_toolchain("rust", "1.0.0", "2024-01-15", None)
2124 .await
2125 .unwrap();
2126 let target_id = db
2127 .add_toolchain_target(id, TARGET, ARCHIVE_PATH, "abc123", 1024)
2128 .await
2129 .unwrap();
2130
2131 spawn_target_processing(&BackgroundTasks::new(), db.provider(), target_id, async {
2132 panic!("extraction bug")
2133 })
2134 .await
2135 .unwrap();
2136
2137 let status = &toolchain(&db).await.unwrap().targets[0].status;
2138 assert_eq!(*status, ToolchainTargetStatus::Failed);
2139 }
2140
2141 #[tokio::test(start_paused = true)]
2142 async fn shutdown_waits_for_target_processing() {
2143 let tasks = BackgroundTasks::new();
2144 let finished = Arc::new(AtomicBool::new(false));
2145 let finished_in_task = finished.clone();
2146
2147 spawn_target_processing(&tasks, test_state().db, 7, async move {
2148 tokio::time::sleep(Duration::from_secs(5)).await;
2149 finished_in_task.store(true, Ordering::SeqCst);
2150 });
2151 tasks.request_shutdown();
2152 tasks.wait_for_shutdown(Duration::from_secs(30)).await;
2153
2154 assert!(finished.load(Ordering::SeqCst));
2155 }
2156}