Skip to main content

ytcli/cli/
attachment.rs

1//! Attachment commands.
2//!
3//! Downloads write files that came from other people. The destination is always
4//! explicit and never inferred from a server-supplied filename, so a crafted
5//! attachment name cannot decide where bytes land.
6
7use std::path::{Path, PathBuf};
8
9use clap::Subcommand;
10
11use crate::cli::write::{Gate, Intent, check};
12use crate::cli::{Session, emit, report};
13use crate::exit::ExitCode;
14use crate::render::{Format, entity as render, image, machine};
15
16#[derive(Debug, Subcommand)]
17pub enum AttachmentCommand {
18    /// List the attachments of an issue.
19    #[command(long_about = crate::cli::help::md(crate::cli::help::ATTACHMENT_LIST))]
20    List { key: String },
21    /// Download one attachment.
22    #[command(long_about = crate::cli::help::md(crate::cli::help::ATTACHMENT_DOWNLOAD))]
23    Download {
24        key: String,
25        attachment: String,
26        /// Directory to write into.
27        #[arg(long, short = 'o')]
28        out: PathBuf,
29        /// Overwrite a file that is already there.
30        #[arg(long)]
31        force: bool,
32    },
33    /// Draw an image attachment in the terminal.
34    #[command(long_about = crate::cli::help::md(crate::cli::help::ATTACHMENT_SHOW))]
35    Show { key: String, attachment: String },
36    /// Upload a file to an issue.
37    #[command(long_about = crate::cli::help::md(crate::cli::help::ATTACHMENT_UPLOAD))]
38    Upload { key: String, file: PathBuf },
39    /// Remove an attachment from an issue.
40    #[command(long_about = crate::cli::help::md(crate::cli::help::ATTACHMENT_DELETE))]
41    Delete {
42        key: String,
43        /// Attachment id, or its filename. `attachment list` prints both.
44        attachment: String,
45    },
46}
47
48pub async fn run(command: &AttachmentCommand, session: &Session) -> ExitCode {
49    match command {
50        AttachmentCommand::List { key } => list(key, session).await,
51        AttachmentCommand::Download {
52            key,
53            attachment,
54            out,
55            force,
56        } => download(key, attachment, out, *force, session).await,
57        AttachmentCommand::Show { key, attachment } => show(key, attachment, session).await,
58        AttachmentCommand::Upload { key, file } => upload(key, file, session).await,
59        AttachmentCommand::Delete { key, attachment } => delete(key, attachment, session).await,
60    }
61}
62
63async fn list(target: &str, session: &Session) -> ExitCode {
64    let (client, key) = match session.client_for(target).await {
65        Ok(pair) => pair,
66        Err(code) => return code,
67    };
68    let key = key.as_str();
69
70    match client.attachments(key).await {
71        Ok(attachments) => {
72            let rendered = match session.render.format {
73                Format::Text => Ok(render::attachments(key, &attachments, &session.render)),
74                Format::JsonRaw => machine(&attachments, Format::Json),
75                other => machine(&attachments, other),
76            };
77            match rendered {
78                Ok(text) => {
79                    emit(&text);
80                    ExitCode::Success
81                }
82                Err(error) => report(&error, ExitCode::Failure),
83            }
84        }
85        Err(error) => {
86            let code = error.exit_code();
87            report(&error, code)
88        }
89    }
90}
91
92/// Strip everything that could steer a path out of the destination directory.
93///
94/// The filename comes from whoever uploaded the file. It decides only the *name*
95/// inside a directory the caller named explicitly, never the directory itself,
96/// and a name that survives this as empty is replaced rather than trusted.
97fn safe_filename(raw: &str, fallback: &str) -> String {
98    let base = raw
99        .rsplit(['/', '\\'])
100        .next()
101        .unwrap_or(raw)
102        .trim()
103        .trim_matches('.');
104
105    let cleaned: String = base
106        .chars()
107        .filter(|c| !c.is_control() && !matches!(c, ':' | '*' | '?' | '"' | '<' | '>' | '|'))
108        .collect();
109
110    if cleaned.is_empty() {
111        fallback.to_owned()
112    } else {
113        cleaned
114    }
115}
116
117async fn download(
118    target: &str,
119    attachment: &str,
120    out: &Path,
121    force: bool,
122    session: &Session,
123) -> ExitCode {
124    let (client, key) = match session.client_for(target).await {
125        Ok(pair) => pair,
126        Err(code) => return code,
127    };
128    let key = key.as_str();
129
130    let attachments = match client.attachments(key).await {
131        Ok(attachments) => attachments,
132        Err(error) => {
133            let code = error.exit_code();
134            return report(&error, code);
135        }
136    };
137
138    let Some(found) = attachments
139        .iter()
140        .find(|candidate| candidate.id == attachment || candidate.name == attachment)
141    else {
142        return report(
143            &format!("issue {key} has no attachment `{attachment}`"),
144            ExitCode::NotFound,
145        );
146    };
147
148    let Some(url) = found.content.as_deref() else {
149        return report(
150            &format!("attachment `{attachment}` has no download URL"),
151            ExitCode::ApiRejected,
152        );
153    };
154
155    let destination = out.join(safe_filename(&found.name, &found.id));
156    if destination.exists() && !force {
157        return report(
158            &format!(
159                "{} already exists; pass --force to overwrite",
160                destination.display()
161            ),
162            ExitCode::ConfirmationRequired,
163        );
164    }
165
166    let bytes = match client.download(url).await {
167        Ok(bytes) => bytes,
168        Err(error) => {
169            let code = error.exit_code();
170            return report(&error, code);
171        }
172    };
173
174    if let Err(error) = std::fs::create_dir_all(out) {
175        return report(&error, ExitCode::Failure);
176    }
177    if let Err(error) = std::fs::write(&destination, &bytes) {
178        return report(&error, ExitCode::Failure);
179    }
180
181    emit(&format!("{}\n", destination.display()));
182    ExitCode::Success
183}
184
185/// Draw an image, or say exactly what to run instead.
186///
187/// Every path out of here that cannot draw prints the same thing: what the file
188/// is, and the `download` command that puts it somewhere openable. Silence would
189/// leave a caller — an agent especially — with nothing to act on, and a
190/// screenful of escape codes would be worse than either.
191async fn show(target: &str, attachment: &str, session: &Session) -> ExitCode {
192    let (client, key) = match session.client_for(target).await {
193        Ok(pair) => pair,
194        Err(code) => return code,
195    };
196    let key = key.as_str();
197
198    let found = match find_attachment(&client, key, attachment).await {
199        Ok(found) => found,
200        Err(code) => return code,
201    };
202
203    // Machine formats never emit pixels. A caller that asked for JSON asked for
204    // a description of the attachment, and binary in the middle of a document
205    // is not a description.
206    if session.render.format != Format::Text {
207        return match machine(&found, session.render.format) {
208            Ok(text) => {
209                emit(&text);
210                ExitCode::Success
211            }
212            Err(error) => report(&error, ExitCode::Failure),
213        };
214    }
215
216    let hint = format!("  ytcli attachment download {key} {} -o .", found.id);
217    let what = describe(&found);
218
219    let Some(protocol) = image::protocol() else {
220        emit(&format!(
221            "{what} — this terminal cannot draw images:\n{hint}\n"
222        ));
223        return ExitCode::Success;
224    };
225
226    let Some(url) = found.content.as_deref() else {
227        return report(
228            &format!("attachment `{attachment}` has no download URL"),
229            ExitCode::ApiRejected,
230        );
231    };
232
233    let bytes = match client.download(url).await {
234        Ok(bytes) => bytes,
235        Err(error) => {
236            let code = error.exit_code();
237            return report(&error, code);
238        }
239    };
240
241    let Some(kind) = image::Kind::of(&bytes) else {
242        emit(&format!("{what} is not an image:\n{hint}\n"));
243        return ExitCode::Success;
244    };
245
246    if !protocol.carries(kind) {
247        emit(&format!(
248            "{what} is {}, which this terminal cannot draw inline:\n{hint}\n",
249            kind.name()
250        ));
251        return ExitCode::Success;
252    }
253
254    emit(&image::draw(
255        protocol,
256        &bytes,
257        &found.name,
258        session.render.width,
259    ));
260    // Under the picture, not over it: the caption belongs to what precedes it,
261    // and a name printed first is a name read before there is anything to
262    // attach it to.
263    emit(&format!("{what}\n"));
264    ExitCode::Success
265}
266
267/// The name and size, for the lines that stand in for the picture.
268fn describe(found: &crate::api::models::Attachment) -> String {
269    match found.size {
270        Some(size) => format!("{} ({})", found.name, render::human_size(size)),
271        None => found.name.clone(),
272    }
273}
274
275/// The attachment named by id or by filename, or the error a caller can act on.
276async fn find_attachment(
277    client: &crate::api::Client,
278    key: &str,
279    attachment: &str,
280) -> Result<crate::api::models::Attachment, ExitCode> {
281    let attachments = match client.attachments(key).await {
282        Ok(attachments) => attachments,
283        Err(error) => {
284            let code = error.exit_code();
285            return Err(report(&error, code));
286        }
287    };
288
289    attachments
290        .into_iter()
291        .find(|candidate| candidate.id == attachment || candidate.name == attachment)
292        .ok_or_else(|| {
293            report(
294                &format!("issue {key} has no attachment `{attachment}`"),
295                ExitCode::NotFound,
296            )
297        })
298}
299
300async fn upload(target: &str, file: &Path, session: &Session) -> ExitCode {
301    let (client, key) = match session.client_for(target).await {
302        Ok(pair) => pair,
303        Err(code) => return code,
304    };
305    let key = key.as_str();
306
307    let bytes = match std::fs::read(file) {
308        Ok(bytes) => bytes,
309        Err(error) => return report(&error, ExitCode::Failure),
310    };
311    let name = file.file_name().map_or_else(
312        || "upload".to_owned(),
313        |name| name.to_string_lossy().into_owned(),
314    );
315
316    let body = serde_json::json!({
317        "file": name,
318        "bytes": bytes.len(),
319    });
320    let targets = [key.to_owned()];
321    let intent = Intent {
322        action: &format!("upload {name} to {key}"),
323        targets: &targets,
324        body: &body,
325        always_confirm: false,
326    };
327    if let Gate::Stop(code) = check(&intent, session) {
328        return code;
329    }
330
331    match client.upload(key, &name, bytes).await {
332        Ok(attachment) => {
333            emit(&format!("{key} attachment {}\n", attachment.id));
334            ExitCode::Success
335        }
336        Err(error) => {
337            let code = error.exit_code();
338            report(&error, code)
339        }
340    }
341}
342
343/// Remove one attachment.
344///
345/// The listing is fetched first so the confirmation can name the file rather
346/// than an id. An id on its own says nothing about what is about to be lost,
347/// and this is the one command here with no undo at all.
348async fn delete(target: &str, attachment: &str, session: &Session) -> ExitCode {
349    let (client, key) = match session.client_for(target).await {
350        Ok(pair) => pair,
351        Err(code) => return code,
352    };
353    let key = key.as_str();
354
355    let attachments = match client.attachments(key).await {
356        Ok(attachments) => attachments,
357        Err(error) => {
358            let code = error.exit_code();
359            return report(&error, code);
360        }
361    };
362
363    let Some(found) = attachments
364        .iter()
365        .find(|candidate| candidate.id == attachment || candidate.name == attachment)
366    else {
367        return report(
368            &format!("issue {key} has no attachment `{attachment}`"),
369            ExitCode::NotFound,
370        );
371    };
372
373    let body = serde_json::json!({ "file": found.name, "id": found.id });
374    let targets = [key.to_owned()];
375    let intent = Intent {
376        action: &format!("delete {} from {key}", found.name),
377        targets: &targets,
378        body: &body,
379        // One file is enough: Tracker keeps no copy, and nothing here puts it
380        // back. The same reason `queue create` asks.
381        always_confirm: true,
382    };
383    if let Gate::Stop(code) = check(&intent, session) {
384        return code;
385    }
386
387    match client.delete_attachment(key, &found.id).await {
388        Ok(()) => {
389            emit(&format!("{key} deleted attachment {}\n", found.name));
390            ExitCode::Success
391        }
392        Err(error) => {
393            let code = error.exit_code();
394            report(&error, code)
395        }
396    }
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402
403    /// The name comes from whoever uploaded the file; it must not be able to
404    /// choose a directory.
405    #[test]
406    fn a_traversing_name_is_reduced_to_its_last_segment() {
407        assert_eq!(safe_filename("../../etc/passwd", "id"), "passwd");
408        assert_eq!(safe_filename("/tmp/evil.sh", "id"), "evil.sh");
409        assert_eq!(safe_filename("a\\b\\c.txt", "id"), "c.txt");
410    }
411
412    #[test]
413    fn a_name_that_is_only_dots_falls_back_to_the_id() {
414        assert_eq!(safe_filename("..", "42"), "42");
415        assert_eq!(safe_filename("   ", "42"), "42");
416    }
417
418    #[test]
419    fn an_ordinary_name_is_left_alone() {
420        assert_eq!(safe_filename("report.pdf", "id"), "report.pdf");
421    }
422}