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}