Skip to main content

kellnr_docs/
api.rs

1use std::sync::Arc;
2
3use axum::Json;
4use axum::extract::{Path, State};
5use axum::http::StatusCode;
6use axum::response::Redirect;
7use kellnr_appstate::{AppState, DbState, DocsStorageState, SettingsState};
8use kellnr_auth::crate_access::can_read_crate;
9use kellnr_auth::maybe_user::MaybeUser;
10use kellnr_auth::token::Token;
11use kellnr_common::normalized_name::NormalizedName;
12use kellnr_common::original_name::OriginalName;
13use kellnr_common::version::Version;
14use kellnr_db::DbProvider;
15use kellnr_error::api_error::ApiResult;
16use kellnr_registry::kellnr_api::{check_can_modify, check_ownership};
17use tracing::error;
18
19use crate::doc_archive::DocArchive;
20use crate::doc_queue_response::DocQueueResponse;
21use crate::docs_error::DocsError;
22use crate::upload::upload_dir_and_prune;
23use crate::upload_response::DocUploadResponse;
24use crate::{compute_doc_url, get_latest_version_with_doc};
25
26/// Get documentation build queue
27///
28/// Returns the list of crates currently in the documentation build queue.
29/// Download-restricted crates are only listed for users who may read their docs.
30#[utoipa::path(
31    get,
32    path = "/builds",
33    tag = "docs",
34    responses(
35        (status = 200, description = "Documentation build queue", body = DocQueueResponse)
36    ),
37    security(("session_cookie" = []))
38)]
39pub async fn docs_in_queue(
40    user: Result<MaybeUser, StatusCode>,
41    State(db): DbState,
42) -> ApiResult<Json<DocQueueResponse>> {
43    let user = user.ok();
44    let mut visible = Vec::new();
45    for entry in db.get_doc_queue().await? {
46        if can_read_docs(&entry.normalized_name, user.as_ref(), &db).await {
47            visible.push(entry);
48        }
49    }
50    Ok(Json(DocQueueResponse::from(visible)))
51}
52
53/// Redirect to latest documentation
54///
55/// Redirects to the latest documentation for a given package.
56#[utoipa::path(
57    get,
58    path = "/{package}/latest",
59    tag = "docs",
60    params(
61        ("package" = String, Path, description = "Package name")
62    ),
63    responses(
64        (status = 302, description = "Redirect to latest documentation")
65    ),
66    security(("session_cookie" = []))
67)]
68pub async fn latest_docs(
69    Path(package): Path<OriginalName>,
70    user: Result<MaybeUser, StatusCode>,
71    State(settings): SettingsState,
72    State(docs_storage): DocsStorageState,
73    State(db): DbState,
74) -> Redirect {
75    let name = package.to_normalized();
76
77    // Same response as "no docs" to not reveal that a restricted crate exists.
78    if !can_read_docs(&name, user.ok().as_ref(), &db).await {
79        return Redirect::temporary("/");
80    }
81
82    let opt_doc_version = get_latest_version_with_doc(&name, &docs_storage).await;
83    let res_db_version = db.get_max_version_from_name(&name).await;
84
85    if let Some(doc_version) = opt_doc_version
86        && let Ok(db_version) = res_db_version
87        && doc_version == db_version
88    {
89        return Redirect::temporary(&compute_doc_url(&name, &db_version, &settings.origin.path));
90    }
91
92    Redirect::temporary("/")
93}
94
95/// Whether `user` may read the docs of a crate, honoring its download restriction.
96async fn can_read_docs(
97    crate_name: &NormalizedName,
98    user: Option<&MaybeUser>,
99    db: &Arc<dyn DbProvider>,
100) -> bool {
101    can_read_crate(crate_name, user, db)
102        .await
103        .unwrap_or_else(|e| {
104            error!("Failed to check docs access for {crate_name}: {e}");
105            false
106        })
107}
108
109/// Publish documentation for a crate version
110///
111/// Upload documentation for a specific crate and version.
112/// Requires ownership of the crate (via cargo token).
113#[utoipa::path(
114    put,
115    path = "/{package}/{version}",
116    tag = "docs",
117    params(
118        ("package" = String, Path, description = "Package name"),
119        ("version" = String, Path, description = "Package version")
120    ),
121    request_body(content = Vec<u8>, description = "Documentation archive (tar.gz or zip)", content_type = "application/octet-stream"),
122    responses(
123        (status = 200, description = "Documentation published successfully", body = DocUploadResponse),
124        (status = 400, description = "Crate or version does not exist"),
125        (status = 401, description = "Not authorized"),
126        (status = 403, description = "Not an owner of the crate")
127    ),
128    security(("cargo_token" = []))
129)]
130pub async fn publish_docs(
131    Path((package, version)): Path<(OriginalName, Version)>,
132    token: Token,
133    State(state): AppState,
134    mut docs: DocArchive,
135) -> ApiResult<Json<DocUploadResponse>> {
136    let db = state.db;
137    let settings = state.settings;
138    let docs_storage = state.docs_storage;
139    let normalized_name = package.to_normalized();
140    let crate_version = &version.to_string();
141
142    // Check if crate with the version exists.
143    if let Some(id) = db.get_crate_id(&normalized_name).await? {
144        if !db.crate_version_exists(id, crate_version).await? {
145            return crate_does_not_exist(&normalized_name, crate_version);
146        }
147    } else {
148        return crate_does_not_exist(&normalized_name, crate_version);
149    }
150
151    // Check if user from token is an owner of the crate.
152    // If not, he is not allowed to push the docs.
153    let user = MaybeUser::from_token(token);
154    check_can_modify(&user)?;
155    check_ownership(&normalized_name, &user, &db).await?;
156
157    let tmp_dir = tempfile::tempdir().map_err(DocsError::IoError)?;
158    let extract_path = tmp_dir.path().to_path_buf();
159
160    tokio::task::spawn_blocking(move || docs.extract(&extract_path))
161        .await
162        .map_err(|_| DocsError::ExtractFailed)??;
163
164    upload_dir_and_prune(tmp_dir.path(), "", &package, crate_version, &docs_storage).await?;
165
166    db.update_docs_link(
167        &normalized_name,
168        &version,
169        &compute_doc_url(&package, &version, &settings.origin.path),
170    )
171    .await?;
172
173    Ok(Json(DocUploadResponse::new(
174        "Successfully published docs.".to_string(),
175        &package,
176        &version,
177        &settings.origin.path,
178    )))
179}
180
181fn crate_does_not_exist(
182    crate_name: &str,
183    crate_version: &str,
184) -> ApiResult<Json<DocUploadResponse>> {
185    Err(DocsError::CrateDoesNotExist(crate_name.to_string(), crate_version.to_string()).into())
186}
187
188#[cfg(test)]
189mod tests {
190    use std::io::Write;
191    use std::path::PathBuf;
192    use std::sync::Arc;
193
194    use axum::Router;
195    use axum::body::Body;
196    use axum::http::{Request, StatusCode, header};
197    use axum::routing::{get, put};
198    use http_body_util::BodyExt;
199    use kellnr_appstate::AppStateData;
200    use kellnr_common::normalized_name::NormalizedName;
201    use kellnr_db::error::DbError;
202    use kellnr_db::mock::MockDb;
203    use kellnr_db::{DbProvider, DocQueueEntry, User};
204    use kellnr_storage::cached_crate_storage::DynStorage;
205    use kellnr_storage::docs_storage::DocsStorage;
206    use kellnr_storage::fs_storage::FSStorage;
207    use mockall::predicate::eq;
208    use tower::ServiceExt;
209
210    use super::*;
211    use crate::doc_queue_response::DocQueueEntryResponse;
212
213    #[tokio::test]
214    async fn doc_in_queue_returns_queue_entries() {
215        let mut db = MockDb::new();
216        db.expect_is_download_restricted().returning(|_| Ok(false));
217        db.expect_get_doc_queue().returning(|| {
218            Ok(vec![
219                DocQueueEntry {
220                    id: 0,
221                    normalized_name: NormalizedName::from_unchecked("crate1".to_string()),
222                    version: "0.0.1".to_string(),
223                    path: PathBuf::default(),
224                },
225                DocQueueEntry {
226                    id: 1,
227                    normalized_name: NormalizedName::from_unchecked("crate2".to_string()),
228                    version: "0.0.2".to_string(),
229                    path: PathBuf::default(),
230                },
231            ])
232        });
233
234        let kellnr = app(Arc::new(db));
235        let r = kellnr
236            .oneshot(Request::get("/queue").body(Body::empty()).unwrap())
237            .await
238            .unwrap();
239
240        let actual = r.into_body().collect().await.unwrap().to_bytes();
241        let actual = serde_json::from_slice::<DocQueueResponse>(&actual).unwrap();
242        assert_eq!(
243            DocQueueResponse {
244                queue: vec![
245                    DocQueueEntryResponse {
246                        name: "crate1".to_string(),
247                        version: "0.0.1".to_string()
248                    },
249                    DocQueueEntryResponse {
250                        name: "crate2".to_string(),
251                        version: "0.0.2".to_string()
252                    }
253                ]
254            },
255            actual
256        );
257    }
258
259    fn queue_entry(id: i64, name: &str) -> DocQueueEntry {
260        DocQueueEntry {
261            id,
262            normalized_name: NormalizedName::from_unchecked(name.to_string()),
263            version: "1.0.0".to_string(),
264            path: PathBuf::default(),
265        }
266    }
267
268    /// Queue with the unrestricted crate `public` and the restricted crate `secret`.
269    fn db_with_restricted_queue_entry() -> MockDb {
270        let mut db = MockDb::new();
271        db.expect_get_doc_queue()
272            .returning(|| Ok(vec![queue_entry(0, "public"), queue_entry(1, "secret")]));
273        db.expect_is_download_restricted()
274            .returning(|name| Ok(name.to_string() == "secret"));
275        db
276    }
277
278    async fn queued_names(db: MockDb, token: Option<&str>) -> Vec<String> {
279        let mut request = Request::get("/queue");
280        if let Some(token) = token {
281            request = request.header(header::AUTHORIZATION, token);
282        }
283        let r = app(Arc::new(db))
284            .oneshot(request.body(Body::empty()).unwrap())
285            .await
286            .unwrap();
287        let body = r.into_body().collect().await.unwrap().to_bytes();
288        serde_json::from_slice::<DocQueueResponse>(&body)
289            .unwrap()
290            .queue
291            .into_iter()
292            .map(|e| e.name)
293            .collect()
294    }
295
296    #[tokio::test]
297    async fn doc_queue_hides_restricted_crate_from_anonymous_user() {
298        let names = queued_names(db_with_restricted_queue_entry(), None).await;
299
300        assert_eq!(names, vec!["public"]);
301    }
302
303    #[tokio::test]
304    async fn doc_queue_hides_restricted_crate_from_user_without_access() {
305        let mut db = db_with_restricted_queue_entry();
306        expect_token_user(&mut db, "bob");
307        db.expect_is_crate_user().returning(|_, _| Ok(false));
308        db.expect_is_crate_group_user().returning(|_, _| Ok(false));
309        db.expect_is_owner().returning(|_, _| Ok(false));
310
311        let names = queued_names(db, Some("token")).await;
312
313        assert_eq!(names, vec!["public"]);
314    }
315
316    #[tokio::test]
317    async fn doc_queue_lists_restricted_crate_for_crate_user() {
318        let mut db = db_with_restricted_queue_entry();
319        expect_token_user(&mut db, "alice");
320        db.expect_is_crate_user()
321            .returning(|_, user| Ok(user == "alice"));
322
323        let names = queued_names(db, Some("token")).await;
324
325        assert_eq!(names, vec!["public", "secret"]);
326    }
327
328    fn app(db: Arc<dyn DbProvider>) -> Router {
329        Router::new()
330            .route("/queue", get(docs_in_queue))
331            .with_state(AppStateData {
332                db,
333                ..kellnr_appstate::test_state()
334            })
335    }
336
337    fn build_zip(files: &[(&str, &[u8])]) -> Vec<u8> {
338        let mut buf = Vec::new();
339        let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
340        let options = zip::write::SimpleFileOptions::default();
341        for (name, content) in files {
342            zip.start_file(*name, options).unwrap();
343            zip.write_all(content).unwrap();
344        }
345        zip.finish().unwrap();
346        buf
347    }
348
349    fn publish_app(db: Arc<dyn DbProvider>, docs_storage: Arc<DocsStorage>) -> Router {
350        Router::new()
351            .route("/{package}/{version}", put(publish_docs))
352            .with_state(AppStateData {
353                db,
354                docs_storage,
355                ..kellnr_appstate::test_state()
356            })
357    }
358
359    fn expect_admin_owner(db: &mut MockDb, normalized: &NormalizedName) {
360        db.expect_get_crate_id()
361            .with(eq(normalized.clone()))
362            .returning(|_| Ok(Some(1)));
363        db.expect_crate_version_exists()
364            .with(eq(1), eq("1.0.0"))
365            .returning(|_, _| Ok(true));
366        db.expect_get_user_from_token().returning(|_| {
367            Ok(User {
368                is_admin: true,
369                name: "admin".to_string(),
370                ..User::default()
371            })
372        });
373    }
374
375    #[tokio::test]
376    async fn publish_docs_uploads_files_and_updates_docs_link() {
377        let normalized = NormalizedName::from_unchecked("test-crate".to_string());
378        let mut db = MockDb::new();
379        expect_admin_owner(&mut db, &normalized);
380        db.expect_update_docs_link()
381            .with(
382                eq(normalized.clone()),
383                eq(Version::try_from("1.0.0").unwrap()),
384                eq("/docs/test-crate/1.0.0/doc/test_crate/index.html".to_string()),
385            )
386            .returning(|_, _, _| Ok(()));
387
388        let tmp = tempfile::tempdir().unwrap();
389        let docs_storage = Arc::new(DocsStorage::new(Box::new(
390            FSStorage::new(tmp.path().to_str().unwrap()).unwrap(),
391        ) as DynStorage));
392
393        let kellnr = publish_app(Arc::new(db), docs_storage.clone());
394        let zip_bytes = build_zip(&[("doc/test_crate/index.html", b"<html>docs</html>")]);
395
396        let r = kellnr
397            .oneshot(
398                Request::put("/test-crate/1.0.0")
399                    .header(header::AUTHORIZATION, "sometoken")
400                    .body(Body::from(zip_bytes))
401                    .unwrap(),
402            )
403            .await
404            .unwrap();
405
406        assert_eq!(r.status(), StatusCode::OK);
407
408        let key = DocsStorage::file_key("test-crate", "1.0.0", "doc/test_crate/index.html");
409        let stored = docs_storage.get(&key).await.unwrap();
410        assert_eq!(stored, bytes::Bytes::from_static(b"<html>docs</html>"));
411    }
412
413    #[tokio::test]
414    async fn publish_docs_rejects_read_only_owner() {
415        let normalized = NormalizedName::from_unchecked("test-crate".to_string());
416        let mut db = MockDb::new();
417        db.expect_get_crate_id()
418            .with(eq(normalized.clone()))
419            .returning(|_| Ok(Some(1)));
420        db.expect_crate_version_exists()
421            .with(eq(1), eq("1.0.0"))
422            .returning(|_, _| Ok(true));
423        db.expect_get_user_from_token().returning(|_| {
424            Ok(User {
425                name: "reader".to_string(),
426                is_read_only: true,
427                ..User::default()
428            })
429        });
430        db.expect_is_owner().returning(|_, _| Ok(true));
431        db.expect_update_docs_link().never();
432
433        let tmp = tempfile::tempdir().unwrap();
434        let docs_storage = Arc::new(DocsStorage::new(Box::new(
435            FSStorage::new(tmp.path().to_str().unwrap()).unwrap(),
436        ) as DynStorage));
437
438        let kellnr = publish_app(Arc::new(db), docs_storage.clone());
439        let zip_bytes = build_zip(&[("doc/test_crate/index.html", b"<html>docs</html>")]);
440
441        let r = kellnr
442            .oneshot(
443                Request::put("/test-crate/1.0.0")
444                    .header(header::AUTHORIZATION, "sometoken")
445                    .body(Body::from(zip_bytes))
446                    .unwrap(),
447            )
448            .await
449            .unwrap();
450
451        // Read-only users are rejected with 400, like on all other modifying endpoints.
452        assert_eq!(r.status(), StatusCode::BAD_REQUEST);
453        let key = DocsStorage::file_key("test-crate", "1.0.0", "doc/test_crate/index.html");
454        assert!(!docs_storage.exists(&key).await.unwrap());
455    }
456
457    #[tokio::test]
458    async fn publish_docs_propagates_extraction_failure() {
459        // A well-formed zip whose only entry escapes the extraction directory. The
460        // archive parses fine (`ZipArchive::new` succeeds) but `ZipArchive::extract`
461        // rejects the unsafe path, so the inner `Result` from `docs.extract(..)` must
462        // reach the caller as an error (previously silently discarded, returning 200).
463        let normalized = NormalizedName::from_unchecked("test-crate".to_string());
464        let mut db = MockDb::new();
465        expect_admin_owner(&mut db, &normalized);
466
467        let tmp = tempfile::tempdir().unwrap();
468        let docs_storage = Arc::new(DocsStorage::new(Box::new(
469            FSStorage::new(tmp.path().to_str().unwrap()).unwrap(),
470        ) as DynStorage));
471
472        let kellnr = publish_app(Arc::new(db), docs_storage);
473        let zip_bytes = build_zip(&[("../escape.html", b"evil")]);
474
475        let r = kellnr
476            .oneshot(
477                Request::put("/test-crate/1.0.0")
478                    .header(header::AUTHORIZATION, "sometoken")
479                    .body(Body::from(zip_bytes))
480                    .unwrap(),
481            )
482            .await
483            .unwrap();
484
485        // Previously this error was silently discarded (`let _ = ...`), returning 200
486        // even though extraction failed. It must now surface as a client error.
487        assert_eq!(r.status(), StatusCode::BAD_REQUEST);
488    }
489
490    fn latest_app(db: Arc<dyn DbProvider>, docs_storage: Arc<DocsStorage>) -> Router {
491        Router::new()
492            .route("/{package}/latest", get(latest_docs))
493            .with_state(AppStateData {
494                db,
495                docs_storage,
496                ..kellnr_appstate::test_state()
497            })
498    }
499
500    async fn docs_storage_with_latest_docs() -> (tempfile::TempDir, Arc<DocsStorage>) {
501        let tmp = tempfile::tempdir().unwrap();
502        let docs_storage = Arc::new(DocsStorage::new(Box::new(
503            FSStorage::new(tmp.path().to_str().unwrap()).unwrap(),
504        ) as DynStorage));
505        let key = DocsStorage::file_key("secret", "1.0.0", "doc/secret/index.html");
506        docs_storage
507            .put(&key, bytes::Bytes::from_static(b"<html>docs</html>"))
508            .await
509            .unwrap();
510        (tmp, docs_storage)
511    }
512
513    fn expect_restricted_crate(db: &mut MockDb, restricted: bool) {
514        db.expect_is_download_restricted()
515            .returning(move |_| Ok(restricted));
516        db.expect_get_max_version_from_name()
517            .returning(|_| Ok(Version::try_from("1.0.0").unwrap()));
518    }
519
520    fn expect_token_user(db: &mut MockDb, name: &'static str) {
521        db.expect_get_user_from_token().returning(move |_| {
522            Ok(User {
523                name: name.to_string(),
524                ..User::default()
525            })
526        });
527    }
528
529    async fn latest_location(
530        db: MockDb,
531        docs_storage: Arc<DocsStorage>,
532        token: Option<&str>,
533    ) -> String {
534        let mut request = Request::get("/secret/latest");
535        if let Some(token) = token {
536            request = request.header(header::AUTHORIZATION, token);
537        }
538        let r = latest_app(Arc::new(db), docs_storage)
539            .oneshot(request.body(Body::empty()).unwrap())
540            .await
541            .unwrap();
542        assert_eq!(r.status(), StatusCode::TEMPORARY_REDIRECT);
543        r.headers()[header::LOCATION].to_str().unwrap().to_string()
544    }
545
546    #[tokio::test]
547    async fn latest_docs_redirects_to_docs_for_unrestricted_crate() {
548        let (_tmp, docs_storage) = docs_storage_with_latest_docs().await;
549        let mut db = MockDb::new();
550        expect_restricted_crate(&mut db, false);
551
552        let location = latest_location(db, docs_storage, None).await;
553
554        assert_eq!(location, "/docs/secret/1.0.0/doc/secret/index.html");
555    }
556
557    #[tokio::test]
558    async fn latest_docs_hides_restricted_crate_from_anonymous_user() {
559        let (_tmp, docs_storage) = docs_storage_with_latest_docs().await;
560        let mut db = MockDb::new();
561        expect_restricted_crate(&mut db, true);
562
563        let location = latest_location(db, docs_storage, None).await;
564
565        assert_eq!(location, "/");
566    }
567
568    #[tokio::test]
569    async fn latest_docs_hides_restricted_crate_from_user_without_access() {
570        let (_tmp, docs_storage) = docs_storage_with_latest_docs().await;
571        let mut db = MockDb::new();
572        expect_restricted_crate(&mut db, true);
573        expect_token_user(&mut db, "bob");
574        db.expect_is_crate_user().returning(|_, _| Ok(false));
575        db.expect_is_crate_group_user().returning(|_, _| Ok(false));
576        db.expect_is_owner().returning(|_, _| Ok(false));
577
578        let location = latest_location(db, docs_storage, Some("token")).await;
579
580        assert_eq!(location, "/");
581    }
582
583    #[tokio::test]
584    async fn latest_docs_redirects_crate_user_of_restricted_crate() {
585        let (_tmp, docs_storage) = docs_storage_with_latest_docs().await;
586        let mut db = MockDb::new();
587        expect_restricted_crate(&mut db, true);
588        expect_token_user(&mut db, "alice");
589        db.expect_is_crate_user()
590            .with(
591                eq(NormalizedName::from_unchecked("secret".to_string())),
592                eq("alice"),
593            )
594            .returning(|_, _| Ok(true));
595
596        let location = latest_location(db, docs_storage, Some("token")).await;
597
598        assert_eq!(location, "/docs/secret/1.0.0/doc/secret/index.html");
599    }
600
601    #[tokio::test]
602    async fn latest_docs_redirects_owner_of_restricted_crate() {
603        let (_tmp, docs_storage) = docs_storage_with_latest_docs().await;
604        let mut db = MockDb::new();
605        expect_restricted_crate(&mut db, true);
606        expect_token_user(&mut db, "olivia");
607        db.expect_is_crate_user().returning(|_, _| Ok(false));
608        db.expect_is_crate_group_user().returning(|_, _| Ok(false));
609        db.expect_is_owner()
610            .with(
611                eq(NormalizedName::from_unchecked("secret".to_string())),
612                eq("olivia"),
613            )
614            .returning(|_, _| Ok(true));
615
616        let location = latest_location(db, docs_storage, Some("token")).await;
617
618        assert_eq!(location, "/docs/secret/1.0.0/doc/secret/index.html");
619    }
620
621    #[tokio::test]
622    async fn latest_docs_redirects_group_member_of_restricted_crate() {
623        let (_tmp, docs_storage) = docs_storage_with_latest_docs().await;
624        let mut db = MockDb::new();
625        expect_restricted_crate(&mut db, true);
626        expect_token_user(&mut db, "greg");
627        db.expect_is_crate_user().returning(|_, _| Ok(false));
628        db.expect_is_crate_group_user()
629            .with(
630                eq(NormalizedName::from_unchecked("secret".to_string())),
631                eq("greg"),
632            )
633            .returning(|_, _| Ok(true));
634
635        let location = latest_location(db, docs_storage, Some("token")).await;
636
637        assert_eq!(location, "/docs/secret/1.0.0/doc/secret/index.html");
638    }
639
640    #[tokio::test]
641    async fn latest_docs_hides_crate_when_restriction_check_fails() {
642        let (_tmp, docs_storage) = docs_storage_with_latest_docs().await;
643        let mut db = MockDb::new();
644        db.expect_is_download_restricted()
645            .returning(|_| Err(DbError::CrateNotFound("secret".to_string())));
646
647        let location = latest_location(db, docs_storage, None).await;
648
649        assert_eq!(location, "/");
650    }
651
652    #[tokio::test]
653    async fn latest_docs_hides_restricted_crate_when_access_check_fails() {
654        let (_tmp, docs_storage) = docs_storage_with_latest_docs().await;
655        let mut db = MockDb::new();
656        expect_restricted_crate(&mut db, true);
657        expect_token_user(&mut db, "alice");
658        db.expect_is_crate_user()
659            .returning(|_, _| Err(DbError::CrateNotFound("secret".to_string())));
660
661        let location = latest_location(db, docs_storage, Some("token")).await;
662
663        assert_eq!(location, "/");
664    }
665}