軽すきゅえるで黒いよーで秘密nなGo! – sqlite, AlecAivazis/survey, crypto

「初学者」としての学習の「成果」が即実用になる、という幸運な例というのはそうそうあるもんではないが。

sqlite, AlecAivazis/survey, crypto 例 @ Go

やりたきこと

(1)(2)(3)(←(0))→(4)で一貫して説明してきたのが「今時点での Windows 版 Go のランタイム依存のなさがいいのよねー」なのだが、この特徴をフルに活かして、ワタシ的には「遂にずっと欲しかったもの」をやろうかと。

これから言う「ずっと欲しかったもの」が、ワタシが生活の中心にしている Python で実現不可能なのかと言えばそういうことではないのだが、「ランタイム依存」の部分の問題で、まぁ「やりにくい」わけである、Python では。Go でこれを作れるのであれば、本当に EXE 一個だけあればよく、そして「USB メモリにぶっ込んで、ぶっ込んだまま動かせる」てことだが、その「欲しいもの」の最優先要件であったりする、その特徴は。

それは何かというと、「秘密の箱」である。剥き身のまま置いておきたくないような、たとえば何かしらサービスのアカウント情報、そう、ユーザ名・パスワードの情報などの「メモ」なんぞを暗号化して置いておきたいわけね。

けれども、暗号化ソフトを使おうとすれば普通は、平文ファイルと暗号化後ファイルが同時に存在している時間が生じてしまう。平文は自分の PC だけ、暗号化したものは人に渡すためにネット転送する、みたいな場合はそれでもいいんだけれど、そうではなくて、「USB メモリにメモを置いておきたいが、デバイスごと紛失する危険性のある USB メモリに平文のまま秘密をぶっ込んでおきたくない」がゴールである場合は、その平文ファイルと暗号化後ファイルが同時に存在している時間はいらない。合理性だけの問題でなくて、実際非常に手間がかかってしまい、なんなら「だったらいいかぁ」になりがちなわけね。「開くのに暗号化ソフトを開いて暗号化されたファイルを開いて復号、復号したファイルを編集作業場所に保存、保存し終わったらおもむろにお好みのテキストエディタで開き、好きに編集し、編集し終わったら暗号化ソフトに突っ込んで暗号化済ファイルにして、暗号化前ファイルを消しつつ暗号化済ファイルに置き換える…」みたいなことだよ、わかるでしょ。

つまり、「パスワード入力、復号化した内容の閲覧または編集、編集終了すれば暗号化したものを永続化」という一連を全部やってくれるもの、そういうのが「たった一個の EXE で」出来るならば、その EXE とそれで保守されるデータ本体をセットで USB メモリに突っ込んで、安心して「エロいまんまの秘密」を管理出来るであろう、とまぁそういうわけである。

やりたきこと、の実現に求められる要件(求めたい要件)

ランタイム依存がないこと…、まぁ厳密なことを言えばそもそも「Windows」みたいに、動かす OS だけは固定ではあるんだけれど、そこは問わないとして、とにかく EXE だけあればいい、というのが必須の要件。

また、「http で給仕するサービス」というアプローチも「実現」には手早かったりはするのだけれど、これは「使い勝手」の面から却下。EXE 起動して Chrome で…、は、そもそも起動の手間が結構バカにならん(またしても「ここまでやらんでいいかぁ」を誘発する)し、そもそも html の input だけで編集をまかなうのはやはり苦痛だ。理想的には「秘密の編集」は、はっきりいってしまえば「emacs」を直接使いたい。

メモの「構造化」も欲しいので、永続化はデータベース的なものが欲しい。これについても「C/S」アプローチを避けたい、すなわちスタンドアロンであることが重要なので、まぁ普通は今だと SQLite 一択、ということになる。(というか、なんなら NOSQL でもいいんだけれど、C/S アーキテクチャでない NOSQL をワタシは知らんのよね。)

「暗号化」だけは、ワタシのこのライトなニーズには最高峰の暗号化はいらんので、標準ライブラリの crypto で良い。まぁ簡単なのでいいかなとは思ってる。

要件はこんななのだが、まぁなんというか、見つけたものがあまりにも順調すぎてね、こいつぁいいや、と、ちぃと久々に感動しておる。順に説明してくな。

AlecAivazis/survey

当たり前だがイキナリここに辿り着いたのではないし、そもそも見つけてお試した順序は sqlite の方が先だが、説明をするならこちらからした方がわかりやすいのでこちらから。

「理想的には「秘密の編集」は、はっきりいってしまえば「emacs」を直接使いたい」は本当に究極の形であって、そうもいかんのであろうなぁ、と探していたのに、ズバリが見つかってびっくりした、というのが流れだったりする。

なんであれ「それは最悪」の結末があるとすればそれは「標準入力で編集する」こと。高級な端末制御を伴わないものならば、これはすなわち「バックスペースで文字を消す」ことすらかなわない「編集」となり、いくら EXE 一個で済む範囲で済ませたいと言ったって、いくらなんでも不愉快過ぎる、と。(無論 Unicode など文字コードやロケールの問題もある。)そうでないものを狙うのならば、コンソールベースなら curses だの termcap/terminfo 的なものに依拠してちゃんとした制御をするもの、もしくはちゃんとした GUI のものを探そう、てことになるわけだ。

が、GUI は、まぁやはりというべきなのかな。tcl/tk がアウトなんだわ、Python の tkinter みたいに「抱え込み(に近いもの)」してるものが見つからなかった。つまり、「別途 ActiveTcl をインストールせべし」なのね。以外のものは、まぁ当然だが Qt や GTK だわ、当然これは本体が必要であり、「ランタイム依存なし」にならないので必然的に GUI は、さらっと見たものだと全滅。真剣に探せばあるのかもしれんなぁとは思うんだけどね。(→ 2022-03-19の追記参照。)

してコンソールベースを探したんだけれど、いや、まぁ AlecAivazis/survey は、今のワタシには満点だよ、なかなかにステキ。ここから辿ったんだったかな、確か。違ったかも。いずれにしても Go コミュニティでは十分に有名なもののようである。(別途別のドキュメントに辿り着いてるんだが、これはどっから辿り着いたんだろうか? Editor の例はここから。)

細かいことを言うと若干ややこしい話はある。パッケージ検索すると複数バージョンがアクティブなことがわかると思うのだが、なんとなく公式にみえる「survey (gopkg.in/AlecAivazis/survey.v1)」は、今のワタシの目的は果たせない。具体的には「emacs で」を可能とする「survey.Editor」が、どうも動作しない模様、少なくとも Windows 版は。試して動かせたのは survey (github.com/AlecAivazis/survey/v2)。Editor がいらないなら v1 だけでも結構感動出来るが、Editor が一番の目的なのでね、のでひとまず v2 を使うことにした。(検索では v3 てのもみえてるが、作者も違うみたいなので、当座 v2 でいいかなと。)

公式のサンプルに、ワタシの欲しい Editor 例も追加した以下を試した:

 1 package main
 2 
 3 import (
 4     "fmt"
 5     //"gopkg.in/AlecAivazis/survey.v1"
 6     "github.com/AlecAivazis/survey/v2"  // go get github.com/AlecAivazis/survey/v2
 7 )
 8 
 9 // the questions to ask
10 var qs = []*survey.Question{
11     {
12         Name:     "name",
13         Prompt:   &survey.Input{Message: "What is your name?"},
14         Validate: survey.Required,
15         Transform: survey.Title,
16     },
17     {
18         Name: "color",
19         Prompt: &survey.Select{
20             Message: "Choose a color:",
21             Options: []string{"red", "blue", "green"},
22             Default: "red",
23         },
24     },
25     {
26         Name: "age",
27         Prompt:   &survey.Input{Message: "How old are you?"},
28     },
29 }
30 
31 func main() {
32     // the answers will be written to this struct
33     answers := struct {
34         Name string
35         //   survey will match the question and field names
36 
37         FavoriteColor string `survey:"color"`
38         //   or you can tag fields to match a specific name
39 
40         Age int
41         //   if the types don't match exactly, survey will try to convert for you
42     }{}
43 
44     // perform the questions
45     err := survey.Ask(qs, &answers)
46     if err != nil {
47         fmt.Println(err.Error())
48         return
49     }
50     fmt.Printf("%s chose %s.\n", answers.Name, answers.FavoriteColor)
51     prompt := &survey.Editor{
52         Message: "Shell code snippet",
53         Default: "hogejiroh",
54         AppendDefault: true,
55         FileName: "*.sh",
56     }
57     var content string
58     survey.AskOne(prompt, &content)
59     fmt.Println(content)
60 }

ほんとは Editor 例も qs の一味に出来るんだけれど、この賢い書き方がわかりにくいとも思うので、このワタシのバカな書き方がよかろうと思う。なんというか、是非自分で動かしてみて欲しいなぁと思うよ。経験豊富な人ほど感動するような気がするよ。まさに「小さくて気が利くヤツだ」て感じなのよね、というか「このヒト、わかってるっ」て感じな。

Editor は、環境変数 EDITOR または VISUAL を参照して、それで指定されたエディタを起動するんだけれど、うん、これ、C/C++ だのの他のプログラミング環境で実現しようとしてみたことがある人はわかると思うんだ、これね、結構繊細で大変なんだよ、特に Windows で。まぁこの振る舞い自体はたとえば亀Git とかでよく見かけるのでお馴染みだとは思うんだ、だから難しいと思わない人が多いとは思うんだけど、実際は非常に大変、これ。だからほんとにほんっとーに、感謝、てことよ。

「動機」で書いたワタシが欲しい物にするためには、この Editor への「入力」となるもの(上のお試しでは「hogejiroh」)が「sqlite に格納された暗号化されたテキスト、の復号テキスト」で、AskOne で得られた content を「暗号化して sqlite に格納する」ということ。(編集列の選択とかは survey.Input とか survey.Select でやる感じかなって思う。)

ちなみに Editor は一時ファイルに内容を入れてそれをエディタで開いてくれるわけなのだが、編集終了してエディタを閉じるとその一時ファイルはちゃんと削除される。この特徴も、ワタシが作りたいものに必要なことである。

sqlite

これも順調に見つかったんだけれどもね、けど繊細な説明はいるかなとは思う。

java とか C# みたいなのに馴染んでる人であれば、「データベースについてのサポート」のあり方ってのは、おおむね「クライアント API の標準規約」だけが標準ライブラリにあって、個々のデータベースを相手にするにはベンダなどが提供するドライバを別途入手する必要がある、みたいなのに慣れてると思う。これはどんな言語でも大抵そう。ワタシが知ってるのは全部そうかな。

で、「データベースのサポート」としては bsddb と sqlite だけが異端児的に「抱え込」まれていることがあって、Python はそのパターンなんだけど、こういうことが可能なのは無論まずはスタンドアロンだからであるし、そして当然「小さいから」こそそうした抱え込みが可能なわけなんだけれど、Go の場合はそれはしてないみたい。SQLite と言えども github.com/golang/go/wiki/SQLDrivers から探せ、てことになる。

ほかのネタ内で「Windows 版では cgo は全然ダメ」と言ってて、そこの説明をいずれせねばいかんかのぉ、ともちょっと思うが、今回はご勘弁。とにかく「cgo のものは選べない」てことね、とすると必然的に「一択」となる。SQLite: (pure go)。これがまぁ何の問題もない:

