Skip to main content

kellnr_toolchain/
lib.rs

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/// Response for toolchain operations
30#[derive(Debug, Serialize, Deserialize, ToSchema)]
31pub struct ToolchainResponse {
32    /// Whether the operation succeeded
33    pub success: bool,
34    /// Optional message with details
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub message: Option<String>,
37}
38
39/// Request to set a channel's toolchain
40#[derive(Debug, Serialize, Deserialize, ToSchema)]
41pub struct SetChannelRequest {
42    /// Toolchain name (e.g., "rust")
43    pub name: String,
44    /// Toolchain version (e.g., "1.75.0")
45    pub version: String,
46}
47
48/// Query parameters for uploading a toolchain
49#[derive(Debug, Deserialize, ToSchema, utoipa::IntoParams)]
50pub struct UploadQuery {
51    /// Toolchain name (e.g., "rust")
52    pub name: String,
53    /// Toolchain version (e.g., "1.75.0")
54    pub version: String,
55    /// Target triple (e.g., "x86_64-unknown-linux-gnu")
56    pub target: String,
57    /// Release date (e.g., "2024-01-15")
58    pub date: String,
59    /// Optional channel to associate (e.g., "stable")
60    #[serde(default)]
61    pub channel: Option<String>,
62}
63
64/// Logs the error and answers with a 500 that does not reveal its details.
65fn 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/// List all toolchains
78///
79/// Returns all toolchains with their available targets.
80/// Requires admin access.
81#[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/// Upload a toolchain
104///
105/// Adds the combined archive of one target to a toolchain. The archive is
106/// split into its components in the background.
107/// Requires admin access.
108#[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(&params.name, &params.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    // Store the archive before it is added to the database, so a failed store
158    // does not leave a toolchain or target without archive behind.
159    let archive_id = ToolchainArchiveId {
160        name: &params.name,
161        version: &params.version,
162        date: &params.date,
163        target: &params.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(), &params, &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    // Spawn background task to split the combined archive into individual component archives.
182    // This can take a while for large archives, so we don't block the upload response.
183    // The target status is "processing" until extraction completes; the manifest only
184    // includes targets with status "ready".
185    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
277/// Runs the processing of an uploaded target in the background. If it panics,
278/// the target is marked as failed instead of staying "processing" forever.
279fn 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
298/// Adds the uploaded target, and its toolchain if it is new, to the database.
299/// Removes a toolchain created here again if the target cannot be added.
300async 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                &params.name,
313                &params.version,
314                &params.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, &params.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(&params.name, &params.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/// Delete a toolchain and all its targets
342///
343/// Removes a toolchain version and all associated target archives.
344/// Requires admin access.
345#[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    // Delete from the database first (cascades to targets and components), so
384    // a failure does not leave targets whose archives are gone.
385    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/// Delete a specific toolchain target
403///
404/// Removes a specific target from a toolchain. If this is the last target,
405/// the entire toolchain is deleted.
406/// Requires admin access.
407#[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    // Delete from the database first, so a failure does not leave a target
458    // whose archives are gone. If this is the last target, the entire
459    // toolchain is deleted.
460    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
486/// Deletes the archive of a target and its component archives. Failures are
487/// only logged, the target is already removed from the database.
488async 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/// List all channels
506///
507/// Returns all configured channels (e.g., stable, beta, nightly) and
508/// the toolchain versions they point to.
509/// Requires admin access.
510#[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/// Set a channel to point to a specific toolchain version
533///
534/// Updates a channel (e.g., "stable") to point to a specific toolchain version.
535/// Requires admin access.
536#[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/// Get the channel manifest (rustup-compatible TOML)
582///
583/// Returns a rustup-compatible manifest for a channel.
584/// Expects a filename in the format `channel-rust-{channel}.toml`
585/// e.g., `channel-rust-stable.toml` or `channel-rust-nightly.toml`
586#[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    // Parse the manifest filename:
608    //   channel-rust-{channel}.toml         -> return the manifest
609    //   channel-rust-{channel}.toml.sha256  -> return the SHA256 hash of the manifest
610    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    // Try channel lookup first (e.g. "stable"), then fall back to version lookup
620    // (e.g. "1.94.0") so that `rustup install 1.94.0` works too.
621    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    // Check if any targets are ready before generating the manifest.
635    // If none are ready, the manifest would be invalid (missing required `target` field).
636    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/// Download a toolchain archive
667///
668/// Downloads a specific toolchain archive file.
669#[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
720/// Limits how many uploaded archives are split into components at the same
721/// time. Splitting decompresses a whole toolchain and compresses every
722/// component again, which keeps a CPU core busy for minutes.
723static EXTRACTION_PERMITS: Semaphore = Semaphore::const_new(1);
724
725/// Extract individual component archives from a combined toolchain archive.
726///
727/// The combined `rust-{version}-{target}.tar.xz` archive contains sub-component
728/// directories (rustc, cargo, rust-std, etc.) each with their own `manifest.in`.
729/// This function extracts each component into its own `.tar.xz` archive so rustup
730/// can download them individually.
731///
732/// The decompressed components are written to temporary files and compressed
733/// one by one, so only the archive and one compressed component are held in
734/// memory. The CPU heavy work runs on the blocking thread pool.
735async 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    // Compress and store each component archive.
766    // Track stored paths so we can clean up on failure (e.g. if the target
767    // was deleted concurrently and the DB insert fails with an FK violation).
768    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        // A file no target refers to is left over, e.g. from a target whose
781        // archives could not all be deleted, and would block the target.
782        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            // DB insert failed (likely FK violation from concurrent delete).
808            // Clean up all stored component files to avoid orphans.
809            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
828/// Splits the combined archive into one uncompressed tar per component, each
829/// in a temporary file, in the order of the `components` metadata file.
830///
831/// Uses two streamed passes through the archive.
832/// First it reads metadata (`components`, `rust-installer-version`),
833/// then it routes entries into per-component archives.
834/// This is robust even if the metadata appears late in the archive.
835fn 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    // Pass 1: read metadata only.
849    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    // Per-component tar builders created from metadata
888    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    // Pass 2: route component entries into their respective builders.
911    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        // Skip root metadata files in this pass.
925        if entry_path == meta_components_path || entry_path == meta_installer_path {
926            continue;
927        }
928
929        // Route entry to the matching component builder
930        if !entry_path.starts_with(&prefix) {
931            continue;
932        }
933        let rel_path = &entry_path[prefix.len()..];
934        // The first path segment is the component name
935        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
983/// Append a small metadata file (rust-installer-version, components) to a tar builder.
984fn 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    // [pkg.rust], the meta-package for the combined archive
1015    let _ = write!(
1016        manifest,
1017        r#"
1018[pkg.rust]
1019version = "{}"
1020"#,
1021        toolchain.version
1022    );
1023
1024    // Only include targets whose component extraction has completed
1025    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        // List each component so rustup knows what to install
1045        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    // Individual component packages, rustup downloads these one by one
1059    // Collect unique component names across all targets
1060    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    /// App state on a fresh database with `toolchain_storage`, and a session
1139    /// cookie of the admin.
1140    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    /// Create a router with API routes for testing
1153    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        // Create a minimal valid-looking archive (XZ magic bytes)
1204        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    /// Adds the toolchain `rust` 1.0.0 of 2024-01-15 on the channel `stable`,
1233    /// with a ready target for each of `targets`. Returns the target ids.
1234    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    /// The toolchain `rust` 1.0.0, if it exists.
1260    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    // ==================== Authorization Tests ====================
1282
1283    #[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    // ==================== Happy Path Tests ====================
1313
1314    #[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        // Two targets so deleting one doesn't trigger auto-delete of toolchain
1390        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        // Message should indicate the whole toolchain was deleted (last target removed)
1429        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    // ==================== Error Path Tests ====================
1474
1475    #[tokio::test]
1476    async fn test_upload_toolchain_storage_not_configured() {
1477        // No toolchain storage configured
1478        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        // Missing prefix
1514        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        // No toolchain storage
1554        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    // ==================== Manifest Generation Test ====================
1565
1566    #[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        // Verify manifest structure
1587        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        // "1.0.0" is not a channel name, so the version lookup finds the toolchain
1603        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        // Component list under the rust meta-package
1642        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        // Individual component packages with download URLs
1647        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    /// A combined archive of the toolchain `rust` 1.0.0 for
1657    /// `x86_64-unknown-linux-gnu` with the components `rustc` and `cargo`.
1658    fn two_component_archive() -> Vec<u8> {
1659        use std::io::Write;
1660
1661        // Build a minimal combined archive with two components
1662        let mut tar_builder = tar::Builder::new(Vec::new());
1663
1664        // rust-installer-version
1665        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        // components file
1680        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        // rustc/manifest.in
1695        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        // cargo/manifest.in
1710        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        // Compress with xz
1727        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        // Set up storage and database
1744        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        // Verify component archives were stored
1772        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        // Verify the component archive contains rust-installer-version and components files
1789        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    /// Extracts [`two_component_archive`] into `storage` after a leftover file
1829    /// was stored at the path of the rustc component, which another target
1830    /// refers to if `in_use`.
1831    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        // rustup requests the .sha256 of the manifest before downloading
1887        // the manifest itself. This must return a valid SHA256 hash.
1888        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        // Should be a valid hex-encoded SHA256 hash (64 characters)
1900        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    /// A misspelled target must not count as the last target, which would
1958    /// delete the whole toolchain.
1959    #[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    // The database cannot fail the delete of a toolchain while it still
2010    // reads it, so these tests keep a mock.
2011
2012    /// Create an encrypted admin session cookie
2013    fn admin_cookie() -> String {
2014        encode_cookies([("kellnr_session_id", "admin_session")])
2015    }
2016
2017    /// Database with a session of the admin that holds the toolchain `rust`
2018    /// 1.0.0 with `targets`.
2019    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    /// Stands in for a database error whose text must not reach the client.
2038    fn db_error() -> DbError {
2039        DbError::InitializationError("secret detail".to_string())
2040    }
2041
2042    /// State with `mock_db` and a toolchain storage that holds the archive of
2043    /// the `x86_64-unknown-linux-gnu` target.
2044    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}