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            // `NOT_IMPLEMENTED`, not `NO_OA_AVAILABLE`. The latter carries
56            // disposition `needs_config` -- "a named change makes it" -- and
57            // there is no knob: this command is arXiv-only and DOI-to-arXiv
58            // linking is not built (#281 item 5). Kept identical to the MCP
59            // sibling; changing one surface and not the other is the defect
60            // this release is about, and the first pass at this fix did
61            // exactly that.
62            let code = ErrorCode::NotImplemented;
63            print_err(format_args!(
64                "error[{}]: no source bundle for a bare DOI — if an arXiv preprint exists, \
65                 pass its id (e.g. `doiget source arxiv:2401.12345 --out ./src`)",
66                code.as_wire()
67            ));
68            return Err(anyhow::Error::new(CliExit(cli_exit_code(code))));
69        }
70    };
71
72    let base = resolve_arxiv_src_base().map_err(|e| anyhow::anyhow!("{e}"))?;
73    let ctx = build_resolve_context().context("building fetch context")?;
74    let filter = if figures_only {
75        BundleFilter::FiguresOnly
76    } else {
77        BundleFilter::All
78    };
79
80    let files = match paper_source_bundle(&base, &id, filter, &ctx).await {
81        Ok(f) => f,
82        Err(e) => {
83            let code = ErrorCode::from(&e);
84            print_err(format_args!("error[{}]: {e}", code.as_wire()));
85            if code == ErrorCode::TextUnavailable {
86                print_err(format_args!(
87                    "  = note: no {} found (no matching files, PDF-only, or single-file \
88                     submission). Fetch the PDF instead: `doiget fetch arxiv:{}`",
89                    if figures_only {
90                        "figures"
91                    } else {
92                        "source bundle"
93                    },
94                    id.as_str()
95                ));
96            }
97            return Err(anyhow::Error::new(CliExit(cli_exit_code(code))));
98        }
99    };
100
101    let written = write_files(&out_dir, &files)?;
102
103    // The written files ARE the artifact — suppress only on *explicit* Quiet
104    // (ADR-0017 Amendment 2, same rule as `doiget tex-source`). The files are
105    // already on disk regardless; this only governs the stdout summary.
106    if mode == OutputMode::Quiet && quiet_was_explicit {
107        return Ok(());
108    }
109
110    let stdout = std::io::stdout();
111    let mut out = stdout.lock();
112    if mode == OutputMode::Json {
113        let payload = serde_json::json!({
114            "ok": true,
115            "arxiv_id": id.as_str(),
116            "out_dir": out_dir.as_str(),
117            "figures_only": figures_only,
118            "count": written.len(),
119            "files": written.iter().map(|p| p.as_str()).collect::<Vec<_>>(),
120        });
121        let s = serde_json::to_string_pretty(&payload).context("serializing source JSON")?;
122        writeln!(out, "{s}").context("writing source JSON to stdout")?;
123        return Ok(());
124    }
125
126    writeln!(out, "wrote {} file(s) to {out_dir}", written.len())
127        .context("writing source summary")?;
128    for rel in &written {
129        writeln!(out, "  {rel}").context("writing source file line")?;
130    }
131    Ok(())
132}
133
134/// Write each [`SourceFile`] under `out_dir`, returning the relative paths
135/// written (sorted for stable output).
136///
137/// `f.path` is already sanitised by the core (relative, no `..`; ADR-0034 D3),
138/// but the join is re-verified to stay within `out_dir` as defence-in-depth —
139/// a regression in the core sanitiser cannot turn into a write outside the
140/// output directory here.
141fn write_files(out_dir: &Utf8Path, files: &[SourceFile]) -> Result<Vec<Utf8PathBuf>> {
142    std::fs::create_dir_all(out_dir.as_std_path())
143        .with_context(|| format!("creating output dir {out_dir}"))?;
144
145    let mut written: Vec<Utf8PathBuf> = Vec::with_capacity(files.len());
146    for f in files {
147        let rel = f.path();
148        let dest = out_dir.join(rel);
149        // Defence-in-depth: `rel` is already relative with no `..` (a
150        // `SourceFile` can only be built via `sanitize_entry_path` in the core,
151        // ADR-0034 D3/I3), so this can never fire for a real value — but a
152        // regression in the core sanitiser must not become a write outside
153        // out_dir.
154        if !dest.starts_with(out_dir) {
155            anyhow::bail!("refusing to write outside the output dir (zip-slip guard): {rel}");
156        }
157        // Create the file's parent only when it is a real subdirectory; a flat
158        // entry's parent is out_dir, already created above.
159        if let Some(parent) = dest.parent() {
160            if parent != out_dir {
161                std::fs::create_dir_all(parent.as_std_path())
162                    .with_context(|| format!("creating {parent}"))?;
163            }
164        }
165        std::fs::write(dest.as_std_path(), &f.bytes).with_context(|| format!("writing {dest}"))?;
166        written.push(rel.to_owned());
167    }
168    written.sort();
169    Ok(written)
170}