公式の例ほぼそのまんま
 1 package main
 2 
 3 import (
 4     "fmt"
 5     "database/sql"
 6     _ "modernc.org/sqlite"  // go get modernc.org/sqlite
 7 )
 8 
 9 func main_() error {
10     db, err := sql.Open("sqlite", "my.db")
11     if err != nil {
12         return err
13     }
14     if _, err = db.Exec(`
15 drop table if exists t;
16 create table t(i);
17 insert into t values(42), (314);
18 `); err != nil {
19         return err
20     }
21 
22     rows, err := db.Query("select 3*i from t order by i;")
23     if err != nil {
24         return err
25     }
26 
27     for rows.Next() {
28         var i int
29         if err = rows.Scan(&i); err != nil {
30             return err
31         }
32 
33         fmt.Println(i)
34     }
35 
36     if err = rows.Err(); err != nil {
37         return err
38     }
39 
40     if err = db.Close(); err != nil {
41         return err
42     }
43     return nil
44 }
45 func main() {
46     err := main_()
47     if err != nil {
48         panic(err)
49     }
50 }

これねぇ、静的に丸抱えしてるか、あるいは完全にスクラッチから全部書いてるかどっちかなんだと思うけど、ほかの言語でよくやられてるような「sqlite3.dll」をロードして…ではなくて、ほんとに丸抱えしてるかのように振る舞ってる。つまり、ワタシの望みどおり「ランタイム依存しない」。「sqlite3.dll」くらいは仕方ないかしらね、とも思ってたのだが、それがいらんのよ、結構衝撃的にも思えるよね。(丸抱えがただの静的リンクということなら技術的には驚くべきことではないけれど、どっちにしたって結構な労力が必要だったんじゃないかなぁと思うんだよね。)

sqlite についてはこれだけだよ、これだけ。というか「これだけ」てことに感激しちゃってるよあたしゃ。ありがてー。

crypto

部品としては最後、暗号化の部分。上で説明した通り、標準ライブラリの範囲内でいい、ワタシのライトなニーズには。

ワタシ的にはかなり昔に Python の暗号化の例を書いたことがある。そこに書いたことで読み取れるかわからんのだが、「アルゴリズムの選択」と「鍵の管理」はこれは一体であって、たとえば Initial Vector などの考えが必要なものもあるし、そうでないものもある、など、使うものによって維持しなければならない箱の鍵の数や種類が違ってくるが、難しいことは考えたくない、簡単なのにしとこう、てことならひとまず rc4 かなと思う。暗号の強さの問題は無論あり、rc4 は今ではよろしくはない(脆弱であることがわかっている)が、ワタシの目的にはひとまず十分、としておく。パスワードを要求するだけで十分なのだ、とりあえず、てことなのね、今はさ。(今のところ AES の方がいいのは明らかなようだけれど、秘密の管理が一つ多いのでその扱いを考えないといけなくて、てことね。)

てわけで AlecAivazis/survey はさっそく使いつつ:

 1 package main
 2 
 3 import (
 4     "github.com/AlecAivazis/survey/v2"  // go get github.com/AlecAivazis/survey/v2
 5     "crypto/rc4"
 6     "fmt"
 7 )
 8 
 9 func main() {
10     plain := ""
11     survey.AskOne(&survey.Multiline{
12         Message: "text for encryption",
13     }, &plain)
14     passwd := ""
15     survey.AskOne(&survey.Password{
16         Message: "Please type your password",
17     }, &passwd)
18 
19     // ENCRYPT
20     c, err := rc4.NewCipher([]byte(passwd))
21     if err != nil {
22         panic(err)
23     }
24     src := []byte(plain)
25     //fmt.Println("Plaintext: ", string(src))
26 
27     dst := make([]byte, len(src))
28     c.XORKeyStream(dst, src)
29     //fmt.Println("Ciphertext: ", dst)
30 
31     // DECRYPT
32     c2, err := rc4.NewCipher([]byte(passwd))
33     if err != nil {
34         panic(err)
35     }
36     src2 := make([]byte, len(dst))
37     c2.XORKeyStream(src2, dst)
38     fmt.Println("decrypted: ", string(src2))
39 }

もしくは、たとえば「passwd」がいったん平文で外に出てしまうようなケースに備えてハッシュを採用するなら:

 1 package main
 2 
 3 import (
 4     "github.com/AlecAivazis/survey/v2"  // go get github.com/AlecAivazis/survey/v2
 5     "crypto/rc4"
 6     "crypto/sha256"
 7     "fmt"
 8 )
 9 
10 func main() {
11     plain := ""
12     survey.AskOne(&survey.Multiline{
13         Message: "text for encryption",
14     }, &plain)
15     passwd := ""
16     survey.AskOne(&survey.Password{
17         Message: "Please type your password",
18     }, &passwd)
19     passwdd := sha256.Sum256([]byte(passwd))
20 
21     // ENCRYPT
22     c, err := rc4.NewCipher(passwdd[:])
23     if err != nil {
24         panic(err)
25     }
26     src := []byte(plain)
27     //fmt.Println("Plaintext: ", string(src))
28 
29     dst := make([]byte, len(src))
30     c.XORKeyStream(dst, src)
31     //fmt.Println("Ciphertext: ", dst)
32 
33     // DECRYPT
34     c2, err := rc4.NewCipher(passwdd[:])
35     if err != nil {
36         panic(err)
37     }
38     src2 := make([]byte, len(dst))
39     c2.XORKeyStream(src2, dst)
40     fmt.Println("decrypted: ", string(src2))
41 }

うん、まぁ簡単は簡単ね。一応 RC4 は非推奨だよ念のため。ただ今はこの簡単さだけが大事なので、脆弱性にはいったん目をつむる。

ファースト出来てみたのは良き哉良き哉

「まったく苦労なんかしなかったぜ」なわけはなく、初学者らしく「マニュアル熟読しながら頭掻きむしって」書いた、けれど、あくまでも「初学者なら当たり前」のレベルでの苦労。そこを差し引けば、本当に、本当に順調で、なおかつ「このインチキ初版でもかなり実用になりうる」のがなかなかにステキだ:

  1 package main
  2 
  3 import (
  4     "os"
  5     "strings"
  6     "path/filepath"
  7     "log"
  8     "crypto/rc4"
  9     "crypto/sha256"
 10     "github.com/AlecAivazis/survey/v2"  // go get github.com/AlecAivazis/survey/v2
 11     "database/sql"
 12     _ "modernc.org/sqlite"
 13 )
 14 
 15 /*
 16  * related to database access
 17  */
 18 type MyHimichuDB struct {
 19     db *sql.DB
 20 }
 21 
 22 func (mydb *MyHimichuDB) Open() (error) {
 23     var _0 = filepath.Base(os.Args[0])
 24     var _1 = filepath.Ext(os.Args[0])
 25     var _mybasename = _0[:len(_0) - len(_1)]
 26 
 27     wd, err := filepath.Abs(".")
 28     if err != nil {
 29         return err
 30     }
 31     dbfn := filepath.Join(wd, "." + _mybasename + ".db")
 32     _, err = os.Stat(dbfn)
 33     virgin := (err != nil)
 34     if virgin {
 35         log.Printf("creating new database '%s'...", filepath.Base(dbfn))
 36     } else {
 37         log.Printf("opening database '%s'...", filepath.Base(dbfn))
 38     }
 39     mydb.db, err = sql.Open("sqlite", dbfn)
 40     if err != nil {
 41         return err
 42     }
 43     if virgin {
 44         _, err = mydb.db.Exec(`CREATE TABLE t_orenohimitchu (
 45     key TEXT PRIMARY KEY UNIQUE NOT NULL,
 46     txt BLOB
 47 )`)
 48         if err != nil {
 49             mydb.db.Close()
 50             return err
 51         }
 52     }
 53     return err
 54 }
 55 
 56 func (mydb *MyHimichuDB) Insert(key string, txt []byte) (error) {
 57     stmt, err := mydb.db.Prepare(`INSERT INTO t_orenohimitchu VALUES (?, ?)`)
 58     if err != nil {
 59         return err
 60     }
 61     _, err = stmt.Exec(key, txt)
 62     return err
 63 }
 64 
 65 func (mydb *MyHimichuDB) Update(key string, txt []byte) (error) {
 66     stmt, err := mydb.db.Prepare(`UPDATE t_orenohimitchu SET txt = ? WHERE key = ?`)
 67     if err != nil {
 68         return err
 69     }
 70     _, err = stmt.Exec(txt, key)
 71     return err
 72 }
 73 
 74 func (mydb *MyHimichuDB) SelectOne(key string) ([]byte, error) {
 75     stmt, err := mydb.db.Prepare(`SELECT txt FROM t_orenohimitchu WHERE key = ?`)
 76     if err != nil {
 77         return nil, err
 78     }
 79     rows, err := stmt.Query(key)
 80     if err != nil {
 81         return nil, err
 82     }
 83     defer rows.Close()
 84     var txt []byte
 85     for rows.Next() {
 86         if err = rows.Scan(&txt); err != nil {
 87             return nil, err
 88         }
 89         break
 90     }
 91     return txt, nil
 92 }
 93 
 94 func (mydb *MyHimichuDB) DeleteOne(key string) (error) {
 95     stmt, err := mydb.db.Prepare(`DELETE FROM t_orenohimitchu WHERE key = ?`)
 96     _, err = stmt.Exec(key)
 97     return err
 98 }
 99 
100 func (mydb *MyHimichuDB) Keys(q string) ([]string, error) {
101     stmt, err := mydb.db.Prepare(`SELECT key FROM t_orenohimitchu WHERE key LIKE ?`)
102     if err != nil {
103         return nil, err
104     }
105     rows, err := stmt.Query(q)
106     if err != nil {
107         return nil, err
108     }
109     defer rows.Close()
110     reskeys := []string{}
111     for rows.Next() {
112         var txt []byte
113         if err = rows.Scan(&txt); err != nil {
114             return nil, err
115         }
116         reskeys = append(reskeys, string(txt))
117     }
118     return reskeys, nil
119 }
120 
121 /*
122  * related to cipher
123  */
124 type MyCipher struct {
125     pw []byte
126 }
127 
128 func (cs *MyCipher) QueryPassword() {
129     passwd := ""
130     survey.AskOne(&survey.Password{
131         Message: "Please type your password",
132     }, &passwd)
133     p := sha256.Sum256([]byte(passwd))
134     cs.pw = p[:]
135 }
136 
137 func (cs *MyCipher) Encrypt(t string) []byte {
138     c, err := rc4.NewCipher(cs.pw)
139     if err != nil {
140         panic(err)
141     }
142     src := []byte(t)
143     dst := make([]byte, len(src))
144     c.XORKeyStream(dst, src)
145     return dst
146 }
147 
148 func (cs *MyCipher) Decrypt(ct []byte) string {
149     c, err := rc4.NewCipher(cs.pw)
150     if err != nil {
151         panic(err)
152     }
153     src := make([]byte, len(ct))
154     c.XORKeyStream(src, ct)
155     return string(src)
156 }
157 
158 /*
159  * related to ui (by "survey")
160  */
161 func edit(key, value string) (string, error) {
162     prompt := &survey.Editor{
163         Message: "Edit content!",
164         Default: value,
165         AppendDefault: true,
166         FileName: "*" + filepath.Ext(key),
167     }
168     var content string
169     err := survey.AskOne(prompt, &content)
170     return content, err
171 }
172 
173 /*
174  * main
175  */
176 func main() {
177     var cipher MyCipher
178     cipher.QueryPassword()
179     //
180     db := MyHimichuDB{}
181     err := db.Open()
182     if err != nil {
183         panic(err)
184     }
185     for {
186         var mentlsel string
187         survey.AskOne(&survey.Select{Options: []string{"list", "new", "exit"}}, &mentlsel)
188         if mentlsel == "exit" {
189             break
190         } else if mentlsel == "new" {
191             var newkey string
192             survey.AskOne(&survey.Input{Message: "key?"}, &newkey)
193             newval, err := edit(newkey, "")
194             if err != nil {
195                 panic(err)
196             }
197             //log.Println(cipher.Decrypt(cipher.Encrypt(newval)))
198             err = db.Insert(newkey, cipher.Encrypt(newval))
199             if err != nil {
200                 log.Println(err)
201             }
202         } else {
203             var pattern string
204             survey.AskOne(&survey.Input{Message: "pattern?", Default: "%"}, &pattern)
205             keys, err := db.Keys(pattern)
206             if err != nil {
207                 panic(err)
208             }
209             var keysel string
210             survey.AskOne(&survey.Select{Options: keys}, &keysel)
211             var action string
212             survey.AskOne(&survey.Input{Message: "action ([E]dit or [D]elete)?"}, &action)
213             if strings.ToLower(action) == "e" {
214                 txt, err := db.SelectOne(keysel)
215                 newval, err := edit(keysel, cipher.Decrypt(txt))
216                 if err != nil {
217                     panic(err)
218                 }
219                 err = db.Update(keysel, cipher.Encrypt(newval))
220                 if err != nil {
221                     panic(err)
222                 }
223             } else if strings.ToLower(action) == "d" {
224                 err = db.DeleteOne(keysel)
225                 if err != nil {
226                     panic(err)
227                 }
228             } else {
229                 log.Println("action must be 'e' or 'd'")
230             }
231         }
232     }
233 }


