型を意識すればまともなコードが書けるし、通常の文字列とHTML文字列は別の型です

Twitter風URL自動リンクの話(関連: Kazuho@Cybozu Labs: (Twitter の XSS 脆弱性に関連して) 構造化テキストの正しいエスケープ手法について)。
あの問題では、関数のシグネチャを expandString(String):String 、「文字列を受け取って文字列を返す関数」としているが、これはあくまで実装上の都合。実際には「プレインテキストを受け取ってHTMLを返す関数」であって、引数と帰り値のドメインはまったく異なっている。ということを踏まえると以下のコードがどれだけひどいのかわかろうというもの。

module Twitter
  module AutoLink
    # (snip)
    def auto_link(text, options = {})
      auto_link_usernames_or_lists(
        auto_link_urls_custom(
          auto_link_hashtags(text, options),
        options),
      options)
    end
    # (snip)
  end
end

http://github.com/mzsanford/twitter-text-rb/blob/master/lib/autolink.rb (commit: 47c90403e7ca9667cd863a9af0146f229b8f199f)

これはつまり、こういうことをしているのでは……?

autoLink :: PlainText -> HTML
autoLink text = autoLinkUserName $ autoLinkURL $ autoLinkHashTag text

autoLinkURL :: PlainText -> HTML
autoLinkHashTag :: PlainText -> HTML
autoLinkUserName :: PlainText -> HTML


ルールが単純なので、力技(置換を適用する順序や他の処理の影響をよく考える)でもどうにかなってしまうのだが、かなり筋が悪い方法と言えよう。実際、現在のtwitterでは、

http://example.com/#@hoge/


<a href="http://example.com/#@<a class="tweet-url username" href="/hoge" rel="nofollow">hoge</a>/" class="tweet-url web" rel="nofollow" target="_blank">http://example.com/#@<a class="tweet-url username" href="/hoge" rel="nofollow">hoge</a>/</a>

となってしまう(直す気はないらしい…………)*1

じゃあどうするのが正解なのか。型に厳密にやろうとするなら、

autoLink :: PlainText -> HTML
autoLink text = foldl (++) $ replaceByRule [
                    (REGEX_URL, linkURL),
                    (REGEX_USER, linkUser),
                    (REGEX_HASHTAG, linkHashTag)
                  ] text

type Rule = (Regex,(PlainText->HTML))

replaceByRule :: [Rule] -> PlainText -> [HTML]
replaceByRule rules text=
  case matched of
    | Just (matched_str,(_,filter),rest) = (filter matched_str) : replaceByRule rules rest
    | Nothing                            = (htmlPlainText $ head text) : replaceByRule rules $ tail text
  where matched = matchRule rules text

matchRule :: [Rule] -> PlainText -> Maybe (PlainText,Rule,PlainText)
;; ルールを先頭から見ていって、マッチするのがあったら(マッチ文字列,ルール,残りの文字列)を返す

みたいなかんじですかねー。

実際には正規表現一発でどうにかすることが多いと思われる(マッチした文字列からどのルールがヒットしたのかわかるので)

def auto_link text
  return text.gsub(/URL|UserName|HashTag|./) {|matched|
    return link_url(matched) if matched is URL
    return link_userName(matched) if matched is UserName
    return link_hash_tag(matched) if matched is HashTag
    return html_escape matched if matched is single char
  }
end

*1:2010-10-01追記:とか言ってたらXSS騒ぎのどさくさで修正されてました!