Skip to main content

doiget_cli/commands/
source.rs

1//! `doiget source <ref>` — download an arXiv submission's **source bundle**
2//! (every file) or just its **figures** to a directory (ADR-0034, issue #343).
3//!
4//! Fetches the same arXiv source tarball as `doiget tex-source`
5//! (`export.arxiv.org/src/<id>`, one request) but, instead of extracting only
6//! the main `.tex` text, materialises files to `--out`:
7//!
8//! - default: the full bundle (`*.tex`, `*.bib`, `*.sty`, figures, …),
9//! - `--figures-only`: just the image artifacts.
10//!
11//! Files are written **opaque** (never interpreted; ADR-0034 D2). Tar entry
12//! paths are sanitised in the core (`sanitize_entry_path`, ADR-0034 D3) and
13//! the join under `--out` is re-checked here as defence-in-depth, so a
14//! malicious archive cannot write outside the output directory (zip-slip).
15//!
16//! - **arXiv id** → files under `--out`.
17//! - **DOI** → `NO_OA_AVAILABLE` (pass the arXiv id).
18//! - **PDF-only / single-file / figure-less submission** → `TEXT_UNAVAILABLE`
19//!   (wire) with an actionable `doiget fetch` note.
20//!
21//! `--mode json` emits `{ok, arxiv_id, out_dir, figures_only, count, files[]}`.
22
23use std::io::Write;
24
25use anyhow::{Context, Result};
26use camino::{Utf8Path, Utf8PathBuf};
27
28use doiget_core::paper_tex_source::{
29    paper_source_bundle, resolve_arxiv_src_base, BundleFilter, SourceFile,
30};
31use doiget_core::{ArxivId, ErrorCode, Ref};
32
33use super::fetch::{build_resolve_context, cli_exit_code, CliExit};
34use super::output::print_err;
35use super::output::OutputMode;
36
37/// Run the `source` subcommand.
38///
39/// # Errors
40///
41/// Returns a typed [`ErrorCode`] as a process exit code via [`CliExit`] for
42/// the fetch/resolve failures; filesystem write failures surface as a generic
43/// non-zero exit through the top-level reporter.
44pub async fn run(
45    ref_: String,
46    out_dir: Utf8PathBuf,
47    figures_only: bool,
48    mode: OutputMode,
49    quiet_was_explicit: bool,
50) -> Result<()> {
51    let parsed = super::parse_ref_or_exit(&ref_)?;
52    let id: ArxivId = match parsed {
53        Ref::Arxiv(a) => a,
54        Ref::Doi(_) => {
55            let code = ErrorCode::NoOaAvailable;
56            print_err(format_args!(
57                "error[{}]: no source bundle for a bare DOI — if an arXiv preprint exists, \
58                 pass its id (e.g. `doiget source arxiv:2401.12345 --out ./src`)",
59                code.as_wire()
60            ));
61            return Err(anyhow::Error::new(CliExit(cli_exit_code(code))));
62        }
63    };
64
65    let base = resolve_arxiv_src_base().map_err(|e| anyhow::anyhow!("{e}"))?;
66    let ctx = build_resolve_context().context("building fetch context")?;
67    let filter = if figures_only {
68        BundleFilter::FiguresOnly
69    } else {
70        BundleFilter::All
71    };
72
73    let files = match paper_source_bundle(&base, &id, filter, &ctx).await {
74        Ok(f) => f,
75        Err(e) => {
76            let code = ErrorCode::from(&e);
77            print_err(format_args!("error[{}]: {e}", code.as_wire()));
78            if code == ErrorCode::TextUnavailable {
79                print_err(format_args!(
80                    "  = note: no {} found (no matching files, PDF-only, or single-file \
81                     submission). Fetch the PDF instead: `doiget fetch arxiv:{}`",
82                    if figures_only {
83                        "figures"
84                    } else {
85                        "source bundle"
86                    },
87                    id.as_str()
88                ));
89            }
90            return Err(anyhow::Error::new(CliExit(cli_exit_code(code))));
91        }
92    };
93
94    let written = write_files(&out_dir, &files)?;
95
96    // The written files ARE the artifact — suppress only on *explicit* Quiet
97    // (ADR-0017 Amendment 2, same rule as `doiget tex-source`). The files are
98    // already on disk regardless; this only governs the stdout summary.
99    if mode == OutputMode::Quiet && quiet_was_explicit {
100        return Ok(());
101    }
102
103    let stdout = std::io::stdout();
104    let mut out = stdout.lock();
105    if mode == OutputMode::Json {
106        let payload = serde_json::json!({
107            "ok": true,
108            "arxiv_id": id.as_str(),
109            "out_dir": out_dir.as_str(),
110            "figures_only": figures_only,
111            "count": written.len(),
112            "files": written.iter().map(|p| p.as_str()).collect::<Vec<_>>(),
113        });
114        let s = serde_json::to_string_pretty(&payload).context("serializing source JSON")?;
115        writeln!(out, "{s}").context("writing source JSON to stdout")?;
116        return Ok(());
117    }
118
119    writeln!(out, "wrote {} file(s) to {out_dir}", written.len())
120        .context("writing source summary")?;
121    for rel in &written {
122        writeln!(out, "  {rel}").context("writing source file line")?;
123    }
124    Ok(())
125}
126
127/// Write each [`SourceFile`] under `out_dir`, returning the relative paths
128/// written (sorted for stable output).
129///
130/// `f.path` is already sanitised by the core (relative, no `..`; ADR-0034 D3),
131/// but the join is re-verified to stay within `out_dir` as defence-in-depth —
132/// a regression in the core sanitiser cannot turn into a write outside the
133/// output directory here.
134fn write_files(out_dir: &Utf8Path, files: &[SourceFile]) -> Result<Vec<Utf8PathBuf>> {
135    std::fs::create_dir_all(out_dir.as_std_path())
136        .with_context(|| format!("creating output dir {out_dir}"))?;
137
138    let mut written: Vec<Utf8PathBuf> = Vec::with_capacity(files.len());
139    for f in files {
140        let rel = f.path();
141        let dest = out_dir.join(rel);
142        // Defence-in-depth: `rel` is already relative with no `..` (a
143        // `SourceFile` can only be built via `sanitize_entry_path` in the core,
144        // ADR-0034 D3/I3), so this can never fire for a real value — but a
145        // regression in the core sanitiser must not become a write outside
146        // out_dir.
147        if !dest.starts_with(out_dir) {
148            anyhow::bail!("refusing to write outside the output dir (zip-slip guard): {rel}");
149        }
150        // Create the file's parent only when it is a real subdirectory; a flat
151        // entry's parent is out_dir, already created above.
152        if let Some(parent) = dest.parent() {
153            if parent != out_dir {
154                std::fs::create_dir_all(parent.as_std_path())
155                    .with_context(|| format!("creating {parent}"))?;
156            }
157        }
158        std::fs::write(dest.as_std_path(), &f.bytes).with_context(|| format!("writing {dest}"))?;
159        written.push(rel.to_owned());
160    }
161    written.sort();
162    Ok(written)
163}