仕様の説明。「カレントディレクトリ」にこの EXE の名前に基づく sqlite データを作る。たとえば「hoge.exe」という名前にしたならば、「.hoge.db」。テーブル構造は非常に単純なもので、文字列 key (UNIQUE なプライマリキー)と、BLOB な txt だけから成る。この EXE はこのテーブルを「メニューベースの UI で」操作させるようになっている。BLOB のカラムを暗号化して収めるので、そのために最初にその暗号化のためのパスワードを入力させる。全ての txt BLOB カラムはこの共通の鍵で暗号/復号する。txt BLOB カラムの編集は「EDITOR 環境変数」にセットされたエディタで。Windows のデフォルトは「notepad」(「メモ帳」)だが、ワタシは emacs にしている。

てな具合。無論「パーフェクト」にはしようとしてないし、欲が出れば出るだろうよ、てもんである。けれども、「何の役にも立たない」というところからは何万フィートも遥か上空にあり、人によっては97%の満足度、ともなりうるほど「実用になる」。(ちゃんと「暗号化テキストを直接編集出来る」し、「暗号化されているので単独でデータベースだけ盗まれてもそれなりに安全(RC4 の安全性の範囲内で安全てこと)」。)

「カレントディレクトリにデータベースを作る」ようにしたのは、2つのユースケースに基づく。一つはずっと言ってきてるように「USBメモリにこの EXE を突っ込んだまま使う」想定で、その場合は「EXE ダブクルクリックして「そこ」に」というような使い方の想定。もう一つは普通に Windows が入ってる方のハードディスクのパスの通った場所に普通に「インストール」して、この場合はコマンドラインシェルからこのコマンドを指定して起動、という使い方。EXE とデータが別々、という使い方ね。どちらかの使い方だけが正しいってことではなくて、ケースバイケースで考えればよろしい。

にしてもあれだ、Go言語、ライブラリに対する不満は全然ないんだけれど、やっぱドキュメントがキツい。読みにくいしサンプルはとにかく欠けてるし。ゆえ、こうやって実例を紹介してくのって、Python でそれをやるより遥かに価値がある気がするよ。

2022-02-17: 些末なバージョンアップ(1) – hash

このネタ全体としてのミッションが「Go 言語のおべんきょ、兼、わりかし実用」というものであるとすれば、ここでいう「些末」は前者の意味でも後者の意味でも小さい、てこと。けれどもまぁ:

にしてもあれだ、Go言語、ライブラリに対する不満は全然ないんだけれど、やっぱドキュメントがキツい。読みにくいしサンプルはとにかく欠けてるし。ゆえ、こうやって実例を紹介してくのって、Python でそれをやるより遥かに価値がある気がするよ。

てことだからよ、おべんきょな意味合いの価値はいつでも結構高い、てことな。

「(1)」と言っているからには既に「(2)」を考えているわけだが、コードの変更量が思わず大きいんで分けただけ。「実用面」での本質は実は同じで、「コンテンツの版管理」に関係する。

「秘密の管理」を目的とするわけだけれど、その「管理しているもの」を「分散管理」したくなってくるわけよ。ワタシの場合は「外部ストレージがお亡くなりになることに備えて多重化しておきたい」わけね。多重化いうておるけれど、最も簡単な「データベースをまるごと複製」てことよ。なんてことをしてるとさ、当然「どっちがマスタだったっけ」問題が出てくる。ゆえに、「同一性の簡易な確認手段が欲しい」「作成日時・更新日時のようなメタ情報もあるべきだ」てことね。「版管理」なら履歴を持てる構造にするのか、というところまで思考は及びうるけれど、まぁそれはほかの手段、たとえば本物のリビジョン管理システムに任せるとするなら、ひとまずはこの二つで良かろう、と。

で「(1)」はこの「同一性の簡易な確認手段が欲しい」。コンテンツのハッシュダイジェストが出力できればいいよねと。コードは整理もしつつこんなだ:

  1 package main
  2 
  3 import (
  4     "os"
  5     "strings"
  6     "path/filepath"
  7     "log"
  8     "fmt"
  9     "crypto/rc4"
 10     "crypto/sha256"
 11     "github.com/AlecAivazis/survey/v2"  // go get github.com/AlecAivazis/survey/v2
 12     "database/sql"
 13     _ "modernc.org/sqlite"
 14 )
 15 
 16 /*
 17  * related to database access
 18  */
 19 type MyHimichuDB struct {
 20     db *sql.DB
 21 }
 22 
 23 func (mydb *MyHimichuDB) Open() (error) {
 24     var _0 = filepath.Base(os.Args[0])
 25     var _1 = filepath.Ext(os.Args[0])
 26     var _mybasename = _0[:len(_0) - len(_1)]
 27 
 28     wd, err := filepath.Abs(".")
 29     if err != nil {
 30         return err
 31     }
 32     dbfn := filepath.Join(wd, "." + _mybasename + ".db")
 33     _, err = os.Stat(dbfn)
 34     virgin := (err != nil)
 35     if virgin {
 36         log.Printf("creating new database '%s'...", filepath.Base(dbfn))
 37     } else {
 38         log.Printf("opening database '%s'...", filepath.Base(dbfn))
 39     }
 40     mydb.db, err = sql.Open("sqlite", dbfn)
 41     if err != nil {
 42         return err
 43     }
 44     if virgin {
 45         _, err = mydb.db.Exec(`CREATE TABLE t_orenohimitchu (
 46     key TEXT PRIMARY KEY UNIQUE NOT NULL,
 47     txt BLOB
 48 )`)
 49         if err != nil {
 50             mydb.db.Close()
 51             return err
 52         }
 53     }
 54     return err
 55 }
 56 
 57 func (mydb *MyHimichuDB) Insert(key string, txt []byte) (error) {
 58     stmt, err := mydb.db.Prepare(`INSERT INTO t_orenohimitchu VALUES (?, ?)`)
 59     if err != nil {
 60         return err
 61     }
 62     _, err = stmt.Exec(key, txt)
 63     return err
 64 }
 65 
 66 func (mydb *MyHimichuDB) Update(key string, txt []byte) (error) {
 67     stmt, err := mydb.db.Prepare(`UPDATE t_orenohimitchu SET txt = ? WHERE key = ?`)
 68     if err != nil {
 69         return err
 70     }
 71     _, err = stmt.Exec(txt, key)
 72     return err
 73 }
 74 
 75 func (mydb *MyHimichuDB) SelectOne(key string) ([]byte, error) {
 76     stmt, err := mydb.db.Prepare(`SELECT txt FROM t_orenohimitchu WHERE key = ?`)
 77     if err != nil {
 78         return nil, err
 79     }
 80     rows, err := stmt.Query(key)
 81     if err != nil {
 82         return nil, err
 83     }
 84     defer rows.Close()
 85     var txt []byte
 86     for rows.Next() {
 87         if err = rows.Scan(&txt); err != nil {
 88             return nil, err
 89         }
 90         break
 91     }
 92     return txt, nil
 93 }
 94 
 95 func (mydb *MyHimichuDB) DeleteOne(key string) (error) {
 96     stmt, err := mydb.db.Prepare(`DELETE FROM t_orenohimitchu WHERE key = ?`)
 97     _, err = stmt.Exec(key)
 98     return err
 99 }
100 
101 func (mydb *MyHimichuDB) Keys(q string) ([]string, error) {
102     stmt, err := mydb.db.Prepare(`SELECT key FROM t_orenohimitchu WHERE key LIKE ?`)
103     if err != nil {
104         return nil, err
105     }
106     rows, err := stmt.Query(q)
107     if err != nil {
108         return nil, err
109     }
110     defer rows.Close()
111     reskeys := []string{}
112     for rows.Next() {
113         var txt []byte
114         if err = rows.Scan(&txt); err != nil {
115             return nil, err
116         }
117         reskeys = append(reskeys, string(txt))
118     }
119     return reskeys, nil
120 }
121 
122 /*
123  * related to cipher
124  */
125 type MyCipher struct {
126     //c *rc4.Cipher
127     pw []byte
128 }
129 
130 func (cs *MyCipher) QueryPassword() {
131     passwd := ""
132     survey.AskOne(&survey.Password{
133         Message: "Please type your password",
134     }, &passwd)
135     p := sha256.Sum256([]byte(passwd))
136     cs.pw = p[:]
137 }
138 
139 func (cs *MyCipher) Encrypt(t string) []byte {
140     c, err := rc4.NewCipher(cs.pw)
141     if err != nil {
142         panic(err)
143     }
144     src := []byte(t)
145     dst := make([]byte, len(src))
146     c.XORKeyStream(dst, src)
147     return dst
148 }
149 
150 func (cs *MyCipher) Decrypt(ct []byte) string {
151     c, err := rc4.NewCipher(cs.pw)
152     if err != nil {
153         panic(err)
154     }
155     src := make([]byte, len(ct))
156     c.XORKeyStream(src, ct)
157     return string(src)
158 }
159 
160 /*
161  * related to ui (by "survey")
162  */
163 func edit(key, value string) (string, error) {
164     prompt := &survey.Editor{
165         Message: "Edit content!",
166         Default: value,
167         AppendDefault: true,
168         FileName: "*" + filepath.Ext(key),
169     }
170     var content string
171     err := survey.AskOne(prompt, &content)
172     return content, err
173 }
174 
175 /*
176  * toplevel menu actions
177  */
178 type MainEntry struct {
179     cipher MyCipher
180     db MyHimichuDB
181 }
182 func (ent *MainEntry) doNewAction() {
183     var newkey string
184     survey.AskOne(&survey.Input{Message: "key?"}, &newkey)
185     newval, err := edit(newkey, "")
186     if err != nil {
187         panic(err)
188     }
189     //log.Println(cipher.Decrypt(cipher.Encrypt(newval)))
190     err = ent.db.Insert(newkey, ent.cipher.Encrypt(newval))
191     if err != nil {
192         log.Println(err)
193     }
194 }
195 func (ent *MainEntry) doListAction() {
196     var pattern string
197     survey.AskOne(&survey.Input{Message: "pattern?", Default: "%"}, &pattern)
198     keys, err := ent.db.Keys(pattern)
199     if err != nil {
200         panic(err)
201     }
202     var keysel string
203     survey.AskOne(&survey.Select{Options: keys}, &keysel)
204     var action string
205     survey.AskOne(&survey.Input{Message: "action ([H]ash / [E]dit / [D]elete)?"}, &action)
206     action = strings.ToLower(action)
207     if action == "h" || action == "e" {
208         txt, err := ent.db.SelectOne(keysel)
209         if err != nil {
210             panic(err)
211         }
212         dtxt := ent.cipher.Decrypt(txt)
213         if action == "h" {
214             fmt.Printf("%x\n", sha256.Sum256([]byte(dtxt)))
215         } else {
216             newval, err := edit(keysel, dtxt)
217             if err != nil {
218                 panic(err)
219             }
220             err = ent.db.Update(keysel, ent.cipher.Encrypt(newval))
221             if err != nil {
222                 panic(err)
223             }
224         }
225     } else if action == "d" {
226         err = ent.db.DeleteOne(keysel)
227         if err != nil {
228             panic(err)
229         }
230     } else {
231         log.Println("action must be 'e' or 'd'")
232     }
233 }
234 func (ent *MainEntry) mainLoop() {
235     for {
236         var mentlsel string
237         survey.AskOne(&survey.Select{Options: []string{"list", "new", "exit"}}, &mentlsel)
238         if mentlsel == "exit" {
239             break
240         } else if mentlsel == "new" {
241             ent.doNewAction()
242         } else {
243             ent.doListAction()
244         }
245     }
246 }
247 
248 /*
249  * main
250  */
251 func main() {
252     ent := MainEntry{}
253     ent.cipher.QueryPassword()
254     err := ent.db.Open()
255     if err != nil {
256         panic(err)
257     }
258     ent.mainLoop()
259 }

