Skip to main content

kellnr_docgen/
doc_queue.rs

1use std::collections::HashSet;
2use std::path::Path;
3use std::sync::{Arc, Mutex};
4use std::time::Duration;
5
6use bytes::Bytes;
7use cargo::GlobalContext;
8use cargo::core::Workspace;
9use cargo::core::compiler::UserIntent;
10use cargo::core::resolver::CliFeatures;
11use cargo::ops::{self, CompileOptions, DocOptions, OutputFormat};
12use cargo::util::Filesystem;
13use flate2::read::GzDecoder;
14use kellnr_common::background_tasks::{BackgroundTasks, FirstRound};
15use kellnr_common::canonical_name::CanonicalName;
16use kellnr_common::original_name::OriginalName;
17use kellnr_common::version::Version;
18use kellnr_db::{DbProvider, DocQueueEntry};
19use kellnr_docs::docs_error::DocsError;
20use kellnr_docs::unpack::{ExtractLimits, unpack_crate};
21use kellnr_docs::upload::upload_dir_and_prune;
22use kellnr_docs::{compute_doc_url, crate_name_to_docs_name};
23use kellnr_settings::{Settings, UrlPathPrefix};
24use kellnr_storage::docs_storage::{DocsName, DocsStorage};
25use kellnr_storage::kellnr_crate_storage::KellnrCrateStorage;
26use tokio::fs::remove_dir_all;
27use tokio_util::sync::CancellationToken;
28use tracing::{debug, error, info, warn};
29
30use crate::build_process::DocBuilder;
31
32pub fn doc_extraction_queue(
33    db: Arc<dyn DbProvider>,
34    cs: Arc<KellnrCrateStorage>,
35    docs_storage: Arc<DocsStorage>,
36    settings: &Settings,
37    tasks: &BackgroundTasks,
38) {
39    let program = match std::env::current_exe() {
40        Ok(program) => program,
41        Err(e) => {
42            error!("Rustdoc generation disabled, cannot locate the kellnr binary: {e}");
43            return;
44        }
45    };
46    let builder = DocBuilder::new(
47        program,
48        settings.docs.build_wrapper.clone(),
49        settings
50            .proxy
51            .cratesio_index_override()
52            .map(ToString::to_string),
53    )
54    .with_timeout(Duration::from_secs(settings.docs.build_timeout_seconds))
55    .with_shutdown(tasks.shutdown_token());
56    let worker = Arc::new(DocQueueWorker {
57        db,
58        cs,
59        docs_storage,
60        path_prefix: settings.origin.path_prefix(),
61        builder,
62        limits: ExtractLimits::from_max_docs_size(settings.docs.max_size),
63        processed: Mutex::default(),
64        shutdown: tasks.shutdown_token(),
65    });
66
67    tasks.spawn_periodic(
68        "docs build queue",
69        Duration::from_secs(10),
70        FirstRound::AfterInterval,
71        move || {
72            let worker = worker.clone();
73            async move {
74                if let Err(e) = worker.process_queue().await {
75                    error!("Rustdoc generation loop failed: {e}");
76                }
77            }
78        },
79    );
80}
81
82/// Turns queued crates into hosted rustdoc, one crate at a time.
83struct DocQueueWorker {
84    db: Arc<dyn DbProvider>,
85    cs: Arc<KellnrCrateStorage>,
86    docs_storage: Arc<DocsStorage>,
87    path_prefix: UrlPathPrefix,
88    builder: DocBuilder,
89    limits: ExtractLimits,
90    /// Queue entries that were processed but could not be removed from the
91    /// queue. They are only removed, but not built again.
92    processed: Mutex<HashSet<i64>>,
93    /// Stops the queue after the current entry and kills a running build.
94    shutdown: CancellationToken,
95}
96
97/// Whether a queue entry is done or should be processed again later.
98#[derive(Debug, PartialEq, Eq)]
99enum Outcome {
100    Done,
101    Retry,
102}
103
104impl DocQueueWorker {
105    async fn process_queue(&self) -> Result<(), DocsError> {
106        let entries = self.db.get_doc_queue().await?;
107        let mut built = HashSet::new();
108
109        for entry in entries {
110            if self.shutdown.is_cancelled() {
111                break;
112            }
113            // A crate version queued several times is only built once.
114            let is_first = built.insert((entry.name.clone(), entry.version.clone()));
115            let outcome = if is_first && !self.is_processed(entry.id) {
116                self.process_entry(&entry).await
117            } else {
118                Outcome::Done
119            };
120
121            // Always remove the working directory, also after failed builds.
122            if let Err(e) = clean_up(&entry.path).await {
123                error!("Failed to delete temporary rustdoc queue folder: {e}");
124            }
125
126            if outcome == Outcome::Done {
127                self.remove_from_queue(entry.id).await;
128            }
129        }
130
131        Ok(())
132    }
133
134    /// Build and publish the docs of a queue entry. Only an entry whose crate
135    /// could not be read is retried, failed builds are not, as every build
136    /// runs untrusted code.
137    async fn process_entry(&self, entry: &DocQueueEntry) -> Outcome {
138        let version = &entry.version;
139        let Ok(name) = entry.canonical_name() else {
140            warn!(
141                "Skipping the docs build of {} {version}, the crate name is invalid",
142                entry.name
143            );
144            return Outcome::Done;
145        };
146        let name = &name;
147        let (original_name, contents) = match self.read_crate(name, version).await {
148            Ok(Some(krate)) => krate,
149            Ok(None) => {
150                warn!("Skipping the docs build of {name} {version}, the crate does not exist");
151                return Outcome::Done;
152            }
153            Err(e) => {
154                error!("Failed to read {name} {version} for its docs build, retrying later: {e}");
155                return Outcome::Retry;
156            }
157        };
158
159        let result = self
160            .build_docs(entry, name, &original_name, version, contents)
161            .await;
162        if matches!(result, Err(DocsError::BuildStopped)) {
163            info!(
164                "Stopped the docs build of {name} {version} on shutdown, it runs again after the restart"
165            );
166            return Outcome::Retry;
167        }
168        // A failure afterwards must not trigger another build.
169        self.processed.lock().unwrap().insert(entry.id);
170        if let Err(e) = result {
171            error!("Failed to build the docs of {name} {version}: {e}");
172            return Outcome::Done;
173        }
174
175        let docs_link = compute_doc_url(&original_name, version, &self.path_prefix);
176        if let Err(e) = self.db.update_docs_link(name, version, &docs_link).await {
177            error!("Failed to update the docs link of {name} {version}: {e}");
178        }
179        Outcome::Done
180    }
181
182    /// The original name and the crate file, or `None` if the crate version
183    /// does not exist.
184    async fn read_crate(
185        &self,
186        name: &CanonicalName,
187        version: &Version,
188    ) -> Result<Option<(OriginalName, Bytes)>, DocsError> {
189        // The crate file and the folder in it are named after the original name.
190        let Some(original_name) = self.db.get_original_name(name, version).await? else {
191            return Ok(None);
192        };
193        let contents = self.cs.get(&original_name, version).await?;
194        Ok(contents.map(|contents| (original_name, contents)))
195    }
196
197    async fn build_docs(
198        &self,
199        doc: &DocQueueEntry,
200        name: &CanonicalName,
201        original_name: &OriginalName,
202        version: &Version,
203        contents: Bytes,
204    ) -> Result<(), DocsError> {
205        let crate_root = format!("{original_name}-{version}");
206        let tar = GzDecoder::new(std::io::Cursor::new(contents));
207        let dest = doc.path.clone();
208        let root = crate_root.clone();
209        let limits = self.limits;
210        tokio::task::spawn_blocking(move || unpack_crate(tar, &dest, &root, limits))
211            .await
212            .map_err(|_| DocsError::ExtractFailed)??;
213
214        // Generate the docs
215        let generated_docs_path = &doc.path.join(crate_root);
216        strip_rust_toolchain_files(generated_docs_path).await?;
217
218        // The build runs untrusted code (build scripts, proc macros), so it runs
219        // in a separate process without the Kellnr environment. See `DocBuilder`.
220        self.builder.build(generated_docs_path).await?;
221
222        // The version may have been deleted during the build. Its docs would
223        // never be deleted then.
224        if self.db.get_original_name(name, version).await?.is_none() {
225            return Err(DocsError::CrateDoesNotExist(
226                doc.name.clone(),
227                version.to_string(),
228            ));
229        }
230
231        // Upload the docs directory, pruning any stale pages from a previous build.
232        // The `doc` key prefix mirrors the manual-upload path (api.rs), where the
233        // uploaded zip already contains a top-level `doc/` folder: both must land
234        // under `{crate}/{version}/doc/...` to match `compute_doc_url`.
235        let from = generated_docs_path.join("target").join("doc");
236        let docs_dir = from.clone();
237        let docs_name = crate_name_to_docs_name(original_name);
238        tokio::task::spawn_blocking(move || remove_non_rustdoc_files(&docs_dir, &docs_name))
239            .await
240            .map_err(|_| DocsError::ExtractFailed)??;
241        upload_dir_and_prune(
242            &from,
243            "doc",
244            &DocsName::from(original_name),
245            version,
246            &self.docs_storage,
247        )
248        .await?;
249
250        Ok(())
251    }
252
253    fn is_processed(&self, id: i64) -> bool {
254        self.processed.lock().unwrap().contains(&id)
255    }
256
257    async fn remove_from_queue(&self, id: i64) {
258        match self.db.delete_doc_queue(id).await {
259            Ok(()) => {
260                self.processed.lock().unwrap().remove(&id);
261            }
262            Err(e) => error!("Failed to remove entry {id} from the docs queue: {e}"),
263        }
264    }
265}
266
267/// Files and folders rustdoc writes to `target/doc` besides the folder of the
268/// crate and the `src` folder. Older rustdoc versions wrote some of them.
269const RUSTDOC_OUTPUT: &[&str] = &[
270    "static.files",
271    "search.index",
272    "search.desc",
273    "trait.impl",
274    "type.impl",
275    "implementors",
276    "crates.js",
277    "help.html",
278    "settings.html",
279    "src-files.js",
280    "source-files.js",
281];
282
283fn is_rustdoc_output(name: &str, docs_name: &str) -> bool {
284    name == docs_name
285        || RUSTDOC_OUTPUT.contains(&name)
286        || (name.starts_with("search-index") && name.to_ascii_lowercase().ends_with(".js"))
287}
288
289/// Remove everything from `doc_dir` that is not rustdoc output of the crate
290/// `docs_name`.
291///
292/// The build runs untrusted code, which could copy files of the host into
293/// `target/doc` to get them published. This limits what is published to the
294/// files rustdoc writes, but the build can still write into the crate's own
295/// docs. Only a confining `docs.build_wrapper` prevents that.
296fn remove_non_rustdoc_files(doc_dir: &Path, docs_name: &str) -> std::io::Result<()> {
297    retain_entries(doc_dir, |name| {
298        name == "src" || is_rustdoc_output(name, docs_name)
299    })?;
300    let src = doc_dir.join("src");
301    if std::fs::symlink_metadata(&src).is_ok_and(|m| m.is_dir()) {
302        retain_entries(&src, |name| name == docs_name)?;
303    }
304    Ok(())
305}
306
307/// Remove the entries of `dir` whose name does not satisfy `keep`, without
308/// following symlinks.
309fn retain_entries(dir: &Path, keep: impl Fn(&str) -> bool) -> std::io::Result<()> {
310    for entry in std::fs::read_dir(dir)? {
311        let entry = entry?;
312        if keep(&entry.file_name().to_string_lossy()) {
313            continue;
314        }
315        debug!(
316            "Not publishing {}, it is no rustdoc output",
317            entry.path().display()
318        );
319        if entry.file_type()?.is_dir() {
320            std::fs::remove_dir_all(entry.path())?;
321        } else {
322            std::fs::remove_file(entry.path())?;
323        }
324    }
325    Ok(())
326}
327
328async fn clean_up(path: &Path) -> Result<(), DocsError> {
329    match remove_dir_all(path).await {
330        Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e.into()),
331        _ => Ok(()),
332    }
333}
334
335/// Remove any `rust-toolchain.toml` (or legacy `rust-toolchain`) at the crate
336/// root before invoking cargo.
337///
338/// These files are a rustup feature for pinning a local development or CI
339/// toolchain. They were never intended as a contract with downstream
340/// consumers: the canonical way to declare a minimum supported Rust version
341/// is `package.rust-version` in `Cargo.toml`, which cargo's resolver honors
342/// without swapping out the compiler.
343///
344/// However, cargo does not exclude these files from `cargo publish` by
345/// default, so some crates accidentally ship them. When that happens, the
346/// rustup proxy that backs `rustc` inside the Kellnr container walks up from
347/// the working directory, finds the file, and silently switches to the
348/// pinned toolchain (downloading it if necessary). If the pin predates a
349/// stable feature the crate now relies on (e.g. `check-cfg`), `cargo doc`
350/// fails with errors that look like bugs in the crate or in Kellnr but are
351/// really an invisible toolchain swap. See issue #1176.
352///
353/// Dropping the file here only covers the crate currently being documented.
354/// Transitive dependencies that ship the same accident are unpacked by cargo
355/// into its own registry cache and need `RUSTUP_TOOLCHAIN` set in the
356/// container environment to neutralize.
357async fn strip_rust_toolchain_files(crate_path: &Path) -> Result<(), DocsError> {
358    for name in ["rust-toolchain.toml", "rust-toolchain"] {
359        let path = crate_path.join(name);
360        match tokio::fs::remove_file(&path).await {
361            Ok(()) => {}
362            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
363            Err(e) => return Err(e.into()),
364        }
365    }
366    Ok(())
367}
368
369/// Build the cargo [`GlobalContext`] used to document a crate.
370///
371/// When a custom crates.io proxy index is configured (`cratesio_index`), point
372/// cargo's `crates-io` source at it via source replacement so dependency
373/// resolution for `cargo doc` honors `proxy.index` instead of fetching from the
374/// upstream `index.crates.io`. See issue #1185.
375fn build_doc_context(cratesio_index: Option<&str>) -> Result<GlobalContext, DocsError> {
376    let mut ctx = GlobalContext::default().map_err(|e| DocsError::CargoError(e.to_string()))?;
377
378    if let Some(index) = cratesio_index {
379        let cli_config = vec![
380            "source.crates-io.replace-with=\"kellnr-proxy\"".to_string(),
381            format!("source.kellnr-proxy.registry=\"sparse+{index}\""),
382        ];
383        ctx.configure(0, false, None, false, false, false, &None, &[], &cli_config)
384            .map_err(|e| DocsError::CargoError(e.to_string()))?;
385    }
386
387    Ok(ctx)
388}
389
390/// Select the features to document the crate with.
391///
392/// Honors the feature keys of the docs.rs metadata table
393/// (`[package.metadata.docs.rs]`): `all-features`, `features` and
394/// `no-default-features`. Unlike docs.rs, `all-features` defaults to `true`,
395/// which keeps the behavior Kellnr always had for crates without the table.
396/// Crates whose features are mutually exclusive set `all-features = false`
397/// to opt out. See issue #1407.
398fn doc_features(workspace: &Workspace) -> Result<CliFeatures, DocsError> {
399    let package = workspace
400        .current()
401        .map_err(|e| DocsError::CargoError(e.to_string()))?;
402    // `[package.metadata.docs.rs]` is a dotted key, so `rs` is nested in `docs`.
403    let docs_rs = package
404        .manifest()
405        .custom_metadata()
406        .and_then(|m| m.get("docs"))
407        .and_then(|m| m.get("rs"));
408
409    let flag = |key: &str| docs_rs.and_then(|m| m.get(key)?.as_bool());
410    let all_features = flag("all-features").unwrap_or(true);
411    let no_default_features = flag("no-default-features").unwrap_or(false);
412    let features: Vec<String> = docs_rs
413        .and_then(|m| m.get("features"))
414        .and_then(|v| v.as_array())
415        .map(|a| {
416            a.iter()
417                .filter_map(|f| f.as_str().map(str::to_string))
418                .collect()
419        })
420        .unwrap_or_default();
421
422    CliFeatures::from_command_line(&features, all_features, !no_default_features)
423        .map_err(|e| DocsError::CargoError(e.to_string()))
424}
425
426/// Document the crate at `crate_path` with cargo in the current process.
427///
428/// This runs the crate's build scripts and proc macros. It is only called by
429/// the hidden `doc-build` subcommand, which [`DocBuilder`] runs as a separate
430/// process with a cleared environment.
431pub fn generate_docs(
432    crate_path: impl AsRef<Path>,
433    cratesio_index: Option<&str>,
434) -> Result<(), DocsError> {
435    let manifest_path = crate_path.as_ref().join("Cargo.toml").canonicalize()?;
436    let ctx = build_doc_context(cratesio_index)?;
437    let mut workspace =
438        Workspace::new(&manifest_path, &ctx).map_err(|e| DocsError::CargoError(e.to_string()))?;
439    // The docs are published from `crate_path/target/doc`, so ignore a target
440    // dir set by `CARGO_TARGET_DIR` or the cargo config of the environment.
441    workspace.set_target_dir(Filesystem::new(crate_path.as_ref().join("target")));
442    let compile_opts = CompileOptions {
443        cli_features: doc_features(&workspace)?,
444        ..CompileOptions::new(
445            &ctx,
446            UserIntent::Doc {
447                deps: false,
448                json: false,
449            },
450        )
451        .map_err(|e| DocsError::CargoError(e.to_string()))?
452    };
453    let options = DocOptions {
454        open_result: false,
455        compile_opts,
456        output_format: OutputFormat::Html,
457    };
458    ops::doc(&workspace, &options).map_err(|e| DocsError::CargoError(e.to_string()))?;
459    Ok(())
460}
461
462#[cfg(test)]
463mod tests {
464    use std::path::PathBuf;
465    use std::sync::atomic::{AtomicBool, Ordering};
466
467    use async_trait::async_trait;
468    use cargo::core::SourceId;
469    use cargo::sources::SourceConfigMap;
470    use kellnr_db::test_utils::{TestCrateBuilder, test_drop_table};
471    use kellnr_storage::object_storage::MemoryStorage;
472    use kellnr_storage::storage::{ObjectWithMeta, Storage};
473    use kellnr_storage::storage_error::StorageError;
474    use kellnr_storage::storage_key::StorageKey;
475    use kellnr_test_db::TestDb;
476
477    use super::*;
478
479    fn crate_tarball(name: &str, version: &str) -> Vec<u8> {
480        let mut builder = tar::Builder::new(flate2::write::GzEncoder::new(
481            Vec::new(),
482            flate2::Compression::default(),
483        ));
484        let manifest = format!("[package]\nname = \"{name}\"\nversion = \"{version}\"\n");
485        let mut header = tar::Header::new_gnu();
486        header.set_size(manifest.len() as u64);
487        header.set_mode(0o644);
488        header.set_cksum();
489        builder
490            .append_data(
491                &mut header,
492                format!("{name}-{version}/Cargo.toml"),
493                manifest.as_bytes(),
494            )
495            .unwrap();
496        builder.into_inner().unwrap().finish().unwrap()
497    }
498
499    fn version() -> Version {
500        Version::try_from("1.0.0").unwrap()
501    }
502
503    /// Builder that runs `script` with `/bin/sh`. In the script, `$0` is
504    /// `arg` and `$3` is the path of the unpacked crate.
505    fn shell_builder(script: &str, arg: &Path) -> DocBuilder {
506        DocBuilder::new(
507            PathBuf::from("unused"),
508            vec![
509                "/bin/sh".to_string(),
510                "-c".to_string(),
511                script.to_string(),
512                arg.display().to_string(),
513            ],
514            None,
515        )
516    }
517
518    /// Worker on `db` with the crates `names` in a case-sensitive crate storage.
519    async fn worker(
520        db: &TestDb,
521        data_dir: &Path,
522        names: &[&str],
523        builder: DocBuilder,
524    ) -> DocQueueWorker {
525        let mut settings = kellnr_settings::test_settings();
526        settings.registry.data_dir = data_dir.display().to_string();
527        let cs = KellnrCrateStorage::new(&settings, Box::new(MemoryStorage::new()));
528        for name in names {
529            cs.put(
530                &OriginalName::from_unchecked((*name).to_string()),
531                &version(),
532                crate_tarball(name, "1.0.0").into(),
533            )
534            .await
535            .unwrap();
536        }
537        DocQueueWorker {
538            db: db.provider(),
539            cs: Arc::new(cs),
540            docs_storage: Arc::new(DocsStorage::new(Box::new(MemoryStorage::new()))),
541            path_prefix: UrlPathPrefix::default(),
542            builder,
543            limits: ExtractLimits::from_max_docs_size(1),
544            processed: Mutex::default(),
545            shutdown: CancellationToken::new(),
546        }
547    }
548
549    /// Worker whose builds fail after appending a line to `build_log`.
550    async fn failing_worker(db: &TestDb, data_dir: &Path, build_log: &Path) -> DocQueueWorker {
551        let builder = shell_builder("echo \"$3\" >> \"$0\"; exit 1", build_log);
552        worker(db, data_dir, &["a", "b"], builder).await
553    }
554
555    /// Adds version 1.0.0 of the crates `names`, owned by `admin`.
556    async fn add_crates(db: &TestDb, names: &[&str]) {
557        for name in names {
558            TestCrateBuilder::new(db).name(name).build().await.unwrap();
559        }
560    }
561
562    /// Queues the docs builds of version 1.0.0 of `names`, each with its own
563    /// working directory in `data_dir`. Returns the working directories.
564    async fn queue(db: &TestDb, data_dir: &Path, names: &[&str]) -> Vec<PathBuf> {
565        let mut paths = Vec::new();
566        for (i, name) in names.iter().enumerate() {
567            let path = data_dir.join(format!("queue{i}"));
568            std::fs::create_dir(&path).unwrap();
569            db.add_doc_queue(
570                &OriginalName::from_unchecked((*name).to_string()),
571                &version(),
572                &path,
573            )
574            .await
575            .unwrap();
576            paths.push(path);
577        }
578        paths
579    }
580
581    async fn queued(db: &TestDb) -> Vec<String> {
582        db.get_doc_queue()
583            .await
584            .unwrap()
585            .into_iter()
586            .map(|entry| entry.name)
587            .collect()
588    }
589
590    async fn docs_link(db: &TestDb, name: &str) -> Option<String> {
591        let name = CanonicalName::try_from(name).unwrap();
592        let data = db.get_crate_data(&name).await.unwrap();
593        data.versions[0].documentation.clone()
594    }
595
596    #[cfg(unix)]
597    #[tokio::test]
598    async fn process_queue_builds_duplicates_once_and_always_cleans_up() {
599        let data_dir = tempfile::tempdir().unwrap();
600        let build_log = data_dir.path().join("builds.log");
601        let db = TestDb::sqlite().await;
602        add_crates(&db, &["a", "b"]).await;
603        let paths = queue(&db, data_dir.path(), &["a", "a", "b"]).await;
604        let worker = failing_worker(&db, data_dir.path(), &build_log).await;
605
606        worker.process_queue().await.unwrap();
607
608        let builds = std::fs::read_to_string(&build_log).unwrap();
609        assert_eq!(builds.lines().count(), 2, "builds: {builds}");
610        assert!(paths.iter().all(|p| !p.exists()));
611        assert!(queued(&db).await.is_empty());
612        assert_eq!(docs_link(&db, "a").await, None);
613    }
614
615    #[cfg(unix)]
616    #[tokio::test]
617    async fn shutdown_stops_the_queue_and_keeps_the_stopped_build_queued() {
618        let data_dir = tempfile::tempdir().unwrap();
619        let build_log = data_dir.path().join("builds.log");
620        let db = TestDb::sqlite().await;
621        add_crates(&db, &["a", "b"]).await;
622        let paths = queue(&db, data_dir.path(), &["a", "b"]).await;
623        let shutdown = CancellationToken::new();
624        let builder = shell_builder("echo \"$3\" >> \"$0\"; sleep 30", &build_log)
625            .with_shutdown(shutdown.clone());
626        let mut worker = worker(&db, data_dir.path(), &["a", "b"], builder).await;
627        worker.shutdown = shutdown.clone();
628
629        let run = tokio::spawn(async move { worker.process_queue().await });
630        while !build_log.exists() {
631            tokio::time::sleep(Duration::from_millis(50)).await;
632        }
633        shutdown.cancel();
634        run.await.unwrap().unwrap();
635
636        let builds = std::fs::read_to_string(&build_log).unwrap();
637        assert_eq!(builds.lines().count(), 1, "builds: {builds}");
638        assert!(!paths[0].exists());
639        assert_eq!(queued(&db).await, vec!["a", "b"]);
640    }
641
642    #[cfg(unix)]
643    #[tokio::test]
644    async fn process_queue_builds_crate_with_uppercase_letters() {
645        let data_dir = tempfile::tempdir().unwrap();
646        let db = TestDb::sqlite().await;
647        add_crates(&db, &["My-Crate"]).await;
648        queue(&db, data_dir.path(), &["My-Crate"]).await;
649        let builder = shell_builder(
650            "test -f \"$3/Cargo.toml\" && mkdir -p \"$3/target/doc/My_Crate\" && echo docs > \"$3/target/doc/My_Crate/index.html\"",
651            data_dir.path(),
652        );
653        let worker = worker(&db, data_dir.path(), &["My-Crate"], builder).await;
654
655        worker.process_queue().await.unwrap();
656
657        let key = DocsStorage::file_key(
658            &DocsName::from(&OriginalName::try_from("my-crate").unwrap()),
659            &version(),
660            "doc/My_Crate/index.html",
661        );
662        assert!(worker.docs_storage.exists(&key).await.unwrap());
663        assert_eq!(
664            docs_link(&db, "my_crate").await.as_deref(),
665            Some("/docs/my-crate/1.0.0/doc/My_Crate/index.html")
666        );
667        assert!(queued(&db).await.is_empty());
668    }
669
670    const SUCCESSFUL_BUILD: &str = "echo \"$3\" >> \"$0\"; mkdir -p \"$3/target/doc/a\" && echo docs > \"$3/target/doc/a/index.html\"";
671
672    /// Like [`SUCCESSFUL_BUILD`], but waits for [`process_queue_with`] after
673    /// writing to the build log.
674    const WAITING_BUILD: &str = "echo \"$3\" >> \"$0\"; while [ ! -e \"$0.go\" ]; do sleep 0.01; done; mkdir -p \"$3/target/doc/a\" && echo docs > \"$3/target/doc/a/index.html\"";
675
676    /// Processes the queue with a worker that runs [`WAITING_BUILD`] with
677    /// `build_log`, and runs `during_build` while the first build waits.
678    async fn process_queue_with(
679        worker: &DocQueueWorker,
680        build_log: &Path,
681        during_build: impl Future<Output = ()>,
682    ) {
683        let (result, ()) = tokio::join!(worker.process_queue(), async {
684            while !build_log.exists() {
685                tokio::time::sleep(Duration::from_millis(10)).await;
686            }
687            during_build.await;
688            std::fs::write(build_log.with_extension("log.go"), "").unwrap();
689        });
690        result.unwrap();
691    }
692
693    fn build_count(build_log: &Path) -> usize {
694        std::fs::read_to_string(build_log).map_or(0, |log| log.lines().count())
695    }
696
697    #[cfg(unix)]
698    #[tokio::test]
699    async fn process_queue_continues_after_database_errors() {
700        let data_dir = tempfile::tempdir().unwrap();
701        let build_log = data_dir.path().join("builds.log");
702        let db = TestDb::sqlite().await;
703        add_crates(&db, &["a", "b"]).await;
704        queue(&db, data_dir.path(), &["a", "b"]).await;
705        let builder = shell_builder(WAITING_BUILD, &build_log);
706        let worker = worker(&db, data_dir.path(), &["a", "b"], builder).await;
707
708        // Removing the entries from the queue fails.
709        process_queue_with(&worker, &build_log, async {
710            test_drop_table(&db, "doc_queue").await.unwrap();
711        })
712        .await;
713
714        assert_eq!(build_count(&build_log), 2);
715    }
716
717    #[cfg(unix)]
718    #[tokio::test]
719    async fn process_queue_does_not_build_again_if_entry_cannot_be_removed() {
720        let data_dir = tempfile::tempdir().unwrap();
721        let build_log = data_dir.path().join("builds.log");
722        let db = TestDb::sqlite().await;
723        add_crates(&db, &["a"]).await;
724        queue(&db, data_dir.path(), &["a"]).await;
725        let id = db.get_doc_queue().await.unwrap()[0].id;
726        let builder = shell_builder(WAITING_BUILD, &build_log);
727        let mut worker = worker(&db, data_dir.path(), &["a"], builder).await;
728        process_queue_with(&worker, &build_log, async {
729            test_drop_table(&db, "doc_queue").await.unwrap();
730        })
731        .await;
732
733        // The database is back, with the entry still queued.
734        let restored = TestDb::sqlite().await;
735        add_crates(&restored, &["a"]).await;
736        queue(&restored, data_dir.path(), &["a"]).await;
737        assert_eq!(restored.get_doc_queue().await.unwrap()[0].id, id);
738        worker.db = restored.provider();
739        worker.process_queue().await.unwrap();
740
741        assert_eq!(build_count(&build_log), 1);
742        assert!(queued(&restored).await.is_empty());
743    }
744
745    #[cfg(unix)]
746    #[tokio::test]
747    async fn process_queue_retries_entry_if_crate_cannot_be_read() {
748        let data_dir = tempfile::tempdir().unwrap();
749        let build_log = data_dir.path().join("builds.log");
750        let db = TestDb::sqlite().await;
751        add_crates(&db, &["a"]).await;
752        queue(&db, data_dir.path(), &["a"]).await;
753        test_drop_table(&db, "crate_meta").await.unwrap();
754        let builder = shell_builder(SUCCESSFUL_BUILD, &build_log);
755        let worker = worker(&db, data_dir.path(), &["a"], builder).await;
756
757        worker.process_queue().await.unwrap();
758
759        assert_eq!(build_count(&build_log), 0);
760        assert_eq!(queued(&db).await, vec!["a"]);
761    }
762
763    #[cfg(unix)]
764    #[tokio::test]
765    async fn process_queue_does_not_upload_docs_of_version_deleted_during_build() {
766        let data_dir = tempfile::tempdir().unwrap();
767        let build_log = data_dir.path().join("builds.log");
768        let db = TestDb::sqlite().await;
769        add_crates(&db, &["a"]).await;
770        queue(&db, data_dir.path(), &["a"]).await;
771        let builder = shell_builder(WAITING_BUILD, &build_log);
772        let worker = worker(&db, data_dir.path(), &["a"], builder).await;
773
774        process_queue_with(&worker, &build_log, async {
775            let name = CanonicalName::try_from("a").unwrap();
776            db.delete_crate(&name, &version()).await.unwrap();
777        })
778        .await;
779
780        assert_eq!(build_count(&build_log), 1);
781        let prefix = DocsStorage::version_prefix(
782            &DocsName::from(&OriginalName::try_from("a").unwrap()),
783            &version(),
784        );
785        assert!(worker.docs_storage.list(&prefix).await.unwrap().is_empty());
786        assert!(queued(&db).await.is_empty());
787    }
788
789    /// Docs storage that drops `table` when the first file is stored, so the
790    /// database fails after the docs are built.
791    struct DropTableOnUpload {
792        inner: MemoryStorage,
793        db: Arc<TestDb>,
794        table: &'static str,
795        dropped: AtomicBool,
796    }
797
798    #[async_trait]
799    impl Storage for DropTableOnUpload {
800        async fn get(&self, key: &StorageKey) -> Result<Bytes, StorageError> {
801            self.inner.get(key).await
802        }
803
804        async fn get_with_meta(&self, key: &StorageKey) -> Result<ObjectWithMeta, StorageError> {
805            self.inner.get_with_meta(key).await
806        }
807
808        async fn put(&self, key: &StorageKey, object: Bytes) -> Result<(), StorageError> {
809            self.inner.put(key, object).await
810        }
811
812        async fn put_overwrite(&self, key: &StorageKey, object: Bytes) -> Result<(), StorageError> {
813            if !self.dropped.swap(true, Ordering::SeqCst) {
814                test_drop_table(&self.db, self.table).await.unwrap();
815            }
816            self.inner.put_overwrite(key, object).await
817        }
818
819        async fn delete(&self, key: &StorageKey) -> Result<(), StorageError> {
820            self.inner.delete(key).await
821        }
822
823        async fn exists(&self, key: &StorageKey) -> Result<bool, StorageError> {
824            self.inner.exists(key).await
825        }
826
827        async fn size(&self, key: &StorageKey) -> Result<u64, StorageError> {
828            self.inner.size(key).await
829        }
830
831        async fn list(&self, prefix: &str) -> Result<Vec<StorageKey>, StorageError> {
832            self.inner.list(prefix).await
833        }
834    }
835
836    #[cfg(unix)]
837    #[tokio::test]
838    async fn process_queue_publishes_docs_once_if_docs_link_cannot_be_updated() {
839        let data_dir = tempfile::tempdir().unwrap();
840        let build_log = data_dir.path().join("builds.log");
841        let db = Arc::new(TestDb::sqlite().await);
842        add_crates(&db, &["a"]).await;
843        queue(&db, data_dir.path(), &["a"]).await;
844        let builder = shell_builder(SUCCESSFUL_BUILD, &build_log);
845        let mut worker = worker(&db, data_dir.path(), &["a"], builder).await;
846        // Updating the docs link fails, as the crate versions are gone.
847        worker.docs_storage = Arc::new(DocsStorage::new(Box::new(DropTableOnUpload {
848            inner: MemoryStorage::new(),
849            db: db.clone(),
850            table: "crate_meta",
851            dropped: AtomicBool::new(false),
852        })));
853
854        worker.process_queue().await.unwrap();
855        worker.process_queue().await.unwrap();
856
857        assert_eq!(build_count(&build_log), 1);
858        let key = DocsStorage::file_key(
859            &DocsName::from(&OriginalName::try_from("a").unwrap()),
860            &version(),
861            "doc/a/index.html",
862        );
863        assert!(worker.docs_storage.exists(&key).await.unwrap());
864        assert!(queued(&db).await.is_empty());
865    }
866
867    #[cfg(unix)]
868    #[tokio::test]
869    async fn process_queue_publishes_only_rustdoc_output() {
870        let data_dir = tempfile::tempdir().unwrap();
871        let db = TestDb::sqlite().await;
872        add_crates(&db, &["a"]).await;
873        queue(&db, data_dir.path(), &["a"]).await;
874        let builder = shell_builder(
875            "mkdir -p \"$3/target/doc\" && cd \"$3/target/doc\" && mkdir -p a src/a src/other && touch a/index.html src/a/lib.rs.html src/other/x.html crates.js leak.txt",
876            data_dir.path(),
877        );
878        let worker = worker(&db, data_dir.path(), &["a"], builder).await;
879
880        worker.process_queue().await.unwrap();
881
882        let prefix = DocsStorage::version_prefix(
883            &DocsName::from(&OriginalName::try_from("a").unwrap()),
884            &version(),
885        );
886        let mut keys: Vec<String> = worker
887            .docs_storage
888            .list(&prefix)
889            .await
890            .unwrap()
891            .into_iter()
892            .map(StorageKey::into_inner)
893            .collect();
894        keys.sort_unstable();
895        assert_eq!(
896            keys,
897            vec![
898                "a/1.0.0/doc/a/index.html",
899                "a/1.0.0/doc/crates.js",
900                "a/1.0.0/doc/src/a/lib.rs.html",
901            ]
902        );
903    }
904
905    #[test]
906    fn remove_non_rustdoc_files_keeps_rustdoc_output_of_the_crate() {
907        let dir = tempfile::tempdir().unwrap();
908        let kept = [
909            "My_Crate/index.html",
910            "static.files/rustdoc.css",
911            "search.index/root.js",
912            "trait.impl/core/clone/trait.Clone.js",
913            "type.impl/x.js",
914            "implementors/x.js",
915            "src/My_Crate/lib.rs.html",
916            "crates.js",
917            "help.html",
918            "settings.html",
919            "src-files.js",
920            "search-index1.70.0.js",
921        ];
922        let removed = [
923            "src/other/secret.html",
924            "debug/secret",
925            ".lock",
926            "secret.txt",
927        ];
928        for file in kept.iter().chain(&removed) {
929            let path = dir.path().join(file);
930            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
931            std::fs::write(path, "").unwrap();
932        }
933
934        remove_non_rustdoc_files(dir.path(), "My_Crate").unwrap();
935
936        for file in kept {
937            assert!(dir.path().join(file).exists(), "{file}");
938        }
939        for file in removed {
940            assert!(!dir.path().join(file).exists(), "{file}");
941        }
942    }
943
944    #[tokio::test]
945    async fn clean_up_ignores_missing_directory() {
946        let dir = tempfile::tempdir().unwrap();
947
948        clean_up(&dir.path().join("missing")).await.unwrap();
949    }
950
951    #[test]
952    fn no_index_override_does_not_inject_proxy_source() {
953        let ctx = build_doc_context(None).unwrap();
954        // Without an override we never define the kellnr-proxy source, so cargo
955        // keeps whatever crates.io source the ambient environment provides
956        // (the upstream index.crates.io in the kellnr container). Asserting on
957        // `crates-io.replace-with` directly would be environment-dependent,
958        // since a developer's ~/.cargo/config.toml may itself replace it.
959        assert!(
960            ctx.get_string("source.kellnr-proxy.registry")
961                .unwrap()
962                .is_none()
963        );
964    }
965
966    #[test]
967    fn index_override_replaces_crates_io_source_with_proxy() {
968        let ctx = build_doc_context(Some("https://rsproxy.cn/index/")).unwrap();
969
970        // The CLI config overrides are well-formed and applied by cargo.
971        assert_eq!(
972            ctx.get_string("source.crates-io.replace-with")
973                .unwrap()
974                .map(|v| v.val),
975            Some("kellnr-proxy".to_string())
976        );
977
978        // Cargo's own source resolution maps the crates.io source to the
979        // configured proxy index instead of index.crates.io. See issue #1185.
980        let map = SourceConfigMap::new(&ctx).unwrap();
981        let crates_io = SourceId::crates_io(&ctx).unwrap();
982        let source = map.load(crates_io, &HashSet::new()).unwrap();
983        let replaced = source.replaced_source_id();
984        assert!(!replaced.is_crates_io());
985        assert!(
986            replaced.url().as_str().contains("rsproxy.cn/index/"),
987            "expected replacement source to point at the proxy index, got {}",
988            replaced.url()
989        );
990    }
991
992    fn features_for(manifest: &str) -> CliFeatures {
993        let dir = tempfile::tempdir().unwrap();
994        std::fs::write(dir.path().join("Cargo.toml"), manifest).unwrap();
995        std::fs::create_dir(dir.path().join("src")).unwrap();
996        std::fs::write(dir.path().join("src").join("lib.rs"), "").unwrap();
997        let ctx = build_doc_context(None).unwrap();
998        let manifest_path = dir.path().join("Cargo.toml").canonicalize().unwrap();
999        let workspace = Workspace::new(&manifest_path, &ctx).unwrap();
1000        doc_features(&workspace).unwrap()
1001    }
1002
1003    const PACKAGE: &str = "[package]\nname = \"x\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[features]\na = []\nb = []\n";
1004
1005    fn feature_names(features: &CliFeatures) -> Vec<String> {
1006        let mut names: Vec<String> = features.features.iter().map(ToString::to_string).collect();
1007        names.sort();
1008        names
1009    }
1010
1011    #[test]
1012    fn doc_features_default_to_all_features_without_metadata() {
1013        let features = features_for(PACKAGE);
1014
1015        assert!(features.all_features);
1016        assert!(features.uses_default_features);
1017        assert!(features.features.is_empty());
1018    }
1019
1020    #[test]
1021    fn doc_features_honor_docs_rs_metadata() {
1022        let features = features_for(&format!(
1023            "{PACKAGE}\n[package.metadata.docs.rs]\nall-features = false\nno-default-features = true\nfeatures = [\"a\", \"b\"]\n"
1024        ));
1025
1026        assert!(!features.all_features);
1027        assert!(!features.uses_default_features);
1028        assert_eq!(feature_names(&features), vec!["a", "b"]);
1029    }
1030
1031    #[test]
1032    fn doc_features_keep_all_features_when_only_features_are_listed() {
1033        let features = features_for(&format!(
1034            "{PACKAGE}\n[package.metadata.docs.rs]\nfeatures = [\"a\"]\n"
1035        ));
1036
1037        assert!(features.all_features);
1038        assert!(features.uses_default_features);
1039        assert_eq!(feature_names(&features), vec!["a"]);
1040    }
1041
1042    #[test]
1043    fn doc_features_ignore_unrelated_metadata() {
1044        let features = features_for(&format!(
1045            "{PACKAGE}\n[package.metadata.other]\nall-features = false\n"
1046        ));
1047
1048        assert!(features.all_features);
1049    }
1050
1051    #[test]
1052    fn generate_docs_builds_crate_with_mutually_exclusive_features() {
1053        let dir = tempfile::tempdir().unwrap();
1054        std::fs::write(
1055            dir.path().join("Cargo.toml"),
1056            format!("{PACKAGE}\n[package.metadata.docs.rs]\nall-features = false\n"),
1057        )
1058        .unwrap();
1059        std::fs::create_dir(dir.path().join("src")).unwrap();
1060        std::fs::write(
1061            dir.path().join("src").join("lib.rs"),
1062            "#[cfg(all(feature = \"a\", feature = \"b\"))]\ncompile_error!(\"a and b are exclusive\");\n",
1063        )
1064        .unwrap();
1065
1066        generate_docs(dir.path(), None).unwrap();
1067
1068        assert!(dir.path().join("target/doc/x/index.html").exists());
1069    }
1070
1071    #[tokio::test]
1072    async fn strip_rust_toolchain_files_removes_both_variants() {
1073        let dir = tempfile::tempdir().unwrap();
1074        let toml_path = dir.path().join("rust-toolchain.toml");
1075        let legacy_path = dir.path().join("rust-toolchain");
1076        tokio::fs::write(&toml_path, "[toolchain]\nchannel = \"1.65.0\"\n")
1077            .await
1078            .unwrap();
1079        tokio::fs::write(&legacy_path, "1.65.0\n").await.unwrap();
1080
1081        strip_rust_toolchain_files(dir.path()).await.unwrap();
1082
1083        assert!(!toml_path.exists());
1084        assert!(!legacy_path.exists());
1085    }
1086
1087    #[tokio::test]
1088    async fn strip_rust_toolchain_files_noop_when_absent() {
1089        let dir = tempfile::tempdir().unwrap();
1090        let unrelated = dir.path().join("Cargo.toml");
1091        tokio::fs::write(&unrelated, "[package]\nname = \"x\"\n")
1092            .await
1093            .unwrap();
1094
1095        strip_rust_toolchain_files(dir.path()).await.unwrap();
1096
1097        assert!(unrelated.exists());
1098    }
1099
1100    #[tokio::test]
1101    async fn strip_rust_toolchain_files_only_removes_named_files() {
1102        let dir = tempfile::tempdir().unwrap();
1103        let toml_path = dir.path().join("rust-toolchain.toml");
1104        let cargo_toml = dir.path().join("Cargo.toml");
1105        let src = dir.path().join("src");
1106        tokio::fs::write(&toml_path, "").await.unwrap();
1107        tokio::fs::write(&cargo_toml, "").await.unwrap();
1108        tokio::fs::create_dir(&src).await.unwrap();
1109
1110        strip_rust_toolchain_files(dir.path()).await.unwrap();
1111
1112        assert!(!toml_path.exists());
1113        assert!(cargo_toml.exists());
1114        assert!(src.exists());
1115    }
1116}