about summary refs log blame commit diff stats
path: root/src/config/mailcap.nim
blob: 4370f7758693bae33f64c51ad355b7a72b9fd9cd (plain) (tree)
1
2
3
4
5
6
7
8
9
10





                                            
                
                

                   

                      













































                                                              
                                        




























                                                                         
                                    

















































































































                                                                              

                                                                        





                            
                                   
 
                                                      



                           
                          

                 




                                                           


                                
                                                








                                                                  

                                                    


                    
              



                             


                                             





                            

                            
             
                         

                

                            
             
















                                                                     

                
                

                  
                
                    
                                     

                       
                                                    
                                
                                  






                                 
                                        



                                                                          
                  

                                                     
                                                   

                                 



                                                                        
                 

                               
                     



                                 













                                                                          













                                                                           
# See https://www.rfc-editor.org/rfc/rfc1524

import osproc
import streams
import strutils

import types/url
import types/opt
import utils/twtstr

import chakasu/charset

type
  MailcapParser = object
    stream: Stream
    hasbuf: bool
    buf: char

  MailcapFlags* = enum
    NEEDSTERMINAL = "needsterminal"
    COPIOUSOUTPUT = "copiousoutput"
    HTMLOUTPUT = "x-htmloutput" # from w3m

  MailcapEntry* = object
    mt*: string
    subt*: string
    cmd*: string
    flags*: set[MailcapFlags]
    nametemplate*: string
    edit*: string
    test*: string

  Mailcap* = seq[MailcapEntry]

const DefaultMailcap* = @[
  MailcapEntry(
    mt: "*",
    subt: "*",
    cmd: "xdg-open '%s'"
  )
]

proc has(state: MailcapParser): bool {.inline.} =
  return not state.stream.atEnd

proc consume(state: var MailcapParser): char =
  if state.hasbuf:
    state.hasbuf = false
    return state.buf
  return state.stream.readChar()

proc reconsume(state: var MailcapParser, c: char) =
  state.buf = c
  state.hasbuf = true

proc skipBlanks(state: var MailcapParser, c: var char): bool =
  while state.has():
    c = state.consume()
    if c notin AsciiWhitespace - {'\n'}:
      return true

proc skipBlanks(state: var MailcapParser) =
  var c: char
  if state.skipBlanks(c):
    state.reconsume(c)

proc skipLine(state: var MailcapParser) =
  while state.has():
    let c = state.consume()
    if c == '\n':
      break

proc consumeTypeField(state: var MailcapParser): Result[string, string] =
  var s = ""
  # type
  while state.has():
    let c = state.consume()
    if c == '/':
      s &= c
      break
    if c notin AsciiAlphaNumeric + {'-', '*'}:
      return err("Invalid character encountered in type field")
    s &= c.tolower()
  if not state.has():
    return err("Missing subtype")
  # subtype
  while state.has():
    let c = state.consume()
    if c in AsciiWhitespace + {';'}:
      state.reconsume(c)
      break
    if c notin AsciiAlphaNumeric + {'-', '.', '*', '_', '+'}:
      return err("Invalid character encountered in subtype field")
    s &= c.tolower()
  var c: char
  if not state.skipBlanks(c) or c != ';':
    return err("Semicolon not found")
  return ok(s)

proc consumeCommand(state: var MailcapParser): Result[string, string] =
  state.skipBlanks()
  var quoted = false
  var s = ""
  while state.has():
    let c = state.consume()
    if not quoted:
      if c == '\r':
        continue
      if c == ';' or c == '\n':
        state.reconsume(c)
        return ok(s)
      if c == '\\':
        quoted = true
        continue
      if c notin Ascii - Controls:
        return err("Invalid character encountered in command")
    else:
      quoted = false
    s &= c
  return ok(s)

type NamedField = enum
  NO_NAMED_FIELD, NAMED_FIELD_TEST, NAMED_FIELD_NAMETEMPLATE, NAMED_FIELD_EDIT

proc parseFieldKey(entry: var MailcapEntry, k: string): NamedField =
  case k
  of "needsterminal":
    entry.flags.incl(NEEDSTERMINAL)
  of "copiousoutput":
    entry.flags.incl(COPIOUSOUTPUT)
  of "x-htmloutput":
    entry.flags.incl(HTMLOUTPUT)
  of "test":
    return NAMED_FIELD_TEST
  of "nametemplate":
    return NAMED_FIELD_NAMETEMPLATE
  of "edit":
    return NAMED_FIELD_EDIT
  return NO_NAMED_FIELD

proc consumeField(state: var MailcapParser, entry: var MailcapEntry):
    Result[bool, string] =
  state.skipBlanks()
  if not state.has():
    return ok(false)
  var buf = ""
  while state.has():
    let c = state.consume()
    case c
    of ';', '\n':
      if parseFieldKey(entry, buf) != NO_NAMED_FIELD:
        return err("Expected command")
      return ok(c == ';')
    of '\r':
      continue
    of '=':
      let f = parseFieldKey(entry, buf)
      let cmd = ?state.consumeCommand()
      case f
      of NO_NAMED_FIELD:
        discard
      of NAMED_FIELD_TEST:
        entry.test = cmd
      of NAMED_FIELD_NAMETEMPLATE:
        entry.nametemplate = cmd
      of NAMED_FIELD_EDIT:
        entry.edit = cmd
      return ok(state.consume() == ';')
    else:
      if c in Controls:
        return err("Invalid character encountered in field")
      buf &= c