別に難しいことはないよね。

「(2)」はデータベースの構造変更を伴うわけだけど、どうせならデータマイグレーションについても措置したものを書こうと思う。

2022-02-18: 些末なバージョンアップ(2) – 作成日付・更新日付

予告通りの「作成日付・更新日付」。そしてこれも予告通りだが「取り立てて何かオモロイわけではない、Goのおべんきょ的に」。ただし。まぁ先にコードだ:

  1 package main
  2 
  3 import (
  4     "os"
  5     "strings"
  6     "path/filepath"
  7     "log"
  8     "time"
  9     "fmt"
 10     "regexp"
 11     "crypto/rc4"
 12     "crypto/sha256"
 13     "github.com/AlecAivazis/survey/v2"  // go get github.com/AlecAivazis/survey/v2
 14     "database/sql"
 15     _ "modernc.org/sqlite"
 16 )
 17 
 18 /*
 19  * related to database access
 20  */
 21 type MyHimichuDB struct {
 22     db *sql.DB
 23 }
 24 
 25 func tsNowStr() string {
 26     ct := time.Now().UTC().Format(time.RFC3339)
 27     ct = string(regexp.MustCompile(`(.*)T(.*)Z.*`).ReplaceAll([]byte(ct), []byte(`$1 $2`)))
 28     return ct
 29 }
 30 
 31 func (mydb *MyHimichuDB) Open() (error) {
 32     var _0 = filepath.Base(os.Args[0])
 33     var _1 = filepath.Ext(os.Args[0])
 34     var _mybasename = _0[:len(_0) - len(_1)]
 35 
 36     wd, err := filepath.Abs(".")
 37     if err != nil {
 38         return err
 39     }
 40     dbfn := filepath.Join(wd, "." + _mybasename + ".db")
 41     _, err = os.Stat(dbfn)
 42     virgin := (err != nil)
 43     if virgin {
 44         log.Printf("creating new database '%s'...", filepath.Base(dbfn))
 45     } else {
 46         log.Printf("opening database '%s'...", filepath.Base(dbfn))
 47     }
 48     mydb.db, err = sql.Open("sqlite", dbfn)
 49     if err != nil {
 50         return err
 51     }
 52     if virgin {
 53         // NOTE: we dont use DEFAULT CURRENT_TIMESTAMP because ALTER TABLE doesn't support it.
 54         _, err = mydb.db.Exec(`CREATE TABLE t_orenohimitchu (
 55     key TEXT PRIMARY KEY UNIQUE NOT NULL,
 56     ctime TIMESTAMP NOT NULL,
 57     mtime TIMESTAMP NOT NULL,
 58     txt BLOB)`)
 59         if err != nil {
 60             mydb.db.Close()
 61             return err
 62         }
 63     } else {
 64         rows, _ := mydb.db.Query(`SELECT 1 FROM pragma_table_info('t_orenohimitchu') AS p
 65             WHERE p.name = 'ctime'`)
 66         defer rows.Close()
 67         if !rows.Next() {  // has no ctime, and mtime
 68             ct := fmt.Sprintf(`'%s'`, tsNowStr())
 69             log.Printf(
 70                 "opened database '%s' is old format, so let's migrate it...", filepath.Base(dbfn))
 71             // NOTE: we can't use CURRENT_TIMESTAMP in ALTER TABLE...
 72             _, err := mydb.db.Exec(`
 73         ALTER TABLE t_orenohimitchu 
 74         ADD COLUMN ctime TIMESTAMP NOT NULL DEFAULT ` + ct)
 75             if err != nil {
 76                 mydb.db.Close()
 77                 return err
 78             }
 79             // Fri, 18 Feb 2022 08:13:09 JST
 80             // 2022-02-17 23:28:57
 81             _, err = mydb.db.Exec(`
 82         ALTER TABLE t_orenohimitchu 
 83         ADD COLUMN mtime TIMESTAMP NOT NULL DEFAULT ` + ct)
 84             if err != nil {
 85                 mydb.db.Close()
 86                 return err
 87             }
 88         }
 89     }
 90     return err
 91 }
 92 
 93 func (mydb *MyHimichuDB) Insert(key string, txt []byte) (error) {
 94     ct := tsNowStr()
 95     stmt, err := mydb.db.Prepare(`
 96 INSERT INTO t_orenohimitchu (key, txt, ctime, mtime) VALUES (?, ?, ?, ?)`)
 97     if err != nil {
 98         return err
 99     }
100     _, err = stmt.Exec(key, txt, ct, ct)
101     return err
102 }
103 
104 func (mydb *MyHimichuDB) Update(key string, txt []byte) (error) {
105     ct := tsNowStr()
106     stmt, err := mydb.db.Prepare(`
107 UPDATE t_orenohimitchu
108 SET txt = ?, mtime = ?
109 WHERE key = ?`)
110     if err != nil {
111         return err
112     }
113     _, err = stmt.Exec(txt, ct, key)
114     return err
115 }
116 
117 func (mydb *MyHimichuDB) SelectOne(key string) ([]byte, []byte, []byte, error) {
118     stmt, err := mydb.db.Prepare(`SELECT txt, ctime, mtime FROM t_orenohimitchu WHERE key = ?`)
119     if err != nil {
120         return nil, nil, nil, err
121     }
122     rows, err := stmt.Query(key)
123     if err != nil {
124         return nil, nil, nil, err
125     }
126     defer rows.Close()
127     var txt []byte
128     var ctime []byte
129     var mtime []byte
130     for rows.Next() {
131         if err = rows.Scan(&txt, &ctime, &mtime); err != nil {
132             return nil, nil, nil, err
133         }
134         break
135     }
136     return txt, ctime, mtime, nil
137 }
138 
139 func (mydb *MyHimichuDB) DeleteOne(key string) (error) {
140     stmt, err := mydb.db.Prepare(`DELETE FROM t_orenohimitchu WHERE key = ?`)
141     _, err = stmt.Exec(key)
142     return err
143 }
144 
145 func (mydb *MyHimichuDB) Keys(q string) ([]string, error) {
146     stmt, err := mydb.db.Prepare(`SELECT key FROM t_orenohimitchu WHERE key LIKE ?`)
147     if err != nil {
148         return nil, err
149     }
150     rows, err := stmt.Query(q)
151     if err != nil {
152         return nil, err
153     }
154     defer rows.Close()
155     reskeys := []string{}
156     for rows.Next() {
157         var txt []byte
158         if err = rows.Scan(&txt); err != nil {
159             return nil, err
160         }
161         reskeys = append(reskeys, string(txt))
162     }
163     return reskeys, nil
164 }
165 
166 /*
167  * related to cipher
168  */
169 type MyCipher struct {
170     //c *rc4.Cipher
171     pw []byte
172 }
173 
174 func (cs *MyCipher) QueryPassword() {
175     passwd := ""
176     survey.AskOne(&survey.Password{
177         Message: "Please type your password",
178     }, &passwd)
179     p := sha256.Sum256([]byte(passwd))
180     cs.pw = p[:]
181 }
182 
183 func (cs *MyCipher) Encrypt(t string) []byte {
184     c, err := rc4.NewCipher(cs.pw)
185     if err != nil {
186         panic(err)
187     }
188     src := []byte(t)
189     dst := make([]byte, len(src))
190     c.XORKeyStream(dst, src)
191     return dst
192 }
193 
194 func (cs *MyCipher) Decrypt(ct []byte) string {
195     c, err := rc4.NewCipher(cs.pw)
196     if err != nil {
197         panic(err)
198     }
199     src := make([]byte, len(ct))
200     c.XORKeyStream(src, ct)
201     return string(src)
202 }
203 
204 /*
205  * related to ui (by "survey")
206  */
207 func edit(key, value string) (string, error) {
208     prompt := &survey.Editor{
209         Message: "Edit content!",
210         Default: value,
211         AppendDefault: true,
212         FileName: "*" + filepath.Ext(key),
213     }
214     var content string
215     err := survey.AskOne(prompt, &content)
216     return content, err
217 }
218 
219 /*
220  * toplevel menu actions
221  */
222 type MainEntry struct {
223     cipher MyCipher
224     db MyHimichuDB
225 }
226 func (ent *MainEntry) doNewAction() {
227     var newkey string
228     survey.AskOne(&survey.Input{Message: "key?"}, &newkey)
229     newval, err := edit(newkey, "")
230     if err != nil {
231         panic(err)
232     }
233     //log.Println(cipher.Decrypt(cipher.Encrypt(newval)))
234     err = ent.db.Insert(newkey, ent.cipher.Encrypt(newval))
235     if err != nil {
236         log.Println(err)
237     }
238 }
239 func (ent *MainEntry) doListAction() {
240     var pattern string
241     survey.AskOne(&survey.Input{Message: "pattern?", Default: "%"}, &pattern)
242     keys, err := ent.db.Keys(pattern)
243     if err != nil {
244         panic(err)
245     }
246     var keysel string
247     survey.AskOne(&survey.Select{Options: keys}, &keysel)
248     var action string
249     survey.AskOne(&survey.Input{Message: "action ([P]roperties / [E]dit / [D]elete)?"}, &action)
250     action = strings.ToLower(action)
251     if action == "p" || action == "e" {
252         txt, ctime, mtime, err := ent.db.SelectOne(keysel)
253         if err != nil {
254             panic(err)
255         }
256         dtxt := ent.cipher.Decrypt(txt)
257         if action == "p" {
258             fmt.Printf(`NAME    : %s
259 DIGEST  : %x
260 CREATED : %s
261 MODIFIED: %s
262 `, keysel, sha256.Sum256([]byte(dtxt)), ctime, mtime)
263         } else {
264             newval, err := edit(keysel, dtxt)
265             if err != nil {
266                 panic(err)
267             }
268             err = ent.db.Update(keysel, ent.cipher.Encrypt(newval))
269             if err != nil {
270                 panic(err)
271             }
272         }
273     } else if action == "d" {
274         err = ent.db.DeleteOne(keysel)
275         if err != nil {
276             panic(err)
277         }
278     } else {
279         log.Println("unknown action")
280     }
281 }
282 func (ent *MainEntry) mainLoop() {
283     for {
284         var mentlsel string
285         survey.AskOne(&survey.Select{Options: []string{"list", "new", "exit"}}, &mentlsel)
286         if mentlsel == "exit" {
287             break
288         } else if mentlsel == "new" {
289             ent.doNewAction()
290         } else {
291             ent.doListAction()
292         }
293     }
294 }
295 
296 /*
297  * main
298  */
299 func main() {
300     ent := MainEntry{}
301     ent.cipher.QueryPassword()
302     err := ent.db.Open()
303     if err != nil {
304         panic(err)
305     }
306     ent.mainLoop()
307 }

