hnwの日記

go-shellwordsでUnixシェル的なものを実装した

私はSlackの入力テキストに対応して外部コマンドを起動するbotをGoで自作しています。このbotに最近「&&」などUnixシェルの演算子を部分的に実装したのですが、その際go-shellwordsが便利だったという話を紹介します。

私の作っているSlack botの簡単な紹介

私の作っているSlack botは入力テキストのパターンマッチを行い対応する外部コマンドを実行するようなものです。実行例を下記に示します。

f:id:hnw:20210117185029p:plain
自作Slack botの実行例

この例では銀行サイトから情報取得するNode.jsスクリプト2つ(口座振替照会、残高照会)を連続で実行して銀行引落に必要な残高があるかを確認しています。

go-shellwordsとは

go-shellwordsはmattnさん作のGoライブラリで、コマンドライン文字列を受け取ってコマンド引数の配列を返すようなものです。

Goのexec.Command()はコマンド引数の配列を受け取る仕様なので、今回のSlack botのように引数を含むユーザー入力を受け取ってコマンドを起動する場合は引数の配列に分割する必要があります。単純なコマンドしか受け付けないのであればstring.Split()で分割してもいいのですが、Unixシェルのようなクォーティングやエスケープに対応したい場合はgo-shellwordsを使うと便利です。

また、go-shellwordsはUnixシェルの特殊文字が出現するとparseを止めてくれるので、「&&」による複数コマンド実行にも対応できます。簡易コードですが、私は次のようなコードを書きました。

   for {
        // コマンドとみなせるところまでparse
        args, err := parser.Parse(line)
        if parser.Position < 0 {
            // 文字列末尾までparseした
            return
        }
        // コマンドの実行(または直前のコマンド実行結果と演算子次第では実行しない)
        // parseできなかったところから演算子を探す
        i := parser.Position
        token := line[i:]
        operators := []string{";", "&&", "||"}
        for _, op := range operators {
            if strings.HasPrefix(token, op) {
                // 演算子を保存
                i += len(op)
                break
            }
        }
        // マッチした演算子の後ろから次のループでparseする
        line = string(line[i:])
    }

エラー処理を省いてあるとはいえ、かなりシンプルに実現できるのがわかると思います。もっとも、今回は「&&」「||」「;」の3つしか対応していないためシンプルに書けた側面もあります。リダイレクトやカッコに対応しようと思ったらもう少し真面目に処理を書く必要があるでしょう。

エラー処理を含む実際のコードに興味がある場合は私の実装(executor.go)をご覧ください。

実装する演算子が左結合だけで済むと楽

「&&」「||」「;」の3つだけでも実装大変じゃない?木構造作る必要あるよね?と考えた人がいるかもしれません。私もそう思っていたのですが、Unixシェルの場合は偶然が重なって先頭から逐次処理していくだけで実現できました。

確かに、普通のプログラミング言語であれば演算子「||」より「&&」の優先度が高いので、例えば A || B && C という論理演算は A || (B && C)と解釈する必要があり、これを実現するには木構造を作る必要がありそうです。

しかし、Unixシェルでは「&&」と「||」の優先度は同じです(参考:Bash Reference Manual 3.2.4 Lists of Commands)。「;」だけはマニュアル上で優先度が低いのですが、同じ優先度と考えても矛盾は生じないようです1

全部の演算子が左結合だと頭からループで実装できるので楽ですね、という手抜きテクニックの紹介でした。

本物のシェルを使わない理由

蛇足かとは思いますが、Unixシェルっぽいことを実現したいときに本物のUnixシェルを使うのはオススメしません。よほど注意しないと外部文字列を元に任意コマンドを実行される脆弱性(OSコマンドインジェクション)を作り込んでしまいます。

今回の処理ならexec.Command("sh","-c",line)などとすればシェルのコマンドラインのフル機能を利用できますが、line がシェルに渡して安全な文字列であることを保証するのは困難だと思います。逆の見方をすれば、Unixシェル的な機能を実現するのに本物のUnixシェルが使えないからこそgo-shellwordsが便利なわけです。

まとめ

  • Unixシェルっぽい文字列処理をするのにgo-shellwordsを使うと楽だし安全
  • シェルの演算子のうち「&&」「||」「;」だけ実現するのは意外と楽

  1. A && B ; C && D を例に考えると、(A && B) ; (C && D)(A && B ; C) && D はどちらもCが成功したときだけDを実行するわけですから等価です。他の例を考えても完全に等価だと思うのですが、数学的に示せるかはわかっていません。数学的帰納法の出番かなあ…?