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}