(1)で予告した通り「どうせならデータマイグレーションについても考慮したもの」としたわけだが、それが理由でややこしいことに気付いた。今回初めて知ったのだけれど、「CURRENT_TIMESTAMP」(などの non constant value)を ALTER TABLE で使えないのね、CREATE TABLE 時は使えるのに。まぁ後から追加する列のデフォルトが可変値とすると「既存のレコード」で困るのはわかるんだけどさ…、そんなん「ALTER TABLE 実行時」の値を取ればいいじゃん、と思うんだけどねぇ…、まぁとにかくそれが SQLite の持つ制約なので…、そう、TIMESTAMP カラムで DEFAULT を使うのを諦めた、てことね。解せないけどね。ALTER TABLE を踏んだ場合とそうでない場合でプログラムを変えるなんてことは馬鹿げているので、ゆえに「INSERT 時に ctime, mtime 指定を省略する」方を諦めた、てことな。

てわけで、一応 Go 初心者のワタシとして全く実のある経験とはならなかったというわけではないけれど、それでもやっぱり「Go のおべんきょ」としてはあまりオイシくはなくて、けれども「データマイグレーション」を考えたおかげで、初体験の SQLite の制限に気付けた、というのは、経験としてはおいしかった、てことで夜露死苦。

2022-02-18: 些末なバージョンアップ(3) – 圧縮

SQLite の制限については調べていないけれど、きっと列のサイズの上限があるに違いない。ワレ的なユースケースとしては、一つのレコードにそうまで巨大なテキストを管理することは想定はしないけれど、それでも多少は大きなメモを持ちたいこともあるに違いない、であれば、その「あるかもしれない上限」の緩和のために、圧縮しちまえ、てことを考えてた。

これは(2)の直後に考えたんだけれど、(2)同様に何か補助情報とともに圧縮するしかないかなと思ってたのね。「compress」という bool 列を作って、それの有無で振る舞いを変えよう、みたいなことね。けどよく考えたら、gzip などのコンプレッッサーって、圧縮結果にマジックナンバーを書き込むわけよ、だから圧縮してれば区別出来る。なんだ、SQLite カラム追加を伴わないで出来るじゃんか、と。

ちぅわけで:

  1 package main
  2 
  3 import (
  4     "os"
  5     "io"
  6     "bytes"
  7     "strings"
  8     "path/filepath"
  9     "log"
 10     "time"
 11     "fmt"
 12     "regexp"
 13     "crypto/rc4"
 14     "crypto/sha256"
 15     "compress/gzip"
 16     "github.com/AlecAivazis/survey/v2"  // go get github.com/AlecAivazis/survey/v2
 17     "database/sql"
 18     _ "modernc.org/sqlite"
 19 )
 20 
 21 /*
 22  * related to database access
 23  */
 24 type MyHimichuDB struct {
 25     db *sql.DB
 26 }
 27 
 28 func tsNowStr() string {
 29     ct := time.Now().UTC().Format(time.RFC3339)
 30     ct = string(regexp.MustCompile(`(.*)T(.*)Z.*`).ReplaceAll([]byte(ct), []byte(`$1 $2`)))
 31     return ct
 32 }
 33 
 34 func (mydb *MyHimichuDB) Open() (error) {
 35     var _0 = filepath.Base(os.Args[0])
 36     var _1 = filepath.Ext(os.Args[0])
 37     var _mybasename = _0[:len(_0) - len(_1)]
 38 
 39     wd, err := filepath.Abs(".")
 40     if err != nil {
 41         return err
 42     }
 43     dbfn := filepath.Join(wd, "." + _mybasename + ".db")
 44     _, err = os.Stat(dbfn)
 45     virgin := (err != nil)
 46     if virgin {
 47         log.Printf("creating new database '%s'...", filepath.Base(dbfn))
 48     } else {
 49         log.Printf("opening database '%s'...", filepath.Base(dbfn))
 50     }
 51     mydb.db, err = sql.Open("sqlite", dbfn)
 52     if err != nil {
 53         return err
 54     }
 55     if virgin {
 56         // NOTE: we dont use DEFAULT CURRENT_TIMESTAMP because ALTER TABLE doesn't support it.
 57         _, err = mydb.db.Exec(`CREATE TABLE t_orenohimitchu (
 58     key TEXT PRIMARY KEY UNIQUE NOT NULL,
 59     ctime TIMESTAMP NOT NULL,
 60     mtime TIMESTAMP NOT NULL,
 61     txt BLOB)`)
 62         if err != nil {
 63             mydb.db.Close()
 64             return err
 65         }
 66     } else {
 67         rows, _ := mydb.db.Query(`SELECT 1 FROM pragma_table_info('t_orenohimitchu') AS p
 68             WHERE p.name = 'ctime'`)
 69         defer rows.Close()
 70         if !rows.Next() {  // has no ctime, and mtime
 71             ct := fmt.Sprintf(`'%s'`, tsNowStr())
 72             log.Printf(
 73                 "opened database '%s' is old format, so let's migrate it...", filepath.Base(dbfn))
 74             // NOTE: we can't use CURRENT_TIMESTAMP in ALTER TABLE...
 75             _, err := mydb.db.Exec(`
 76         ALTER TABLE t_orenohimitchu 
 77         ADD COLUMN ctime TIMESTAMP NOT NULL DEFAULT ` + ct)
 78             if err != nil {
 79                 mydb.db.Close()
 80                 return err
 81             }
 82             // Fri, 18 Feb 2022 08:13:09 JST
 83             // 2022-02-17 23:28:57
 84             _, err = mydb.db.Exec(`
 85         ALTER TABLE t_orenohimitchu 
 86         ADD COLUMN mtime TIMESTAMP NOT NULL DEFAULT ` + ct)
 87             if err != nil {
 88                 mydb.db.Close()
 89                 return err
 90             }
 91         }
 92     }
 93     return err
 94 }
 95 
 96 func (mydb *MyHimichuDB) Insert(key string, txt []byte) (error) {
 97     ct := tsNowStr()
 98     stmt, err := mydb.db.Prepare(`
 99 INSERT INTO t_orenohimitchu (key, txt, ctime, mtime) VALUES (?, ?, ?, ?)`)
100     if err != nil {
101         return err
102     }
103     _, err = stmt.Exec(key, txt, ct, ct)
104     return err
105 }
106 
107 func (mydb *MyHimichuDB) Update(key string, txt []byte) (error) {
108     ct := tsNowStr()
109     stmt, err := mydb.db.Prepare(`
110 UPDATE t_orenohimitchu
111 SET txt = ?, mtime = ?
112 WHERE key = ?`)
113     if err != nil {
114         return err
115     }
116     _, err = stmt.Exec(txt, ct, key)
117     return err
118 }
119 
120 func (mydb *MyHimichuDB) SelectOne(key string) ([]byte, []byte, []byte, error) {
121     stmt, err := mydb.db.Prepare(`SELECT txt, ctime, mtime FROM t_orenohimitchu WHERE key = ?`)
122     if err != nil {
123         return nil, nil, nil, err
124     }
125     rows, err := stmt.Query(key)
126     if err != nil {
127         return nil, nil, nil, err
128     }
129     defer rows.Close()
130     var txt []byte
131     var ctime []byte
132     var mtime []byte
133     for rows.Next() {
134         if err = rows.Scan(&txt, &ctime, &mtime); err != nil {
135             return nil, nil, nil, err
136         }
137         break
138     }
139     return txt, ctime, mtime, nil
140 }
141 
142 func (mydb *MyHimichuDB) DeleteOne(key string) (error) {
143     stmt, err := mydb.db.Prepare(`DELETE FROM t_orenohimitchu WHERE key = ?`)
144     _, err = stmt.Exec(key)
145     return err
146 }
147 
148 func (mydb *MyHimichuDB) Keys(q string) ([]string, error) {
149     stmt, err := mydb.db.Prepare(`SELECT key FROM t_orenohimitchu WHERE key LIKE ?`)
150     if err != nil {
151         return nil, err
152     }
153     rows, err := stmt.Query(q)
154     if err != nil {
155         return nil, err
156     }
157     defer rows.Close()
158     reskeys := []string{}
159     for rows.Next() {
160         var txt []byte
161         if err = rows.Scan(&txt); err != nil {
162             return nil, err
163         }
164         reskeys = append(reskeys, string(txt))
165     }
166     return reskeys, nil
167 }
168 
169 /*
170  * related to cipher
171  */
172 type MyCipher struct {
173     //c *rc4.Cipher
174     pw []byte
175 }
176 
177 func (cs *MyCipher) QueryPassword() {
178     passwd := ""
179     survey.AskOne(&survey.Password{
180         Message: "Please type your password",
181     }, &passwd)
182     p := sha256.Sum256([]byte(passwd))
183     cs.pw = p[:]
184 }
185 
186 func (cs *MyCipher) Encrypt(t []byte) []byte {
187     c, err := rc4.NewCipher(cs.pw)
188     if err != nil {
189         panic(err)
190     }
191     src := []byte(t)
192     dst := make([]byte, len(src))
193     c.XORKeyStream(dst, src)
194     return dst
195 }
196 
197 func (cs *MyCipher) Decrypt(ct []byte) []byte {
198     c, err := rc4.NewCipher(cs.pw)
199     if err != nil {
200         panic(err)
201     }
202     src := make([]byte, len(ct))
203     c.XORKeyStream(src, ct)
204     return src
205 }
206 
207 /*
208  * related to compression
209  */
210 func bytesUncompress(blob []byte) []byte {
211     r := bytes.NewReader(blob)
212     gzr, err := gzip.NewReader(r)
213     if err != nil {
214         return blob  // maybe uncompressed, or corrupted (maybe because the password was wrong)
215     }
216     defer gzr.Close()
217     b := new(bytes.Buffer)
218     _, err = io.Copy(b, gzr)
219     if err != nil {
220         return blob  // what's goin on? i dont know...
221     }
222     return b.Bytes()
223 }
224 func bytesCompress(blob []byte) []byte {
225     b := new(bytes.Buffer)
226     w, _ := gzip.NewWriterLevel(b, 9)
227     w.Write(blob)
228     w.Close()
229     if len(blob) > len(b.Bytes()) {
230         return b.Bytes()
231     }
232     return blob
233 }
234 
235 /*
236  * related to ui (by "survey")
237  */
238 func edit(key, value string) (string, error) {
239     prompt := &survey.Editor{
240         Message: "Edit content!",
241         Default: value,
242         AppendDefault: true,
243         FileName: "*" + filepath.Ext(key),
244     }
245     var content string
246     err := survey.AskOne(prompt, &content)
247     return content, err
248 }
249 
250 /*
251  * toplevel menu actions
252  */
253 type MainEntry struct {
254     cipher MyCipher
255     db MyHimichuDB
256 }
257 func (ent *MainEntry) doNewAction() {
258     var newkey string
259     for {
260         survey.AskOne(&survey.Input{Message: "key?"}, &newkey)
261         if newkey != "" {
262             break
263         }
264     }
265     newval, err := edit(newkey, "")
266     if err != nil {
267         panic(err)
268     }
269     //log.Println(cipher.Decrypt(cipher.Encrypt(newval)))
270     err = ent.db.Insert(
271         newkey, ent.cipher.Encrypt(bytesCompress([]byte(newval))))
272     if err != nil {
273         log.Println(err)
274     }
275 }
276 func (ent *MainEntry) doListAction() {
277     var pattern string
278     survey.AskOne(&survey.Input{Message: "pattern?", Default: "%"}, &pattern)
279     keys, err := ent.db.Keys(pattern)
280     if err != nil {
281         panic(err)
282     }
283     var keysel string
284     survey.AskOne(&survey.Select{Options: keys}, &keysel)
285     var action string
286     survey.AskOne(&survey.Input{Message: "action ([P]roperties / [E]dit / [D]elete)?"}, &action)
287     action = strings.ToLower(action)
288     if action == "p" || action == "e" {
289         blob, ctime, mtime, err := ent.db.SelectOne(keysel)
290         if err != nil {
291             panic(err)
292         }
293         dtxt := bytesUncompress(ent.cipher.Decrypt(blob))
294         if action == "p" {
295             fmt.Printf(`NAME    : %s
296 DIGEST  : %x
297 CREATED : %s
298 MODIFIED: %s
299 `, keysel, sha256.Sum256([]byte(dtxt)), ctime, mtime)
300         } else {
301             newval, err := edit(keysel, string(dtxt))
302             if err != nil {
303                 panic(err)
304             }
305             err = ent.db.Update(
306                 keysel,
307                 ent.cipher.Encrypt(bytesCompress([]byte(newval))))
308             if err != nil {
309                 panic(err)
310             }
311         }
312     } else if action == "d" {
313         err = ent.db.DeleteOne(keysel)
314         if err != nil {
315             panic(err)
316         }
317     } else {
318         log.Println("unknown action")
319     }
320 }
321 func (ent *MainEntry) mainLoop() {
322     for {
323         var mentlsel string
324         survey.AskOne(&survey.Select{Options: []string{"list", "new", "exit"}}, &mentlsel)
325         if mentlsel == "exit" {
326             break
327         } else if mentlsel == "new" {
328             ent.doNewAction()
329         } else {
330             ent.doListAction()
331         }
332     }
333 }
334 
335 /*
336  * main
337  */
338 func main() {
339     ent := MainEntry{}
340     ent.cipher.QueryPassword()
341     err := ent.db.Open()
342     if err != nil {
343         panic(err)
344     }
345     ent.mainLoop()
346 }