proc parseMailcap*(stream: Stream): Result[Mailcap, string] =
  var state = MailcapParser(stream: stream)
  var mailcap: Mailcap
  while not stream.atEnd():
    let c = state.consume()
    if c == '#':
      state.skipLine()
      continue
    state.reconsume(c)
    state.skipBlanks()
    let c2 = state.consume()
    if c2 == '\n' or c2 == '\r':
      continue
    state.reconsume(c2)
    let t = ?state.consumeTypeField()
    let mt = t.until('/') #TODO this could be more efficient
    let subt = t[mt.len + 1 .. ^1]
    var entry = MailcapEntry(
      mt: mt,
      subt: subt,
      cmd: ?state.consumeCommand()
    )
    if state.consume() == ';':
      while ?state.consumeField(entry):
        discard
    mailcap.add(entry)
  return ok(mailcap)

# Mostly based on w3m's mailcap quote/unquote
type UnquoteState = enum
  STATE_NORMAL, STATE_QUOTED, STATE_PERC, STATE_ATTR, STATE_ATTR_QUOTED,
  STATE_DOLLAR

type UnquoteResult* = object
  canpipe*: bool
  cmd*: string

type QuoteState = enum
  QS_NORMAL, QS_DQUOTED, QS_SQUOTED

proc quoteFile(file: string, qs: QuoteState): string =
  var s = ""
  for c in file:
    case c
    of '$', '`', '"', '\\':
      if qs != QS_SQUOTED:
        s &= '\\'
    of '\'':
      if qs == QS_SQUOTED:
        s &= "'\\'" # then re-open the quote by appending c
      elif qs == QS_NORMAL:
        s &= '\\'
      # double-quoted: append normally
    of '_', '.', ':', '/':
      discard # no need to quote
    else:
      if c notin AsciiAlpha and qs == QS_NORMAL:
        s &= '\\'
    s &= c
  return s

proc unquoteCommand*(ecmd, contentType, outpath: string, url: URL,
    charset: Charset, canpipe: var bool): string =
  var cmd = ""
  var attrname = ""
  var state: UnquoteState
  var qss = @[QS_NORMAL] # quote state stack. len >1
  template qs: var QuoteState = qss[^1]
  for c in ecmd:
    case state
    of STATE_QUOTED:
      cmd &= c
      state = STATE_NORMAL
    of STATE_ATTR_QUOTED:
      attrname &= c.tolower()
      state = STATE_ATTR
    of STATE_NORMAL, STATE_DOLLAR:
      let prev_dollar = state == STATE_DOLLAR
      state = STATE_NORMAL
      case c
      of '%':
        state = STATE_PERC
      of '\\':
        state = STATE_QUOTED
      of '\'':
        if qs == QS_SQUOTED:
          qs = QS_NORMAL
        else:
          qs = QS_SQUOTED
        cmd &= c
      of '"':
        if qs == QS_DQUOTED:
          qs = QS_NORMAL
        else:
          qs = QS_DQUOTED
        cmd &= c
      of '$':
        if qs != QS_SQUOTED:
          state = STATE_DOLLAR
        cmd &= c
      of '(':
        if prev_dollar:
          qss.add(QS_NORMAL)
        cmd &= c
      of ')':
        if qs != QS_SQUOTED:
          if qss.len > 1:
            qss.setLen(qss.len - 1)
          else:
            # mismatched parens; probably an invalid shell command...
            qss[0] = QS_NORMAL
        cmd &= c
      else:
        cmd &= c
    of STATE_PERC:
      if c == '%':
        cmd &= c
      elif c == 's':
        cmd &= quoteFile(outpath, qs)
        canpipe = false
      elif c == 't':
        cmd &= quoteFile(contentType.until(';'), qs)
      elif c == 'u': # extension
        cmd &= quoteFile($url, qs)
      elif c == '{':
        state = STATE_ATTR
        continue
      state = STATE_NORMAL
    of STATE_ATTR:
      if c == '}':
        if attrname == "charset":
          cmd &= quoteFile($charset, qs)
          continue
        #TODO this is broken, because content-type is stripped of ; fields
        let kvs = contentType.after(';').toLowerAscii()
        var i = kvs.find(attrname)
        var s = ""
        if i != -1 and kvs.len > i + attrname.len and
            kvs[i + attrname.len] == '=':
          i = skipBlanks(kvs, i + attrname.len + 1)
          var q = false
          for j in i ..< kvs.len:
            if not q and kvs[j] == '\\':
              q = true
            elif not q and (kvs[j] == ';' or kvs[j] in AsciiWhitespace):
              break
            else:
              s &= kvs[j]
        cmd &= quoteFile(s, qs)
        attrname = ""
      elif c == '\\':
        state = STATE_ATTR_QUOTED
      else:
        attrname &= c
  return cmd

proc unquoteCommand*(ecmd, contentType, outpath: string, url: URL,
    charset: Charset): string =
  var canpipe: bool
  return unquoteCommand(ecmd, contentType, outpath, url, charset, canpipe)

proc getMailcapEntry*(mailcap: Mailcap, mimeType, outpath: string,
    url: URL, charset: Charset): ptr MailcapEntry =
  let mt = mimeType.until('/')
  if mt.len + 1 >= mimeType.len:
    return nil
  let st = mimeType[mt.len + 1 .. ^1]
  for entry in mailcap:
    if not (entry.mt.len == 1 and entry.mt[0] == '*') and
        entry.mt != mt:
      continue
    if not (entry.subt.len == 1 and entry.subt[0] == '*') and
        entry.subt != st:
      continue
    if entry.test != "":
      var canpipe = true
      let cmd = unquoteCommand(entry.test, mimeType, outpath, url, charset,
        canpipe)
      #TODO TODO TODO if not canpipe ...
      if execCmd(cmd) != 0:
        continue
    return unsafeAddr entry