「初学者」としての学習の「成果」が即実用になる、という幸運な例というのはそうそうあるもんではないが。
Contents
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 のインストールから始めるとするならこれだけなの:
- インストール:
go get -u github.com/go-bindata/go-bindata/...
- 今の場合リソースとして main.go だけ取り込むなら:
go-bindata main.go
(→bindata.go
が作られる。) 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 }
これだけ。「これだけ」なので、更新したコードは載せない。この後何回バージョンアップを紹介するかわからんけど、以後これを暗黙で入れとくので、そのつもりで読んでくれ。