今回も「Go の鍛錬」以外の意味はあまりない拡張とは言える。ただ、テキスト相手の gzip 圧縮って、最悪でも70%くらいにはなるからね、気休め以上の効果はあるとは思うわ。

2022-03-19追記: zenity てみた

先に tcl/tk ネタの中にしのばせたんだけれど、「Go でのクロスプラットフォーム GUI」について一つ見つけた。リンク先の方にも書いたけれど「コモンコントロールしか提供しない」という非常に割り切ったパッケージで、「ほとんど何も出来ない」という見方も出来るけれど、「それしか出来なくても十分」なこともある。

リンク先にも書いたが改めて説明すると、「Zenity」というオリジナルのアプリケーションはこれは GNOME プロジェクトの一味らしい。tcl よりも遥かに最小限で、「バッチへの GUI 組み込み」だけを狙ったもんだろうと思う。そして、これを「Windows/MacOS でも使えるように」みたいな動機だと思うんだが、go でそれを書いた人がいる、てこと。AlecAivazis/survey の品揃えと比較すると、無論「Edit」の差は大きいは大きいんだけれど、そもそも専門としての「メニュー」はなくて、まぁ「zenity.List」で代替するしかないのね、そこがまぁ惜しいことは惜しい。ケド、はっきりいってこれだけでも十分なアプリケーションって、結構多いと思う。パスワード入力だけ対話的に入力させる必要がある、なんての、結構ありうると思うのよ。

今ワタシがここでやってるヤツを「全部 zenity に乗り換える」ことは、なので出来ないわけなんだけれど、部分的に置き換えてみようかなと:

  1 package main
  2 
  3 import (
  4     "os"
  5     "io"
  6     "bytes"
  7     "strings"
  8     "path/filepath"
  9     "log"
 10     "time"
 11     "fmt"
 12     "regexp"
 13     "crypto/rc4"
 14     "crypto/sha256"
 15     "compress/gzip"
 16     "github.com/AlecAivazis/survey/v2"  // go get github.com/AlecAivazis/survey/v2
 17     "database/sql"
 18     _ "modernc.org/sqlite"
 19     "github.com/ncruces/zenity"
 20 )
 21 
 22 /*
 23  * related to database access
 24  */
 25 type MyHimichuDB struct {
 26     db *sql.DB
 27 }
 28 
 29 func tsNowStr() string {
 30     ct := time.Now().UTC().Format(time.RFC3339)
 31     ct = string(regexp.MustCompile(`(.*)T(.*)Z.*`).ReplaceAll([]byte(ct), []byte(`$1 $2`)))
 32     return ct
 33 }
 34 
 35 func (mydb *MyHimichuDB) Open() (error) {
 36     var _0 = filepath.Base(os.Args[0])
 37     var _1 = filepath.Ext(os.Args[0])
 38     var _mybasename = _0[:len(_0) - len(_1)]
 39 
 40     wd, err := filepath.Abs(".")
 41     if err != nil {
 42         return err
 43     }
 44     dbfn := filepath.Join(wd, "." + _mybasename + ".db")
 45     _, err = os.Stat(dbfn)
 46     virgin := (err != nil)
 47     if virgin {
 48         log.Printf("creating new database '%s'...", filepath.Base(dbfn))
 49     } else {
 50         log.Printf("opening database '%s'...", filepath.Base(dbfn))
 51     }
 52     mydb.db, err = sql.Open("sqlite", dbfn)
 53     if err != nil {
 54         return err
 55     }
 56     if virgin {
 57         // NOTE: we dont use DEFAULT CURRENT_TIMESTAMP because ALTER TABLE doesn't support it.
 58         _, err = mydb.db.Exec(`CREATE TABLE t_orenohimitchu (
 59     key TEXT PRIMARY KEY UNIQUE NOT NULL,
 60     ctime TIMESTAMP NOT NULL,
 61     mtime TIMESTAMP NOT NULL,
 62     txt BLOB)`)
 63         if err != nil {
 64             mydb.db.Close()
 65             return err
 66         }
 67     } else {
 68         rows, _ := mydb.db.Query(`SELECT 1 FROM pragma_table_info('t_orenohimitchu') AS p
 69             WHERE p.name = 'ctime'`)
 70         defer rows.Close()
 71         if !rows.Next() {  // has no ctime, and mtime
 72             ct := fmt.Sprintf(`'%s'`, tsNowStr())
 73             log.Printf(
 74                 "opened database '%s' is old format, so let's migrate it...", filepath.Base(dbfn))
 75             // NOTE: we can't use CURRENT_TIMESTAMP in ALTER TABLE...
 76             _, err := mydb.db.Exec(`
 77         ALTER TABLE t_orenohimitchu 
 78         ADD COLUMN ctime TIMESTAMP NOT NULL DEFAULT ` + ct)
 79             if err != nil {
 80                 mydb.db.Close()
 81                 return err
 82             }
 83             // Fri, 18 Feb 2022 08:13:09 JST
 84             // 2022-02-17 23:28:57
 85             _, err = mydb.db.Exec(`
 86         ALTER TABLE t_orenohimitchu 
 87         ADD COLUMN mtime TIMESTAMP NOT NULL DEFAULT ` + ct)
 88             if err != nil {
 89                 mydb.db.Close()
 90                 return err
 91             }
 92         }
 93     }
 94     return err
 95 }
 96 
 97 func (mydb *MyHimichuDB) Insert(key string, txt []byte) (error) {
 98     ct := tsNowStr()
 99     stmt, err := mydb.db.Prepare(`
100 INSERT INTO t_orenohimitchu (key, txt, ctime, mtime) VALUES (?, ?, ?, ?)`)
101     if err != nil {
102         return err
103     }
104     _, err = stmt.Exec(key, txt, ct, ct)
105     return err
106 }
107 
108 func (mydb *MyHimichuDB) Update(key string, txt []byte) (error) {
109     ct := tsNowStr()
110     stmt, err := mydb.db.Prepare(`
111 UPDATE t_orenohimitchu
112 SET txt = ?, mtime = ?
113 WHERE key = ?`)
114     if err != nil {
115         return err
116     }
117     _, err = stmt.Exec(txt, ct, key)
118     return err
119 }
120 
121 func (mydb *MyHimichuDB) SelectOne(key string) ([]byte, []byte, []byte, error) {
122     stmt, err := mydb.db.Prepare(`SELECT txt, ctime, mtime FROM t_orenohimitchu WHERE key = ?`)
123     if err != nil {
124         return nil, nil, nil, err
125     }
126     rows, err := stmt.Query(key)
127     if err != nil {
128         return nil, nil, nil, err
129     }
130     defer rows.Close()
131     var txt []byte
132     var ctime []byte
133     var mtime []byte
134     for rows.Next() {
135         if err = rows.Scan(&txt, &ctime, &mtime); err != nil {
136             return nil, nil, nil, err
137         }
138         break
139     }
140     return txt, ctime, mtime, nil
141 }
142 
143 func (mydb *MyHimichuDB) DeleteOne(key string) (error) {
144     stmt, err := mydb.db.Prepare(`DELETE FROM t_orenohimitchu WHERE key = ?`)
145     _, err = stmt.Exec(key)
146     return err
147 }
148 
149 func (mydb *MyHimichuDB) Keys(q string) ([]string, error) {
150     stmt, err := mydb.db.Prepare(`SELECT key FROM t_orenohimitchu WHERE key LIKE ?`)
151     if err != nil {
152         return nil, err
153     }
154     rows, err := stmt.Query(q)
155     if err != nil {
156         return nil, err
157     }
158     defer rows.Close()
159     reskeys := []string{}
160     for rows.Next() {
161         var txt []byte
162         if err = rows.Scan(&txt); err != nil {
163             return nil, err
164         }
165         reskeys = append(reskeys, string(txt))
166     }
167     return reskeys, nil
168 }
169 
170 /*
171  * related to cipher
172  */
173 type MyCipher struct {
174     //c *rc4.Cipher
175     pw []byte
176 }
177 
178 func (cs *MyCipher) QueryPassword() {
179     passwd := ""
180     _, passwd, _ = zenity.Password()
181     /*
182     survey.AskOne(&survey.Password{
183         Message: "Please type your password",
184     }, &passwd)
185     */
186     p := sha256.Sum256([]byte(passwd))
187     cs.pw = p[:]
188 }
189 
190 func (cs *MyCipher) Encrypt(t []byte) []byte {
191     c, err := rc4.NewCipher(cs.pw)
192     if err != nil {
193         panic(err)
194     }
195     src := []byte(t)
196     dst := make([]byte, len(src))
197     c.XORKeyStream(dst, src)
198     return dst
199 }
200 
201 func (cs *MyCipher) Decrypt(ct []byte) []byte {
202     c, err := rc4.NewCipher(cs.pw)
203     if err != nil {
204         panic(err)
205     }
206     src := make([]byte, len(ct))
207     c.XORKeyStream(src, ct)
208     return src
209 }
210 
211 /*
212  * related to compression
213  */
214 func bytesUncompress(blob []byte) []byte {
215     r := bytes.NewReader(blob)
216     gzr, err := gzip.NewReader(r)
217     if err != nil {
218         return blob  // maybe uncompressed, or corrupted (maybe because the password was wrong)
219     }
220     defer gzr.Close()
221     b := new(bytes.Buffer)
222     _, err = io.Copy(b, gzr)
223     if err != nil {
224         return blob  // what's goin on? i dont know...
225     }
226     return b.Bytes()
227 }
228 func bytesCompress(blob []byte) []byte {
229     b := new(bytes.Buffer)
230     w, _ := gzip.NewWriterLevel(b, 9)
231     w.Write(blob)
232     w.Close()
233     if len(blob) > len(b.Bytes()) {
234         return b.Bytes()
235     }
236     return blob
237 }
238 
239 /*
240  * related to ui (by "survey")
241  */
242 func edit(key, value string) (string, error) {
243     prompt := &survey.Editor{
244         Message: "Edit content!",
245         Default: value,
246         AppendDefault: true,
247         FileName: "*" + filepath.Ext(key),
248     }
249     var content string
250     err := survey.AskOne(prompt, &content)
251     return content, err
252 }
253 
254 /*
255  * toplevel menu actions
256  */
257 type MainEntry struct {
258     cipher MyCipher
259     db MyHimichuDB
260 }
261 func (ent *MainEntry) doNewAction() {
262     var newkey string
263     for {
264         survey.AskOne(&survey.Input{Message: "key?"}, &newkey)
265         if newkey != "" {
266             break
267         }
268     }
269     newval, err := edit(newkey, "")
270     if err != nil {
271         panic(err)
272     }
273     //log.Println(cipher.Decrypt(cipher.Encrypt(newval)))
274     err = ent.db.Insert(
275         newkey, ent.cipher.Encrypt(bytesCompress([]byte(newval))))
276     if err != nil {
277         log.Println(err)
278     }
279 }
280 func (ent *MainEntry) doListAction() {
281     var pattern string
282     /*
283     survey.AskOne(&survey.Input{Message: "pattern?", Default: "%"}, &pattern)
284     */
285     pattern, _ = zenity.Entry("pattern?", []zenity.Option{zenity.EntryText("%")}...)
286     keys, err := ent.db.Keys(pattern)
287     if err != nil {
288         panic(err)
289     }
290     var keysel string
291     /*
292     survey.AskOne(&survey.Select{Options: keys}, &keysel)
293     */
294     keysel, _ = zenity.List("select one...", keys)
295     var action string
296     survey.AskOne(&survey.Input{Message: "action to `" + keysel + "` ([P]roperties / [E]dit / [D]elete)?"}, &action)
297     action = strings.ToLower(action)
298     if action == "p" || action == "e" {
299         blob, ctime, mtime, err := ent.db.SelectOne(keysel)
300         if err != nil {
301             panic(err)
302         }
303         dtxt := bytesUncompress(ent.cipher.Decrypt(blob))
304         if action == "p" {
305             fmt.Printf(`NAME    : %s
306 DIGEST  : %x
307 CREATED : %s
308 MODIFIED: %s
309 `, keysel, sha256.Sum256([]byte(dtxt)), ctime, mtime)
310         } else {
311             newval, err := edit(keysel, string(dtxt))
312             if err != nil {
313                 panic(err)
314             }
315             err = ent.db.Update(
316                 keysel,
317                 ent.cipher.Encrypt(bytesCompress([]byte(newval))))
318             if err != nil {
319                 panic(err)
320             }
321         }
322     } else if action == "d" {
323         err = ent.db.DeleteOne(keysel)
324         if err != nil {
325             panic(err)
326         }
327     } else {
328         log.Println("unknown action")
329     }
330 }
331 func (ent *MainEntry) mainLoop() {
332     for {
333         var mentlsel string
334         survey.AskOne(&survey.Select{Options: []string{"list", "new", "exit"}}, &mentlsel)
335         if mentlsel == "exit" {
336             break
337         } else if mentlsel == "new" {
338             ent.doNewAction()
339         } else {
340             ent.doListAction()
341         }
342     }
343 }
344 
345 /*
346  * main
347  */
348 func main() {
349     ent := MainEntry{}
350     ent.cipher.QueryPassword()
351     err := ent.db.Open()
352     if err != nil {
353         panic(err)
354     }
355     ent.mainLoop()
356 }

