mailcap/
lib.rs

1// mailcap file handling - see RFC 1524
2// Copyright (C) 2022 savoy
3
4// mailcap is free software: you can redistribute it and/or modify
5// it under the terms of the GNU Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8//
9// mailcap is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12// GNU General Public License for more details.
13//
14// You should have received a copy of the GNU General Public License
15// along with mailcap.  If not, see <http://www.gnu.org/licenses/>.
16
17//! # mailcap
18//!
19//! `mailcap` is a parsing library for mailcap files.
20//!
21//! Mailcap files are a format documented in [RFC
22//! 1524](https://www.rfc-editor.org/rfc/rfc1524.html), "A User Agent Configuration
23//! Mechanism For Multimedia Mail Format Information." They allow the handling of
24//! MIME types by software aware of those types. For example, a mailcap line of
25//! `text/html; qutebrowser '%s'; test=test -n "$DISPLAY"` would instruct the
26//! software to open any HTML file with qutebrowser if you are running a graphical
27//! session, with the file replacing the `'%s'`.
28//!
29//! `mailcap` is a parsing library that looks at either a present `$MAILCAPS` env
30//! variable or cycles through the four paths where a mailcap file would be found in
31//! ascending order of importance: `/usr/local/etc/mailcap`, `/usr/etc/mailcap`,
32//! `/etc/mailcap`, and `$HOME/.mailcap`. It builds the mailcap from all available
33//! files, with duplicate entries being squashed with newer lines, allowing
34//! `$HOME/.mailcap` to be the final decider.
35//!
36//! The entries that make up the mailcap include only those that are relevant i.e.
37//! those that have passed the `test` field (if present). With the above `text/html`
38//! example, that test would fail if run through SSH, and unless another existing
39//! `text/html` entry (or `text/*`) exists that doesn't require a display server, no
40//! entry would exist for that mime type.
41//!
42//! # Usage
43//!
44//! ```rust
45//! use mailcap::{Mailcap, MailcapError};
46//!
47//! fn main() -> Result<(), MailcapError> {
48//!     let cap = Mailcap::new()?;
49//!     if let Some(i) = cap.get("text/html") {
50//!         let command = i.viewer("/var/www/index.html");
51//!         assert_eq!(command, "qutebrowser '/var/www/index.html'");
52//!     }
53//!     Ok(())
54//! }
55//! ```
56//!
57//! Wildcard fallbacks are also supported.
58//!
59//! ```rust
60//! use mailcap::{Mailcap, MailcapError};
61//!
62//! fn main() -> Result<(), MailcapError> {
63//!     let cap = Mailcap::new()?;
64//!     if let Some(i) = cap.get("video/avi") {
65//!         // if no video/avi MIME entry available
66//!         let mime_type = i.mime();
67//!         assert_eq!(mime_type, "video/*");
68//!     }
69//!     Ok(())
70//! }
71//! ```
72
73use libc::system;
74use std::collections::HashMap;
75use std::ffi::CString;
76use std::fs::File;
77use std::io::{BufRead, BufReader};
78use std::path::PathBuf;
79use std::{env, fmt};
80
81/// The error type for `mailcap`.
82#[derive(Debug, PartialEq)]
83pub enum MailcapError {
84    /// The mailcap line was unable to be parsed.
85    LineParseError,
86    /// The mailcap file was unable to be parsed.
87    FileParseError,
88    /// There are no valid mailcap files to parse.
89    NoValidFiles,
90}
91
92/// Meta representation of all available mailcap files and their combined lines.
93#[derive(Default, Debug, PartialEq, Clone)]
94pub struct Mailcap {
95    files: Vec<PathBuf>,
96    data: HashMap<String, Entry>,
97}
98
99/// Parsed mailcap line. Each mailcap entry consists of a number of fields, separated by
100/// semi-colons. The first two fields are required, and must occur in the specified order. The
101/// remaining fields are optional, and may appear in any order.
102#[derive(Default, Debug, PartialEq, Clone)]
103pub struct Entry {
104    mime_type: String,
105    viewer: String,
106    compose: Option<String>,
107    compose_typed: Option<String>,
108    edit: Option<String>,
109    print: Option<String>,
110    test: Option<String>,
111    description: Option<String>,
112    name_template: Option<String>,
113    needs_terminal: bool,
114    copious_output: bool,
115    textual_new_lines: bool,
116}
117
118impl fmt::Display for MailcapError {
119    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
120        match self {
121            MailcapError::NoValidFiles => write!(f, "No populated mailcap found"),
122            MailcapError::LineParseError => write!(f, "Unable to parse mailcap lines"),
123            MailcapError::FileParseError => write!(f, "Unable to parse mailcap file"),
124        }
125    }
126}
127
128impl Mailcap {
129    /// Returns a combined mailcap from all available default files or a $MAILCAPS env.
130    /// The default list (in ascending order of importance) includes:
131    ///
132    /// - `/usr/local/etc/mailcap`
133    /// - `/usr/etc/mailcap`
134    /// - `/etc/mailcap`
135    /// - `$HOME/.mailcap`
136    ///
137    /// # Examples
138    ///
139    /// ```rust
140    /// # use mailcap::{Mailcap, MailcapError};
141    /// # fn main() -> Result<(), MailcapError> {
142    /// let cap = Mailcap::new()?;
143    /// # Ok(())
144    /// # }
145    /// ```
146    ///
147    /// # Errors
148    ///
149    /// If there are no available mailcap files in the default locations or no $MAILCAPS env has
150    /// been set, or if the files or empty or contain no valid mailcap lines, `MailcapError` will
151    /// be returned. The implementation is loose: as long as one file exists with at least one
152    /// valid mailcap line, the `Result` will be `Ok`.
153    pub fn new() -> Result<Mailcap, MailcapError> {
154        let mut files = Self::list_potential_files();
155        Self::check_files_exist(&mut files)?;
156
157        let mut virgin_lines: Vec<String> = vec![];
158        for file in &files {
159            match Self::get_mailcap_lines(&file) {
160                Ok(mut i) => virgin_lines.append(&mut i),
161                Err(_) => continue,
162            };
163        }
164
165        let parsed_lines = Self::parse_valid_lines(virgin_lines)?;
166        let data: HashMap<String, Entry> = parsed_lines
167            .iter()
168            .filter_map(|i| match Entry::from(i) {
169                Some(m) => Some((i[0].to_owned(), m)),
170                None => None,
171            })
172            .collect();
173
174        Ok(Mailcap { files, data })
175    }
176
177    /// Given a specific mime-type value, will lookup if there is an existing mailcap entry for
178    /// that type.
179    ///
180    /// # Examples
181    ///
182    /// ```rust
183    /// # use mailcap::{Mailcap, MailcapError};
184    /// # fn main() -> Result<(), MailcapError> {
185    /// let cap = Mailcap::new()?;
186    /// if let Some(i) = cap.get("text/html") {
187    ///     let command = i.viewer("/var/www/index.html");
188    ///     assert_eq!(command, "qutebrowser '/var/www/index.html'");
189    /// }
190    /// # Ok(())
191    /// # }
192    /// ```
193    pub fn get(&self, key: &str) -> Option<&Entry> {
194        match self.data.get(key) {
195            Some(v) => Some(v),
196            None => {
197                let mime_split: Vec<&str> = key.split("/").collect();
198                let mut wildcard = String::new();
199                if let Some(x) = mime_split.get(0) {
200                    wildcard.push_str(x);
201                    wildcard.push_str("/*");
202                };
203                self.data.get(&wildcard)
204            }
205        }
206    }
207
208    fn get_user_home() -> PathBuf {
209        let home = match env::var("HOME") {
210            Ok(i) => PathBuf::from(i),
211            Err(_) => PathBuf::from("."),
212        };
213
214        home
215    }
216
217    fn list_potential_files() -> Vec<PathBuf> {
218        let mut mailcap_files: Vec<PathBuf> = vec![];
219        if let Ok(paths) = env::var("MAILCAPS") {
220            for path in env::split_paths(&paths) {
221                mailcap_files.push(path)
222            }
223        };
224
225        if mailcap_files.is_empty() {
226            let home = Self::get_user_home();
227
228            let mut default_locations: Vec<PathBuf> = vec![
229                PathBuf::from("/usr/local/etc/mailcap"),
230                PathBuf::from("/usr/etc/mailcap"),
231                PathBuf::from("/etc/mailcap"),
232                home.join(".mailcap"),
233            ];
234
235            mailcap_files.append(&mut default_locations)
236        };
237
238        mailcap_files
239    }
240
241    fn check_files_exist(mailcap_files: &mut Vec<PathBuf>) -> Result<(), MailcapError> {
242        mailcap_files.retain(|i| i.exists());
243
244        match mailcap_files.is_empty() {
245            true => Err(MailcapError::NoValidFiles),
246            false => Ok(()),
247        }
248    }
249
250    fn get_mailcap_lines(filepath: &PathBuf) -> std::io::Result<Vec<String>> {
251        let ignore_chars = ['#', '\n'];
252
253        let file = BufReader::new(File::open(filepath)?);
254        let correct_lines: Vec<String> = file
255            .lines()
256            .map(|i| i.unwrap())
257            .filter(|i| !i.starts_with(ignore_chars))
258            .filter(|i| !i.is_empty())
259            .collect();
260
261        Ok(correct_lines)
262    }
263
264    fn parse_valid_lines(all_lines: Vec<String>) -> Result<Vec<Vec<String>>, MailcapError> {
265        let mut lines: Vec<Vec<String>> = vec![];
266        for line in all_lines {
267            lines.push(
268                line.split(";")
269                    .into_iter()
270                    .map(|i| i.trim().to_string())
271                    .collect::<Vec<String>>(),
272            );
273        }
274
275        match lines.is_empty() {
276            true => Err(MailcapError::FileParseError),
277            false => {
278                if lines.iter().all(|i| i.len() >= 2) {
279                    Ok(lines)
280                } else {
281                    Err(MailcapError::FileParseError)
282                }
283            }
284        }
285    }
286}
287
288impl Entry {
289    fn from(line: &Vec<String>) -> Option<Entry> {
290        let mut entry = Entry::default();
291        entry.mime_type = line[0].to_owned();
292        entry.viewer = line[1].to_owned();
293
294        for field in line[2..].iter() {
295            match Self::parse_arg(field) {
296                Some(("compose", v)) => entry.compose = Some(v[1..].to_string()),
297                Some(("composetyped", v)) => entry.compose_typed = Some(v[1..].to_string()),
298                Some(("edit", v)) => entry.edit = Some(v[1..].to_string()),
299                Some(("print", v)) => entry.print = Some(v[1..].to_string()),
300                Some(("test", v)) => entry.test = Some(v[1..].to_string()),
301                Some(("description", v)) => entry.description = Some(v[1..].to_string()),
302                Some(("nametemplate", v)) => entry.name_template = Some(v[1..].to_string()),
303                Some(("needsterminal", _)) => entry.needs_terminal = true,
304                Some(("copiousoutput", _)) => entry.copious_output = true,
305                Some(("textualnewlines", _)) => entry.textual_new_lines = true,
306                _ => continue,
307            }
308        }
309
310        match entry.test {
311            Some(ref c) => match unsafe { Self::test_entry(c) } {
312                Ok(()) => Some(entry),
313                Err(()) => None,
314            },
315            None => None,
316        }
317    }
318
319    /// The `mime_type`, which indicates the type of data this mailcap entry describes how to
320    /// handle. It is to be matched against the type/subtype specification in the "Content-Type"
321    /// header field of an Internet mail message. If the subtype is specified as "*", it is
322    /// intended to match all subtypes of the named `mime_type`.
323    pub fn mime(&self) -> &String {
324        &self.mime_type
325    }
326
327    /// The second field, `viewer`, is a specification of how the message or body part can be
328    /// viewed at the local site. Although the syntax of this field is fully specified, the
329    /// semantics of program execution are necessarily somewhat operating system dependent. UNIX
330    /// semantics are given in Appendix A of RFC 1524.
331    pub fn viewer(&self, filename: &str) -> String {
332        self.viewer.replace("%s", filename)
333    }
334
335    /// The `compose` field may be used to specify a program that can be used to compose a new body
336    /// or body part in the given format. Its intended use is to support mail composing agents that
337    /// support the composition of multiple types of mail using external composing agents. As with
338    /// `viewer`, the semantics of program execution are operating system dependent, with UNIX
339    /// semantics specified in Appendix A of RFC 1524. The result of the composing program may be
340    /// data that is not yet suitable for mail transport -- that is, a Content-Transfer-Encoding
341    /// may need to be applied to the data.
342    pub fn compose(&self) -> &Option<String> {
343        &self.compose
344    }
345
346    /// The `compose_typed` field is similar to the `compose` field, but is to be used when the
347    /// composing program needs to specify the Content-type header field to be applied to the
348    /// composed data. The `compose` field is simpler, and is preferred for use with existing
349    /// (non-mail-oriented) programs for composing data in a given format. The `compose_typed` field
350    /// is necessary when the Content-type information must include auxilliary parameters, and the
351    /// composition program must then know enough about mail formats to produce output that
352    /// includes the mail type information.
353    pub fn compose_typed(&self) -> &Option<String> {
354        &self.compose_typed
355    }
356
357    /// The `edit` field may be used to specify a program that can be used to edit a body or body
358    /// part in the given format. In many cases, it may be identical in content to the `compose`
359    /// field, and shares the operating-system dependent semantics for program execution.
360    pub fn edit(&self) -> &Option<String> {
361        &self.edit
362    }
363
364    /// The `print` field may be used to specify a program that can be used to print a message or
365    /// body part in the given format. As with `viewer`, the semantics of program execution are
366    /// operating system dependent, with UNIX semantics specified in Appendix A of RFC 1524.
367    pub fn print(&self) -> &Option<String> {
368        &self.print
369    }
370
371    /// The `test` field may be used to test some external condition (e.g., the machine
372    /// architecture, or the window system in use) to determine whether or not the mailcap line
373    /// applies. It specifies a program to be run to test some condition. The semantics of
374    /// execution and of the value returned by the test program are operating system dependent,
375    /// with UNIX semantics specified in Appendix A of RFC 1524. If the test fails, a subsequent
376    /// mailcap entry should be sought. Multiple test fields are not permitted -- since a test can
377    /// call a program, it can already be arbitrarily complex.
378    pub fn test(&self) -> &Option<String> {
379        &self.test
380    }
381
382    /// The `description` field simply provides a textual description, optionally quoted, that
383    /// describes the type of data, to be used optionally by mail readers that wish to describe the
384    /// data before offering to display it.
385    pub fn description(&self) -> &Option<String> {
386        &self.description
387    }
388
389    /// The `name_template` field gives a file name format, in which %s will be replaced by a short
390    /// unique string to give the name of the temporary file to be passed to the viewing command.
391    /// This is only expected to be relevant in environments where filename extensions are
392    /// meaningful, e.g., one coulld specify that a GIF file being passed to a gif viewer should
393    /// have a name eding in ".gif" by using "nametemplate=%s.gif".
394    pub fn name_template(&self) -> &Option<String> {
395        &self.name_template
396    }
397
398    /// The `needs_terminal` field indicates that the `viewer` must be run on an interactive
399    /// terminal. This is needed to inform window-oriented user agents that an interactive
400    /// terminal is needed. (The decision is not left exclusively to `viewer` because in
401    /// some circumstances it may not be possible for such programs to tell whether or not they are
402    /// on interactive terminals). The `needs_terminal` command should be assumed to apply to the
403    /// compose and edit commands, too, if they exist. Note that this is NOT a test -- it is a
404    /// requirement for the environment in which the program will be executed, and should typically
405    /// cause the creation of a terminal window when not executed on either a real terminal or a
406    /// terminal window.
407    pub fn needs_terminal(&self) -> &bool {
408        &self.needs_terminal
409    }
410
411    /// The `copious_output` field indicates that the output from `viewer` will be an
412    /// extended stream of output, and is to be interpreted as advice to the UA (User Agent
413    /// mail-reading program) that the output should be either paged or made scrollable. Note that
414    /// it is probably a mistake if `needs_terminal` and `copious_output` are both specified.
415    pub fn copious_output(&self) -> &bool {
416        &self.copious_output
417    }
418
419    /// The `textual_new_lines` field, if set to any non-zero value, indicates that this type of data
420    /// is line-oriented and that, if encoded in base64, all newlines should be converted to
421    /// canonical form (CRLF) before encoding, and will be in that form after decoding. In general,
422    /// this field is needed only if there is line-oriented data of some type other than text/* or
423    /// non-line-oriented data that is a subtype of text.
424    pub fn textual_new_lines(&self) -> &bool {
425        &self.textual_new_lines
426    }
427
428    fn parse_arg(field: &str) -> Option<(&str, &str)> {
429        match field.find("=") {
430            Some(i) => Some(field.split_at(i)),
431            None => Some((field, "")),
432        }
433    }
434
435    unsafe fn test_entry(test_command: &String) -> Result<(), ()> {
436        let c_str = CString::new(test_command.as_str()).unwrap();
437
438        match system(c_str.as_ptr()) {
439            0 => Ok(()),
440            _ => Err(()),
441        }
442    }
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448    use serial_test::serial;
449    use std::io::Write;
450
451    fn create_dummy_mailcap_line(dummy_value: Vec<String>) -> (PathBuf, Vec<String>) {
452        let path = PathBuf::from("/tmp/mailcap-rs.test");
453        let mut dummy_file = File::create(&path).unwrap();
454        writeln!(&mut dummy_file, "{:?}", dummy_value).unwrap();
455
456        (path, dummy_value)
457    }
458
459    fn dummy_mailcap() -> Mailcap {
460        let mut cap_data: HashMap<String, Entry> = HashMap::new();
461        cap_data.insert(
462            "text/html".to_string(),
463            Entry {
464                mime_type: "text/html".to_string(),
465                viewer: "qutebrowser '%s'".to_string(),
466                compose: None,
467                compose_typed: None,
468                edit: None,
469                print: None,
470                test: Some("test -n \"$DISPLAY\"".to_string()),
471                description: None,
472                name_template: Some("%s.html".to_string()),
473                needs_terminal: true,
474                copious_output: false,
475                textual_new_lines: true,
476            },
477        );
478        cap_data.insert(
479            "text/*".to_string(),
480            Entry {
481                mime_type: "text/*".to_string(),
482                viewer: "qutebrowser '%s'".to_string(),
483                compose: None,
484                compose_typed: None,
485                edit: None,
486                print: None,
487                test: Some("test -n \"$DISPLAY\"".to_string()),
488                description: None,
489                name_template: Some("%s.html".to_string()),
490                needs_terminal: true,
491                copious_output: false,
492                textual_new_lines: true,
493            },
494        );
495        Mailcap {
496            files: vec![PathBuf::from("/etc/mailcap")],
497            data: cap_data,
498        }
499    }
500
501    #[test]
502    #[serial]
503    fn mailcap_files_env() {
504        env::set_var("MAILCAPS", "/etc/mailcap");
505        let mailcaps = Mailcap::list_potential_files();
506        env::remove_var("MAILCAPS");
507
508        assert_eq!(mailcaps, vec![PathBuf::from("/etc/mailcap")]);
509    }
510
511    #[test]
512    #[serial]
513    fn mailcap_files_no_env() {
514        if let Ok(_) = env::var("MAILCAPS") {
515            env::remove_var("MAILCAPS")
516        }
517
518        let home = Mailcap::get_user_home();
519        let default_locations: Vec<PathBuf> = vec![
520            PathBuf::from("/usr/local/etc/mailcap"),
521            PathBuf::from("/usr/etc/mailcap"),
522            PathBuf::from("/etc/mailcap"),
523            home.join(".mailcap"),
524        ];
525
526        assert_eq!(default_locations, Mailcap::list_potential_files())
527    }
528
529    #[test]
530    fn mailcap_lines() {
531        let home = Mailcap::get_user_home();
532        let local_location = home.join(".mailcap");
533
534        let correct_lines = Mailcap::get_mailcap_lines(&local_location);
535        match correct_lines {
536            Ok(i) => assert!(!i.is_empty()),
537            Err(e) => panic!("{}", e),
538        };
539    }
540
541    #[test]
542    fn mailcap_line_splitting() {
543        let all_lines = vec![
544            String::from("text/html; qutebrowser '%s'; test=test -n \"$DISPLAY\"; nametemplate=%s.html; needsterminal"),
545            String::from("image/*; feh -g 1280x720 --scale-down '%s'; test=test -n \"$DISPLAY\"")
546        ];
547        let lines = Mailcap::parse_valid_lines(all_lines).unwrap();
548        assert_eq!(
549            vec![
550                vec![
551                    "text/html",
552                    "qutebrowser '%s'",
553                    "test=test -n \"$DISPLAY\"",
554                    "nametemplate=%s.html",
555                    "needsterminal"
556                ],
557                vec![
558                    "image/*",
559                    "feh -g 1280x720 --scale-down '%s'",
560                    "test=test -n \"$DISPLAY\""
561                ]
562            ],
563            lines
564        );
565    }
566
567    #[test]
568    fn create_entry_struct() {
569        let line = vec![
570            "text/html".to_string(),
571            "qutebrowser '%s'".to_string(),
572            "test=test -n \"$DISPLAY\"".to_string(),
573            "nametemplate=%s.html".to_string(),
574            "needsterminal".to_string(),
575            "textualnewlines=1917".to_string(),
576        ];
577        let entry = Entry::from(&line).unwrap();
578        assert_eq!(
579            Entry {
580                mime_type: "text/html".to_string(),
581                viewer: "qutebrowser '%s'".to_string(),
582                compose: None,
583                compose_typed: None,
584                edit: None,
585                print: None,
586                test: Some("test -n \"$DISPLAY\"".to_string()),
587                description: None,
588                name_template: Some("%s.html".to_string()),
589                needs_terminal: true,
590                copious_output: false,
591                textual_new_lines: true
592            },
593            entry
594        )
595    }
596
597    #[test]
598    #[serial]
599    fn create_mailcap_struct() {
600        let (_path, dummy_line) = create_dummy_mailcap_line(
601            vec!["text/html; qutebrowser '%s'; test=test -n \"$DISPLAY\"; nametemplate=%s.html; needsterminal".to_string()]
602        );
603        let dummy_line_vectorized = Mailcap::parse_valid_lines(dummy_line).unwrap();
604        let dummy_line = Entry::from(&dummy_line_vectorized[0]).unwrap();
605
606        env::set_var("MAILCAPS", "/tmp/mailcap-rs.test");
607        let mailcap = Mailcap::new().unwrap();
608        env::remove_var("MAILCAPS");
609        if let Some(i) = mailcap.data.get("text/html") {
610            assert_eq!(i.viewer, dummy_line.viewer)
611        }
612    }
613
614    #[test]
615    #[serial]
616    fn mailcap_with_duplicates() {
617        let (_path, dummy_line) = create_dummy_mailcap_line(
618            vec![
619                "text/html; qutebrowser '%s'; test=test -n \"$DISPLAY\"; nametemplate=%s.html; needsterminal".to_string(),
620                "text/html; firefox '%s'; test=test -n \"$DISPLAY\"; nametemplate=%s.html".to_string()]
621        );
622
623        let dummy_line_vectorized = Mailcap::parse_valid_lines(dummy_line).unwrap();
624        let dummy_line = Entry::from(&dummy_line_vectorized[1]).unwrap();
625
626        env::set_var("MAILCAPS", "/tmp/mailcap-rs.test");
627        let mailcap = Mailcap::new().unwrap();
628        env::remove_var("MAILCAPS");
629        if let Some(i) = mailcap.data.get("text/html") {
630            assert_eq!(i.viewer, dummy_line.viewer)
631        }
632    }
633
634    #[test]
635    fn get_mailcap_success() {
636        let mailcap = dummy_mailcap();
637        assert!(mailcap.get("text/html").is_some())
638    }
639
640    #[test]
641    fn get_mailcap_failure() {
642        let mailcap = dummy_mailcap();
643        assert!(mailcap.get("image/jpeg").is_none())
644    }
645
646    #[test]
647    fn get_mailcap_wildcard_fallback() {
648        let mailcap = dummy_mailcap();
649        // deliberate mispelling
650        let fallback = mailcap.get("text/hmtl").unwrap();
651        assert_eq!(fallback.mime_type, "text/*")
652    }
653}