Unix 系では「本物の Zenity を別途インストールする必要がある」のは難点といえば難点なんだけれど、まぁ許容範囲だろう、このくらいなら。

なお「Unix 系」には「WSL (Windows Subsystem for Windows)」も含まれる。Zenity は「linux GUI」にあたるわけなので、「WSL2 on Windows 10」では動かない。Windows 10 を使っているなら Windows 11 にアップグレードして、一部ドライバと wsl もアップデートする必要がある。ここらの事情は これとかこれとかこれに書いたので、是非読んでみてくれぃ。

2022-03-20 21時追記: AlecAivazis/survey を選んだ理由が「原因」でほぼ AlecAivazis/survey を捨てるに等しい行為 – #313 が me, too

#313 なんて issue が挙がっていて、これがみーつーである。

「Editor がいいよねっ!」が AlecAivazis/survey で嬉しい一つだったはずなのだが、本気用途で使ってると、この Editor の機能不足がワタシには結構致命的になってた。というのも「本物の ANSI ターミナル」であることにかなり依存していて、emacs から直接起動するのととっても相性が悪いのね、AlecAivazis/survey 全体が。でも Editor 以外は「zenity に置き換え」れば済むのだけど、zenity には Editor 相当の機能がないので、どうしても使いたい。そして、Editor は survey.Ask/survey.AskOne とともに使うことが前提になっているので、「エディターを起動するのかい?」という質問を省略できない。この質問が TERM=emacs で正しく動作しない。

これはもう「欲しい部分(エディタ起動部分)を丸パクリ」しかないかなぁと。てわけで、まぁちょっとムゴいけれどこんな具合:

  1 package main
  2 
  3 import (
  4     "os"
  5     "os/exec"
  6     "runtime"
  7     "io"
  8     "io/ioutil"
  9     "bytes"
 10     "strings"
 11     "path/filepath"
 12     "log"
 13     "time"
 14     "fmt"
 15     "regexp"
 16     "math"
 17     "crypto/rc4"
 18     "crypto/sha256"
 19     "compress/gzip"
 20     "github.com/AlecAivazis/survey/v2"  // go get github.com/AlecAivazis/survey/v2
 21     "database/sql"
 22     _ "modernc.org/sqlite"
 23     "github.com/ncruces/zenity"
 24     "github.com/ogier/pflag"
 25     shellquote "github.com/kballard/go-shellquote"
 26 )
 27 
 28 /*
 29  * global flags that you can control via commandline options
 30  */
 31 var (
 32     use_zenity *bool
 33 
 34     /* copied implementation from survey/editor.go to invoke editor - BEGIN */
 35     bom = []byte{0xef, 0xbb, 0xbf}
 36     editor = "vim"
 37     /* copied implementation from survey/editor.go to invoke editor - END */
 38 )
 39 
 40 /*
 41  * init
 42  */
 43 func init() {
 44     /* copied implementation from survey/editor.go to invoke editor - BEGIN */
 45     if runtime.GOOS == "windows" {
 46         editor = "notepad"
 47     }
 48     if v := os.Getenv("VISUAL"); v != "" {
 49         editor = v
 50     } else if e := os.Getenv("EDITOR"); e != "" {
 51         editor = e
 52     }
 53     /* copied implementation from survey/editor.go to invoke editor - END */
 54 
 55     use_zenity = pflag.BoolP("use_zenity", "z", false, "use `zenity`")
 56     ousage := pflag.Usage
 57     pflag.Usage = func() {
 58         ousage()
 59         os.Exit(1)
 60     }
 61     pflag.Parse()
 62 }
 63 
 64 /*
 65  * some utilities
 66  */
 67 func compstr(lhs, rhs string) bool {
 68     minlen := int(math.Min(float64(len(lhs)), float64(len(rhs))))
 69     if minlen == 0 {
 70         return false
 71     }
 72     return strings.ToLower(lhs)[:minlen] == strings.ToLower(rhs)[:minlen]
 73 }
 74 
 75 /*
 76  * related to database access
 77  */
 78 type MyHimichuDB struct {
 79     db *sql.DB
 80 }
 81 
 82 func tsNowStr() string {
 83     ct := time.Now().UTC().Format(time.RFC3339)
 84     ct = string(regexp.MustCompile(`(.*)T(.*)Z.*`).ReplaceAll([]byte(ct), []byte(`$1 $2`)))
 85     return ct
 86 }
 87 
 88 func (mydb *MyHimichuDB) Open() (error) {
 89     var _0 = filepath.Base(os.Args[0])
 90     var _1 = filepath.Ext(os.Args[0])
 91     var _mybasename = _0[:len(_0) - len(_1)]
 92 
 93     wd, err := filepath.Abs(".")
 94     if err != nil {
 95         return err
 96     }
 97     dbfn := filepath.Join(wd, "." + _mybasename + ".db")
 98     _, err = os.Stat(dbfn)
 99     virgin := (err != nil)
100     if virgin {
101         log.Printf("creating new database '%s'...", filepath.Base(dbfn))
102     } else {
103         log.Printf("opening database '%s'...", filepath.Base(dbfn))
104     }
105     mydb.db, err = sql.Open("sqlite", dbfn)
106     if err != nil {
107         return err
108     }
109     if virgin {
110         // NOTE: we dont use DEFAULT CURRENT_TIMESTAMP because ALTER TABLE doesn't support it.
111         _, err = mydb.db.Exec(`CREATE TABLE t_orenohimitchu (
112     key TEXT PRIMARY KEY UNIQUE NOT NULL,
113     ctime TIMESTAMP NOT NULL,
114     mtime TIMESTAMP NOT NULL,
115     txt BLOB)`)
116         if err != nil {
117             mydb.db.Close()
118             return err
119         }
120     } else {
121         rows, _ := mydb.db.Query(`SELECT 1 FROM pragma_table_info('t_orenohimitchu') AS p
122             WHERE p.name = 'ctime'`)
123         defer rows.Close()
124         if !rows.Next() {  // has no ctime, and mtime
125             ct := fmt.Sprintf(`'%s'`, tsNowStr())
126             log.Printf(
127                 "opened database '%s' is old format, so let's migrate it...", filepath.Base(dbfn))
128             // NOTE: we can't use CURRENT_TIMESTAMP in ALTER TABLE...
129             _, err := mydb.db.Exec(`
130         ALTER TABLE t_orenohimitchu 
131         ADD COLUMN ctime TIMESTAMP NOT NULL DEFAULT ` + ct)
132             if err != nil {
133                 mydb.db.Close()
134                 return err
135             }
136             // Fri, 18 Feb 2022 08:13:09 JST
137             // 2022-02-17 23:28:57
138             _, err = mydb.db.Exec(`
139         ALTER TABLE t_orenohimitchu 
140         ADD COLUMN mtime TIMESTAMP NOT NULL DEFAULT ` + ct)
141             if err != nil {
142                 mydb.db.Close()
143                 return err
144             }
145         }
146     }
147     return err
148 }
149 
150 func (mydb *MyHimichuDB) Insert(key string, txt []byte) (error) {
151     ct := tsNowStr()
152     stmt, err := mydb.db.Prepare(`
153 INSERT INTO t_orenohimitchu (key, txt, ctime, mtime) VALUES (?, ?, ?, ?)`)
154     if err != nil {
155         return err
156     }
157     _, err = stmt.Exec(key, txt, ct, ct)
158     return err
159 }
160 
161 func (mydb *MyHimichuDB) Update(key string, txt []byte) (error) {
162     ct := tsNowStr()
163     stmt, err := mydb.db.Prepare(`
164 UPDATE t_orenohimitchu
165 SET txt = ?, mtime = ?
166 WHERE key = ?`)
167     if err != nil {
168         return err
169     }
170     _, err = stmt.Exec(txt, ct, key)
171     return err
172 }
173 
174 func (mydb *MyHimichuDB) SelectOne(key string) ([]byte, []byte, []byte, error) {
175     stmt, err := mydb.db.Prepare(`SELECT txt, ctime, mtime FROM t_orenohimitchu WHERE key = ?`)
176     if err != nil {
177         return nil, nil, nil, err
178     }
179     rows, err := stmt.Query(key)
180     if err != nil {
181         return nil, nil, nil, err
182     }
183     defer rows.Close()
184     var txt []byte
185     var ctime []byte
186     var mtime []byte
187     for rows.Next() {
188         if err = rows.Scan(&txt, &ctime, &mtime); err != nil {
189             return nil, nil, nil, err
190         }
191         break
192     }
193     return txt, ctime, mtime, nil
194 }
195 
196 func (mydb *MyHimichuDB) DeleteOne(key string) (error) {
197     stmt, err := mydb.db.Prepare(`DELETE FROM t_orenohimitchu WHERE key = ?`)
198     _, err = stmt.Exec(key)
199     return err
200 }
201 
202 func (mydb *MyHimichuDB) Keys(q string) ([]string, error) {
203     stmt, err := mydb.db.Prepare(`SELECT key FROM t_orenohimitchu WHERE key LIKE ?`)
204     if err != nil {
205         return nil, err
206     }
207     rows, err := stmt.Query(q)
208     if err != nil {
209         return nil, err
210     }
211     defer rows.Close()
212     reskeys := []string{}
213     for rows.Next() {
214         var txt []byte
215         if err = rows.Scan(&txt); err != nil {
216             return nil, err
217         }
218         reskeys = append(reskeys, string(txt))
219     }
220     return reskeys, nil
221 }
222 
223 /*
224  * related to cipher
225  */
226 type MyCipher struct {
227     //c *rc4.Cipher
228     pw []byte
229 }
230 
231 func (cs *MyCipher) QueryPassword() {
232     passwd := ""
233     if *use_zenity {
234         _, passwd, _ = zenity.Password()
235     } else {
236         survey.AskOne(&survey.Password{
237             Message: "Please type your password",
238         }, &passwd)
239     }
240     p := sha256.Sum256([]byte(passwd))
241     cs.pw = p[:]
242 }
243 
244 func (cs *MyCipher) Encrypt(t []byte) []byte {
245     c, err := rc4.NewCipher(cs.pw)
246     if err != nil {
247         panic(err)
248     }
249     src := []byte(t)
250     dst := make([]byte, len(src))
251     c.XORKeyStream(dst, src)
252     return dst
253 }
254 
255 func (cs *MyCipher) Decrypt(ct []byte) []byte {
256     c, err := rc4.NewCipher(cs.pw)
257     if err != nil {
258         panic(err)
259     }
260     src := make([]byte, len(ct))
261     c.XORKeyStream(src, ct)
262     return src
263 }
264 
265 /*
266  * related to compression
267  */
268 func bytesUncompress(blob []byte) []byte {
269     r := bytes.NewReader(blob)
270     gzr, err := gzip.NewReader(r)
271     if err != nil {
272         return blob  // maybe uncompressed, or corrupted (maybe because the password was wrong)
273     }
274     defer gzr.Close()
275     b := new(bytes.Buffer)
276     _, err = io.Copy(b, gzr)
277     if err != nil {
278         return blob  // what's goin on? i dont know...
279     }
280     return b.Bytes()
281 }
282 func bytesCompress(blob []byte) []byte {
283     b := new(bytes.Buffer)
284     w, _ := gzip.NewWriterLevel(b, 9)
285     w.Write(blob)
286     w.Close()
287     if len(blob) > len(b.Bytes()) {
288         return b.Bytes()
289     }
290     return blob
291 }
292 
293 /*
294  * related to ui (by "survey")
295  */
296 func edit(key, initialValue string) (string, error) {
297     e := &survey.Editor{
298         Message: "Edit content!",
299         Default: initialValue,
300         AppendDefault: true,
301         FileName: "*" + filepath.Ext(key),
302         HideDefault: true,
303     }
304     /**
305     var content string
306     err := survey.AskOne(e, &content)
307     return content, err
308     **/
309     /* almost all copied implementation from survey/editor.go to invoke editor - BEGIN */
310     // prepare the temp file
311     pattern := e.FileName
312     if pattern == "" {
313         pattern = "survey*.txt"
314     }
315     f, err := ioutil.TempFile("", pattern)
316     if err != nil {
317         return "", err
318     }
319     defer os.Remove(f.Name())
320 
321     // write utf8 BOM header
322     // The reason why we do this is because notepad.exe on Windows determines the
323     // encoding of an "empty" text file by the locale, for example, GBK in China,
324     // while golang string only handles utf8 well. However, a text file with utf8
325     // BOM header is not considered "empty" on Windows, and the encoding will then
326     // be determined utf8 by notepad.exe, instead of GBK or other encodings.
327     if _, err := f.Write(bom); err != nil {
328         return "", err
329     }
330 
331     // write initial value
332     if _, err := f.WriteString(initialValue); err != nil {
333         return "", err
334     }
335 
336     // close the fd to prevent the editor unable to save file
337     if err := f.Close(); err != nil {
338         return "", err
339     }
340 
341     // check is input editor exist
342     if e.Editor != "" {
343         editor = e.Editor
344     }
345 
346     stdio := e.Stdio()
347 
348     args, err := shellquote.Split(editor)
349     if err != nil {
350         return "", err
351     }
352     args = append(args, f.Name())
353 
354     // open the editor
355     cmd := exec.Command(args[0], args[1:]...)
356     cmd.Stdin = stdio.In
357     cmd.Stdout = stdio.Out
358     cmd.Stderr = stdio.Err
359     //cursor.Show()
360     if err := cmd.Run(); err != nil {
361         return "", err
362     }
363 
364     // raw is a BOM-unstripped UTF8 byte slice
365     raw, err := ioutil.ReadFile(f.Name())
366     if err != nil {
367         return "", err
368     }
369 
370     // strip BOM header
371     text := string(bytes.TrimPrefix(raw, bom))
372 
373     // check length, return default value on empty
374     if len(text) == 0 && !e.AppendDefault {
375         return e.Default, nil
376     }
377 
378     return text, nil
379     /* almost all copied implementation from survey/editor.go to invoke editor - END */
380 }
381 
382 /*
383  * toplevel menu actions
384  */
385 type MainEntry struct {
386     cipher MyCipher
387     db MyHimichuDB
388 }
389 func (ent *MainEntry) doNewAction() {
390     msg := "new key?"
391     var newkey string
392     for {
393         if *use_zenity {
394             newkey, _ = zenity.Entry(msg, []zenity.Option{}...)
395         } else {
396             survey.AskOne(&survey.Input{Message: msg}, &newkey)
397         }
398         if newkey != "" {
399             break
400         }
401     }
402     newval, err := edit(newkey, "")
403     if err != nil {
404         panic(err)
405     }
406     //log.Println(cipher.Decrypt(cipher.Encrypt(newval)))
407     err = ent.db.Insert(
408         newkey, ent.cipher.Encrypt(bytesCompress([]byte(newval))))
409     if err != nil {
410         log.Println(err)
411     }
412 }
413 func (ent *MainEntry) doListAction() {
414     msg := "pattern?"
415     var pattern string
416     if *use_zenity {
417         pattern, _ = zenity.Entry(msg, []zenity.Option{zenity.EntryText("%")}...)
418     } else {
419         survey.AskOne(&survey.Input{Message: msg, Default: "%"}, &pattern)
420     }
421     keys, err := ent.db.Keys(pattern)
422     if err != nil {
423         panic(err)
424     }
425     var keysel string
426     if *use_zenity {
427         keysel, _ = zenity.List("select one...", keys)
428     } else {
429         survey.AskOne(&survey.Select{Options: keys}, &keysel)
430     }
431     var action string
432     msg = "action to `" + keysel + "`"
433     if *use_zenity {
434         msg += "?"
435         action, _ = zenity.List(msg, []string{"Properties", "Edit", "Delete"})
436     } else {
437         msg += " ([P]roperties / [E]dit / [D]elete)?"
438         survey.AskOne(&survey.Input{Message: msg}, &action)
439     }
440     action = strings.ToLower(action)
441     if compstr("properties", action) || compstr("edit", action) {
442         blob, ctime, mtime, err := ent.db.SelectOne(keysel)
443         if err != nil {
444             panic(err)
445         }
446         dtxt := bytesUncompress(ent.cipher.Decrypt(blob))
447         if compstr("properties", action) {
448             fmt.Printf(`NAME    : %s
449 DIGEST  : %x
450 CREATED : %s
451 MODIFIED: %s
452 `, keysel, sha256.Sum256([]byte(dtxt)), ctime, mtime)
453         } else {
454             newval, err := edit(keysel, string(dtxt))
455             if err != nil {
456                 panic(err)
457             }
458             err = ent.db.Update(
459                 keysel,
460                 ent.cipher.Encrypt(bytesCompress([]byte(newval))))
461             if err != nil {
462                 panic(err)
463             }
464         }
465     } else if compstr("delete", action) {
466         err = ent.db.DeleteOne(keysel)
467         if err != nil {
468             panic(err)
469         }
470     } else {
471         log.Println("unknown action")
472     }
473 }
474 func (ent *MainEntry) mainLoop() {
475     menuitems := []string{"list", "new", "exit"}
476     for {
477         var mentlsel string
478         if *use_zenity {
479             mentlsel, _ = zenity.List("select action?", menuitems)
480         } else {
481             survey.AskOne(&survey.Select{Options: menuitems}, &mentlsel)
482         }
483         if mentlsel == "exit" {
484             break
485         } else if mentlsel == "new" {
486             ent.doNewAction()
487         } else {
488             ent.doListAction()
489         }
490     }
491 }
492 
493 /*
494  * main
495  */
496 func main() {
497     ent := MainEntry{}
498     ent.cipher.QueryPassword()
499     err := ent.db.Open()
500     if err != nil {
501         panic(err)
502     }
503     ent.mainLoop()
504 }

zenity 使用モードと AlecAivazis/survey 使用モードという形で使い分け出来るようにしてる。emacs から直接起動したい場合は zenity モード一択。そうでない場合はご随意に。

2022-03-20 23時追記: 「自己記述的」埋め込み

例にしている道具としては「2022-02-18: 些末なバージョンアップ(2) – 作成日付・更新日付」とほぼ等しい動機で、ただしネタの意味合い的には「Go のおべんきょ」。リソースを Go バイナリに埋め込んじまえよ、なネタ。

「「2022-02-18: 些末なバージョンアップ(2) – 作成日付・更新日付」とほぼ等しい動機」てのは、作ってる道具を「USB メモリに突っ込んで使うことを想定」してることに関係。EXE (あるいは Linux ELF)の形にしてしまうと「いつのバージョンだっけかこの実行ファイルは」てなったときに、自分自身からソースコードを復元出来たら自己完結的で嬉しいかもなぁ、てなこと。オプションで自分のソースコードを吐き出す、てことね。

テクニカルには「Go バイナリにデータを埋め込む」ということなので GcToolchainTricks の範疇のネタ。「Go が生成するバイナリの仕様に従う」「OS のローダが解読可能な仕組みに従う」というのがまぁ「リンカの層での措置」なわけね、これが .syso とかのアプローチ。そうではなくて、「ただ []byte を保持するだけのただの go モジュールを静的リンクする」という考え方も当然あって、それがたとえば go-bindata。いきなり Go 言語で理解しようとすると想像しにくいのであれば、C 言語の以下みたいなもんだと思えばいい:

1 static const char* resoure = "some binary data";

こういう静的データとそれへのアクセッサだけを提供する「bindata.go」を生成してくれるツール、である。

go-bindata の説明だけだとちょっと悶々とするかもしれないが、ほんとに「一手間」だけでこれを実現出来る。今の場合例えば「main.go」というソースだとして。go-bindata のインストールから始めるとするならこれだけなの:

  1. インストール: go get -u github.com/go-bindata/go-bindata/...
  2. 今の場合リソースとして main.go だけ取り込むなら: go-bindata main.go (→ bindata.go が作られる。)
  3. bindata.go が「Asset」をエキスポートしてくれるので、それを使ってご自由にその中の main.go をゴニョればいい

main.go 自身を修正するたびに go-bindata main.go して更新する、という流れになるので、ビルド用のスクリプトを作っとけばよかろう。「ゴニョ」り方は実に簡単で:

 1 // bindata.go を import する、みたいなことはいらない。bindata.go は package main として作られるので。
 2 
 3 // ...
 4 
 5 func main() {
 6     // ...
 7     data, err := Asset("main.go")
 8     fmt.Println((string)data)
 9     // ...
10 }

これだけ。「これだけ」なので、更新したコードは載せない。この後何回バージョンアップを紹介するかわからんけど、以後これを暗黙で入れとくので、そのつもりで読んでくれ。