Plan 9とGo言語のブログ

主にPlan 9やGo言語の日々気づいたことを書きます。

Titan Security Keyを使ってみた

ここ数年で、身の回りでは2段階認証が当たり前になってきたように思います*1。個人的には今まで、2段階認証が可能なサービスにはSMSとモバイルアプリを登録していました。しかしここ最近は、サービスにログインする度にIIJ SmartKeyやGoogle Authenticatorなどの認証アプリを起動して、表示された6桁の数字を入力することをめんどくさいと感じるようになりました。数字入力の手間を、Titan Security Keyではボタン押すだけで解決できそうだったので買ってみました。

Titan Security KeyはFIDO U2Fに準拠したデバイスです。FIDOには1と2のバージョンがあり、WebAuthnはFIDO2に含まれます*2。WebAuthnは後方互換によりFIDO U2F対応デバイスも利用可能なのでTitan Security Keyも使えますがパスワードレスログインはできません。

パスワードの不要な世界はいかにして実現されるのかによると、FIDO2では以下の3要素でデバイスを分類しています。

  • Transport : 接続方法
    • USB
    • BLE
    • NFC
    • ineternal
  • Attachment : デバイスに直接組み込まれているかどうか
    • platform(組み込み)
    • cross-platform(取り外し可能)
  • User Verification (UV) : 生体認証を含めた本人認証機能があるかどうか
    • あり
    • なし

例えばTitan Security KeyまたはApple Touch IDの場合は以下のように分類されます。

基準 Titan Security Key Apple Touch ID
Transport USB or BLE internal
Attachment cross-platform platform
User Verification なし あり

対応サイト

2019年現在、2段階認証の要素にセキュリティキーがサイトは以下のどちらかに対応しているようです。

  • WebAuthn (FIDO2)
  • U2F, Universal 2nd Factor (FIDO1)

これらの違いは、U2Fはあくまで2段階要素に使える仕様ですが、WebAuthnはUser Verificationありのデバイスに限りユーザ名やパスワードの代用も可能になっているところのようです。上にも書いたように、WebAuthnはU2Fデバイスも利用可能です。

使い方

Titan Security KeyはUSB版とBLE版の2つ梱包されています。Googleによると普段は一つだけ使い、もう一つは安全な場所に保管することを推奨しています。PCやMacだけで使うならUSBの方が使いやすいと思いましたが、モバイル端末でも使いたいのでBLEの方を普段使いに選びました。しかし現状、macOS 10.14ではBLEのペアリングができないので、付属のUSBケーブルでUSBデバイスとして接続する必要があります。また、iOSではSmart Lockアプリを使わなければ接続できません。以下の表は、BLE版を各プラットフォームで接続する方法です。

OS 接続方法 利用可能ブラウザ
macOS USB FirefoxまたはChrome
iOS BLE+Smart Lock Chromeのみ
Android BLE FirefoxまたはChrome

macOSで使う

macOS 10.14時点では、Safari U2FとWebAuthnどちらにも対応していません。そのためFirefoxまたはChromeを使う必要があります。また、システム環境設定のBluetoothからペアリングができないため、USBとして接続する必要があります。

  1. Titan Security KeyをUSBで接続する
  2. U2FまたはWebAuthn対応サイトで2段階認証を設定する
  3. 途中でボタンを押すように促されるのでTitan Security Key中央のボタンを押す(緑色に点滅し始める)
  4. 2段階認証でセキュリティキーが有効になる
  5. 再認証時に、セキュリティキーを選んでボタンを押す

USBとして接続させるためケーブルで繋げなければならない点を除いては、特に不満がありません。ブラウザからBLE機器へ接続するWeb Bluetooth APIが使えるなら事前のペアリングが不要になったりするのかなと期待しましたが、現時点でブラウザの対応状況は今ひとつのようです。

WebAuthnでBLEデバイスを使う方法によると、BlueTooth explorerを使うとBLEデバイスとしてペアリングできるそうですが、今回は試していません。

Androidで使う

利用前に、OSとデバイスの間でペアリング(鍵交換)しておく必要があります。FIDO Bluetooth Specification v1.0に、

Clients and Authenticators MUST create and use a long-term link key (LTK) and SHALL encrypt all communications. Authenticator MUST never use short term keys.

とあるのでペアリングは必須です。

  1. 対応サイトにログインする
  2. 2段階認証にセキュリティキーを使う
  3. ペアリングするように促されるので続ける
  4. Titan Security Keyのボタンを5秒間押して青い点滅になるまで待つ
  5. Androidからペアリングを行う
  6. PINコード(Titan Security Key裏面の数字)を入力する

これでペアリングが完了するので、サイトに戻ってセキュリティキー中央のボタンを押せばログインできます。次からはペアリングされた状態になっているので、ボタンを押すだけでログイン可能です。

BLEのペアリングには、SSP(Simple Secure Pairing)とPINのモードがあり、セキュリティモードで使い分けるようですが詳細は追っていません。

具体例

iOSで使う

設定アプリではTitan Security KeyとBLEペアリングできませんのでGoogle Smart Lockアプリを使う必要があります。

  • Smart LockアプリでGoogleアカウントにログインする(ログインが終われば閉じても良い)
  • ChromeGoogleアカウントの2段階認証を行う
  • Smart Lockアプリが起動するのでペアリングする

あとはAndroidと同じようにセキュリティキー中央のボタンを押せばログインできます。ただし、ChromeGoogleアカウントにログインする以外の用途には(Smart Lockが反応しないので)使えません。とても残念。

Linuxで使う

試していませんが利用可能なようです。

調べたこと

どうやってセキュリティキーからデータを受け取っているか

Androidでいくつか試した限り、セキュリティキーに常時BLE接続しているわけではなさそうでした。だとすると、ブラウザがセキュリティキーを必要とした時に、OSがペアリング済みのデバイスと接続して、ボタンが押されて認証を終えたら切断しているんでしょうか。詳細はわからないけどそんな気がする...

Touch IDや指紋認証と比べてどうなの

基本的に、デバイス内部で持っている秘密鍵を取り出せるべきではありません。WebAuthnの仕様に、

In general, it is expected that a credential private key never leaves the authenticator that created it. Losing an authenticator therefore, in general, means losing all credentials bound to the lost authenticator, which could lock the user out of an account if the user has only one credential registered with the Relying Party.

とあり、秘密鍵のバックアップを可能にするよりも複数のデバイスを登録可能にするよう書かれています。

そうすると、Attachmentがplatformなデバイスは端末に紐づくため、新しい端末を購入した場合は個別に設定が必要ということでしょうか。スマホを1〜2年に1回移行するように仮定すると、その度に各サイトへログインして、新しいデバイスの設定追加が必要になるのは少し面倒な気がします。サイトが数個程度なら苦ではないけれど、GitHub, Google, Twitter, AWS, ...など非常に多いし今後も増えることが推測できるので、ユーザIDやパスワードの入力することになっても、cross-platformなセキュリティキーを使ったほうが便利かなと思いました。

セキュリティキーはいくつまでペアリング可能か

1つのセキュリティキーを何台のデバイスからペアリング可能かは分かりませんでしたが、少なくともTitan Security KeyはAndroidiOSの2台までは確認しました。

開発

WebAuthn APIを使うときの情報。

*1:多要素認証とも呼ぶけどこの記事では2段階認証という名称を使う

*2:iOSのFIDO対応についての考察を参照

カーネル拡張をApple Notary Serviceにアップロードする

個人的にメンテしているHHKPS2USBDriverというカーネル拡張を、Apple Notary Serviceから証明してもらえることができたので記録に残します。

どんな拡張なのか

古いPS/2接続のHappy Hacking Keyboardは、PS/2をUSBに変換するコンバータを使ってmacOSに接続するとCommandキーを正しく入力することができません。この拡張はコンバータ経由でもCommandキーを扱えるようにキーコードを置き換えます。オリジナル版はNAKANISHI Ichiroさんによって作成されましたが、Yosemite以降はコード署名が必須となり使えなくなっていたので、許可を頂いてメンテと公開を行なっています。

Apple Notary Serviceにアップロードする

macOS 10.14.5から、Notary Serviceで署名することが基本的に必須となりました。これはカーネル拡張も例外ではないので、HHKPS2USBDriverも対応が必要となりました。Apple公式のドキュメントには、Notary Serviceで署名するためにはXcodeからアップロードするように書かれていますが、カーネル拡張プロジェクトの成果物はGeneric Xcode Archiveと呼ばれる形式になるようで、この形式ではXcode Distribute Appオプションが現れないためアップロードすることができません。

f:id:lufiabb:20190605215034p:plain
Distribute Appが無い

Generic Xcode Archiveが作成されてしまう原因は、アプリのArchiveでipaが作れなくて焦ったメモによると、

  • Build SettingsのSkip InstallがNO
  • Build PhasesにHeadersがある

のうち片方でも該当すると対象になるようですが、カーネル拡張の場合は上記のパラメータを設定してもGeneric Xcode Archiveから変わりませんでした。

コマンドラインからアップロードする

Generic Xcode Archiveの場合でも、コマンドラインならApple Notary Serviceにアップロード可能です。公式のドキュメントは以下にありました。

ドキュメントを読めば書いていますが、少し悩んだところがあったので今回の手順をまとめておきます。

  1. Xcodeアーカイブを作成して署名する
  2. OrganizerからDistribute Contentを押してモーダルを開き、Built Productsでカーネル拡張(以下ではHHKPS2USBDriver.kext)を書き出す
  3. ditto -c -k --keepParent HHKPS2USBDriver.kext HHKPS2USBDriver.zipを実行
  4. Apple IDサイトにログインしてアプリ用パスワードを生成する
  5. xcrun altool --notarize-app --primary-bundle-id org.lufia.driver.HHKPS2USBDriver --username '<user@icloud.com>' --password '<passpass>' --file HHKPS2USBDriver.zipでアップロード
  6. 終わるまで待つ(10分程度必要でした)

手順4のアプリ用パスワードは、Using app-specific passwords - Apple Supportに作成手順が書かれていますが、Apple IDにログインして、セキュリティのパスワードを生成を押せばすぐに作成できます。

f:id:lufiabb:20190605215119p:plain
パスワードを生成の場所

また、最後のxcrun altoolは、公式のドキュメントでは@keychain:AC_PASSWORDのようにKeychainのエントリからパスワードを取得していますが、パスワードを直接指定することもできます。今回はローカル環境で実行したため直接書けば問題ありませんでした。ただし、CI環境など不特定多数がログインする環境ならKeychainを使う方がいいでしょう。

上記の手順を終えると、

2019-06-05 13:57:06.788 altool[10401:14566062] No errors uploading 'HHKPS2USBDriver.zip'. RequestUUID = d4db7a94-02b8-4ce9-821b-c4f49d8ba7e5

のようなテキストが出力され、Apple IDに設定しているメールアドレスへメールが届きます。これでプロセスは終わりです。

参考情報

カーネル拡張ではないですが、前職の同僚もNotary Serviceのエントリを書いていました。

また、カーネル拡張を開発する時に調べたリンクや自分で過去に書いた記事などです。

Info.plistOSBundleLibrariesは、ビルドしたカーネル拡張をkextlibsで調べると分かります。

$ kextlibs -xml HHKPS2USBDriver

公式Gitのソースコードレイアウト

Gitのソースコードには、gitコマンドをビルドするためのMakefileが含まれている。Gitコマンドのレイアウトを知らなければ理解の難しいものがいくつかあったので調べた。

Gitコマンドのレイアウト

$PATHに含まれる場所(ほとんどは /usr/bin)にGit関連のコマンドが置かれている。

$ ls -l /usr/bin/git* # コマンド出力は一部加工した
git
git-cvsserver
git-receive-pack
git-shell
git-upload-archive
git-upload-pack

$PATHの他に、Gitが参照するgit-coreというディレクトリがあって、git-coreには$PATHにあるもの全てと、git-addのようにgit-プリフィックスとして付けられた実行ファイルが入っている。git-coreの場所はgit --exec-pathで調べられる。これらのファイルはGitのサブコマンドとして実行できる。実際のところ、ほとんどのファイルはgitシンボリックリンクを張ったものだけど、本体に組み込まれない一部のサブコマンドは、実行ファイルがそのまま配置されている。

$ git --exec-path
/Library/Developer/CommandLineTools/usr/libexec/git-core

$ ls -l $(git --exec-path) # コマンド出力は一部加工した
git -> ../../bin/git
git-add -> ../../bin/git
git-add--interactive
git-am -> ../../bin/git
git-annotate -> ../../bin/git
git-apply -> ../../bin/git
git-archive -> ../../bin/git
git-bisect
git-bisect--helper -> ../../bin/git
...

余談だけどgitコマンドは$PATHgit-coreの2箇所に置かれている。同じものだけど、片方が欠けると動かない。

ソースコードレイアウト

Gitのビルドでは、まずlibgit.axdiff/lib.aなどライブラリをビルドして、Gitの組み込みコマンドとそれらライブラリをリンクするという順番で行う*1

Gitリポジトリ直下にある*.cファイルの一部、例えばdir.cdiff.cなどはlibgit.aとしてビルドされる。このライブラリは、Git本体や、Git本体から独立した単体のサブコマンドとリンクする。リポジトリ直下にあるそれ以外の*.cファイルは、Git本体のソースコードであったり、単体サブコマンドのソースコードであったりする。例えばhttp-fetch.cgit fetchの実装だが、これはGit本体とは別の単体コマンドとしてビルドされる。

builtin/以下にはGit本体に組み込むサブコマンドのソースコードが置かれている。ほとんどの組み込みサブコマンドはbuiltin/以下のソースコードと1 : 1で対応している。例えばbuiltin/add.cgit addコマンドそのものであり、cmd_add(argc, argv, prefix)という関数がエントリポイントとなる。これらのファイルは最終的にGit本体に組み込まれて、git-coregit-addとしてシンボリックリンクが作られる。組み込みサブコマンドのシンボリックリンクはどれもGit本体を参照しており、Gitは実行時のファイル名によって、どのサブコマンドを実行するかを決定する。ただし、少数ではあるがbuiltin以下に対応するファイルを持たない組み込みサブコマンドも存在する。例えばgit cherry-pickbuiltin/にファイルを持たないが、git-cherry-pickという名前のシンボリックリンクは用意される。

残りのディレクトリはそれぞれの目的で用意されている。xdiff/xdiff/lib.aソースコードcompat/は各プラットフォームの互換性問題を解消するためのソースコードが格納されている。またDocumentation/には詳細なドキュメントが用意されているので眺めてみると面白いと思う。

Makefileの変数

Makefileでは上で説明したファイルをどう扱っているか。Makefile変数を調べた。

LIB_OBJS

  • libgit.aを作るためのファイルリスト

BUILTIN_OBJS

  • 組み込みサブコマンドのファイルリスト
  • Git本体はこれとgit.cと各種ライブラリで作られる

BUILT_INS

  • Git本体の組み込みサブコマンドリスト
  • BUILTIN_OBJSと、git cherry-pickなどファイルを持たないもの全て

PROGRAM_OBJS

PROGRAMS

GITLIBS

  • Git本体や単体サブコマンドとリンクするファイル
  • libgit.axdiff/lib.acommon-main.o

BINDIR_PROGRAMS_NEED_X, BINDIR_PROGRAM_NO_X

  • 直接$PATHに置かれる単体コマンド
  • Windowsの場合に.exeを付けるかどうかで変数を使い分ける

*1:libgit.alibgit2とは全く別のものでGitからリンクするためだけに使う

Acmeをプログラムから操作する

Acmeは、Plan 9のために書かれたテキストエディタです。現在はPlan 9 from User SpaceUnix環境にも移植されています。

このエディタはプログラムから操作するための機能がいくつか用意されていて、それを使うとキーボードやマウスなどの入力イベントを外部のプログラムが受け取り、加工してエディタに反映するなどといったことができます。標準で用意されているのはメーラーですが、最近Russ CoxがTodoを公開していました。

プログラマ向けの情報はAcmeのマニュアルにあります。

また、Goから扱うためのパッケージもあります。いろいろ複雑なeventファイルを適切に扱ってくれるので便利です。

基本

Acmeは水色の領域(tag)と黄色の領域(body)を合わせてwindowとして扱います。Acmeはwindow毎に1つディレクトリを用意していて(以下の実行例では1という名前のディレクトリ)、windowを開くたびに2,3...と増えていきます。プログラムからAcmeを操作する場合は、これらのディレクトリを使って行います。

$ 9p ls acme | mc
1       cons    draw    index   log
acme    consctl editout label   new

$ 9p ls acme/1 | mc
addr    ctl     editout event   tag     xdata
body    data    errors  rdsel   wrsel

開いているファイルの取得

acme/indexファイルにも、現在開いているファイルやディレクトリのリストが記録されています。acmeディレクトリを直接読み込んでも同じことはできますが、ほとんどの場合はこちらを使ったほうが簡単でしょう。

$ 9p read acme/index
          1          35         318           1           0 /Users/lufia/ Del Snarf Get | Look 
          2          39          20           0           1 /Users/lufia/.inputrc Del Snarf | Look 
          4          39          26           1           0 /Users/lufia/lib/ Del Snarf Get | Look 
          5          40         101           0           0 /Users/lufia/lib/npmrc Del Snarf | Look 

1行が1つのwindowを表し、左から順番に固定幅で次のような情報が書かれています。

  1. windowのID
  2. タグ行の文字数(rune)
  3. ファイルの文字数(rune)
  4. 開いているファイルがディレクトリかどうか(ディレクトリなら1)
  5. 開いているファイルが編集中(未保存)かどうか(編集中なら1)
  6. タグ行の内容

テキストの編集

windowディレクトリ(1/など)配下のaddrdataは、編集しているテキストを読み書きするために使います。

addrAcmeが扱うアドレスを書くとテキストが選択された状態になります。不要な改行を加えるとエラーになるので気をつけましょう。Acmeは以下のような書式をアドレスとして扱います。

  • 2 - 2行目を選択
  • 2,4 - 2〜4行目を選択
  • /func.*\n/ - funcから改行までを選択
  • #0,#2 - ファイルの先頭から2文字選択

範囲選択が成功すると、dataxdataの内容が範囲で選択された部分に置きかわります。datareadすると、範囲選択部分が終わってもテキストが続けば最後まで読んでしまいますが、xdataは範囲の終わりでEOFとなります。同様にwriteすると選択した範囲を新しいテキストで置き換えます。

$ 9p read acme/2/body
1行目
2行目
3行目
$ echo -n '2' | 9p write acme/2/addr
$ 9p read acme/2/data
2行目
3行目
$ echo -n '2' | 9p write acme/2/addr
$ 9p read acme/2/xdata
2行目

ただし、addrを更新しても内部的に選択された状態になるだけで、画面とはリンクしていません。画面を更新する場合はctlを使います。

$ echo -n '#30,#45' | 9p write acme/1/addr
$ echo -n 'dot=addr' | 9p write acme/1/ctl

dot=addrは変わったコマンドですが、dotは画面で選択されている範囲、addraddrファイルの内容を表すので、addrファイルの内容で画面の選択範囲を更新する意味になります。画面で選択した範囲をaddrに設定するaddr=dotもあります。

イベントの取得

Acmeのイベントは大きく2種類あります。ひとつはエディタ全体でのイベント、もうひとつはwindow単位で発生するイベントです。

acme/log

logはwindowのID、操作、ファイル名の3つが連続したテキストです。ファイル名は省略される場合もあります。実行例を示します。

$ 9p read acme/log
1 focus /Users/lufia/
2 new     # Newコマンドを2ボタンで実行した場合
2 focus 
3 new /Users/lufia/src/    # 3ボタンでファイルを開いた場合
3 focus /Users/lufia/src/
2 del 
3 focus /Users/lufia/src/
3 del /Users/lufia/src/
1 focus /Users/lufia/

スペースで区切った3つのうち、最初のカラムはAcmeのwindowを表す数字です。2つ目は操作を表すコマンドで、これはいくつかあります。

op 意味
new 新しいwindowを作成した
focus windowにマウスポインタが移動した
del windowを削除した
get ファイルを再読み込みした
put ファイルを保存した
zerox windowを複製した

最後のカラムは開いているファイル名またはディレクトリ名です。

acme/<id>/event

eventはwindow単位のイベントが流れてくるファイルです。このファイルをopenしている間は、イベントが流れてくる代わりにAcmeデフォルトの動作は止まります(書き戻すことでデフォルトの動作を起こせる)。

ファイルの内容は1つのイベントが1行になっていて、エディタを操作するたびに1行追加されていきます。eventファイルを開いてからのイベントは読み込みできますが、過去のイベントを読むことはできません。

[origin][type][addr0] [addr1] [flag] [n] [text?]\n

originは何からイベントが発生したかを表す1文字です。

origin どこからイベントが発生したか
E bodyまたはtagファイル
F eventファイルへの書き込みなど
K キーボード入力によるイベント
M マウス操作によるイベント

typeは大きく4つですが、tagまたはbodyのどちらで発生したものかを区別します。また、typeによって続きのaddr0addr1が意味するものが変わります。

type 意味
D bodyからaddr0addr1の範囲が削除された
d tagからaddr0addr1の範囲が削除された
I bodyaddr0addr1textを挿入した
i tagaddr0addr1textを挿入した
L bodytextを検索(マウスボタン3)
l tagtextを検索(マウスボタン3)
X bodytext実行(マウスボタン2)
x tagtextを実行(マウスボタン2)

Dまたはdイベントの場合、addr0addr1の値は削除前の範囲を指します。そのため、イベント発生後に範囲の内容を読み込んでも、元のテキストは消えてしまっているため取り出せません。また、Iまたはiイベントの場合は、addr0addr1はテキストを挿入し終えた後の範囲(新しく増えたテキスト部分)を表します。では2文字以上範囲選択した状態でテキスト入力するとどうなるか、ですが、この場合はDIのイベントに分割されます。従って、addrdataファイルを使って以下の操作を行なった場合、

$ echo -n 2 | 9p write acme/12/addr
$ echo hello | 9p write acme/12/data

eventファイルには分割された次のイベントが届きます。

FD6 12 0 0
FI6 12 0 6 hello

イベントの残り要素、flagtextは複雑なのでacmeのマニュアルを読んでください。textは色々な要因により省略されたりします。

利用例

AcmeのファイルAPIを使った例をいくつか挙げます。

# 4行目から9行目の範囲をaddrに設定
echo -n '4,6' | 9p write acme/<id>/addr

# 選択した範囲(4行目から9行目)を読む
9p read acme/<id>/xdata

# 先頭からのオフセットで範囲選択
echo -n '#34,#54' | 9p write acme/5/addr

# 選択した範囲を更新
echo test | 9p write acme/<id>/data

# addrの内容をwindowに反映(dot)
echo -n 'dot=addr' | 9p write acme/<id>/ctl

# windowで選択した範囲をaddrに設定
echo -n 'addr=dot' | 9p write acme/<id>/ctl

# 変更ありの状態にする
echo -n dirty | 9p write acme/<id>/ctl

# 変更ありフラグを落とす
echo -n clean | 9p write acme/<id>/ctl

Plan 9 ANSI/POSIX環境での環境変数

Plan 9ネイティブのCライブラリはPOSIXに準拠しておらず、独自の習慣がある。例えばfopenfcloseなどは使わずopencloseシステムコールを使う。バッファリングが必要であればBiobufを使いなさいという態度を取る。Goの標準パッケージに名残が残っているので、知っていれば雰囲気は伝わると思う。*1

Plan 9環境変数/env以下にファイルとして提供されていて、プロセス毎に異なる内容が扱える*2。これらの変数は配列もサポートしていて、配列の場合は\0で区切られたバイト列として表現される。例えばhome='/usr/lufia'の場合、/env/homeには"/usr/lufia"と保存されていて、path=(. /bin)('.'と'/bin'の配列)の場合/env/pathの内容は".\0/bin"となる。

ANSI/POSIX環境

Plan 9にはネイティブ環境の他に、Unixツールを移植するためだけにANSI/POSIX環境(APE)も用意されている。このライブラリはネイティブとは分けて、ヘッダは/sys/include/ape/に、アーカイブ/386/lib/ape//amd64/lib/ape/などアーキテクチャ毎に置かれている。POSIXでは、getenvputenvで設定した環境変数environ配列からも同じ内容が参照できなければならないという制約があり、environ配列は"home=/usr/lufia"のような"name=value"形式の文字列配列と定義されているため、ANSI/POSIX環境では/envを使わずメモリ上の配列を操作する方針をとる。だからgetenvenviron配列を検索するだけの関数で、putenvenviron配列を更新するだけの関数として用意されている。*3

他のプロセスへ環境変数を引き渡す時は、単純にexecveの引数にenviron配列を渡すだけで良い。execveは渡されたenviron配列を使って/envを再構築する。APEライブラリを使ったプログラムは、mainを実行する前に/envenviron配列へ読み込む。こうするとPlan 9ネイティブなプログラムは/envを参照すればよいし、APEライブラリを使って書かれたプログラムでは引き続きenviron配列として参照できるようになる。これらの処理は以下のソースコードに書かれている。

  • /sys/src/ape/lib/ap/plan9/_envsetup.c
  • /sys/src/ape/lib/ap/plan9/execve.c

ただし、Cの文字列は\0を文字列の終わりと扱ってしまうため、ネイティブ環境で配列となっている環境変数をそのまま扱うと(1つ目の\0で終わってしまって)2つ目以降の要素を扱うことができない。ネイティブ環境の場合は/env以下にファイルがあるため、stat等でファイルサイズを取れば長さが分かるけれども、environ配列はCの文字列になってしまうため、本来の長さを知る手段がない。なので_envsetup\00x1に置き換えていて、execve/envへ書き戻す前に0x1\0へ戻す実装となっていた。

そういった理由から、Plan 9ANSI/POSIX環境で、getenvを使って得た文字列の途中に0x1が見つかった場合、その文字列は配列として扱ってあげる必要がある。

バグ

余談だけどANSI/POSIX環境のgetenvenviron配列の要素を指すポインタを返す。putenvenviron配列を更新するが、getenvが返したポインタがまだ参照されている可能性があるため、以前の値を変更してはいけない。なので何度もputenvを呼び出ししていると、以前の値で使っていたメモリは消すことも再利用することもできずそのままリークする。setenvも最終的にはputenvと同じなので、同じようにリークする。

Plan 9ネイティブのgetenvmallocした値を返すので、使い終わったらユーザ側で適切にfreeしなければならない。

*1:他にもdialtokenizeなど割と多く残っている

*2:正確には、rforkのオプションにRFENVGRFCENVGがあって、これらを使って環境変数を子プロセスと共有したり分離したりといった操作をすることになる

*3:明らかに動いてなかったので修正した

Goでファイルの存在確認

Goでファイルの存在確認について、インターネットではos.Statの戻り値がエラーかどうかを判定する方法が紹介されていますけれど、os.Statはファイルが存在する場合でもエラーを返すことがあるため、この方法では正しく判定できないケースが存在します。また、複数プロセスが同じファイルにアクセスする場合は、os.Statの直後で、別のプロセスによってファイルが作成されたり削除されたりするかもしれません。正確に存在確認する場合は、存在確認した後に行う処理によっていくつかパターンがありますが、基本は、事前に存在を確認するのではなく、意図しない場合にエラーとなるようなフラグを立てておいて、OSのシステムコールが返したエラーを判定することになります。

Cなどでは、Unix系OSと異なり、Windowsは別の関数とフラグを、Plan 9は別のフラグを使いますが、Go標準パッケージのsyscallはその辺りの違いを吸収してくれているので、以下の内容はそのまま使えます。*1

目的別に紹介

ファイルの存在確認をする目的ごとに、対応方法は異なります。以下ではos.OpenFileを使いますが、Goにおいてはos.Openまたはos.Createは以下の呼び出しと同じなので、同じフラグになるのであればどの関数を使っても構いません。

// os.Open(name)は以下と同じ
os.OpenFile(name, os.O_RDONLY, 0)

// os.Create(name)は以下と同じ
os.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0666)

ファイルが存在すれば読む

例えば設定ファイルがあれば読む場合などです。事前の確認を行わず、ファイルを読み込みフラグで開いて、エラーなら、それを使って原因を調べるといいでしょう。

f, err := os.OpenFile(file, os.O_RDONLY, 0)
if err != nil {
    if os.IsNotExist(err) {
        return nil // ファイルが存在しない
    }
    return err // それ以外のエラー(例えばパーミッションがない)
}
// ファイルが正しく読み込める
return nil

ioutil.ReadFileなどを使う場合も、とりあえず読み込んでみて、エラーなら上と同じように判定すればいいです。

ファイルを作成するが存在した場合は何もしない

ファイルがなければデフォルトの値でファイルを作成する場合などで使います。os.O_CREATEと同時にos.O_EXCLをセットすることで、作成できなかった場合にエラーとなるため、存在していたことを判定したい場合はos.IsExistで確認する必要があります。

f, err := os.OpenFile(file, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0666)
if err != nil {
    if os.IsExist(err) {
        return nil // ファイルが既に存在していた
    }
    return err // それ以外のエラー(例えばパーミッションがない)
}
// ファイルに書き込み可能
return nil

ファイルが存在すれば読み込むが、なければ新規作成する

ログなどをファイルへ書き込む場合に使うと良さそうです。ファイルの有無によりエラーとなることがないため、エラーの判定は特にありません。

f, err := os.OpenFile(file, os.O_RDWR|os.O_CREATE, 0666)
if err != nil {
    return err // エラー(例えばパーミッションがない)
}
// ファイルを読み書き可能
return nil

ファイルが存在すれば削除して新規作成する

設定ファイルの更新などで使います。実際はファイルを削除するわけではなく、os.O_TRUNCフラグによってファイルの内容を消去しています。

f, err := os.OpenFile(file, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0666)
if err != nil {
    return err // エラー(例えばパーミッションがない)
}
// ファイルに書き込み可能
return nil

ioutil.WriteFileはこのフラグと同等です。

ファイルが存在すれば削除する

削除の場合はos.OpenFileの代わりにos.Removeを使いますが、基本はos.OpenFileと同様に、実行してからのエラーを判定します。

err := os.Remove(file)
if err != nil {
    if os.IsNotExist(err) {
        return nil // 存在していないので何もしなくていい
    }
    return err // エラー(例えばパーミッションがない)
}
// ファイルに書き込み可能
return nil

参考情報

アトミックなファイル更新

ファイルの書き込みについて補足です。

上記では、ファイルの内容を更新するパターンを紹介しましたが、ファイルの内容を更新途中で電源が落ちたなどの原因によって、中途半端な状態が発生することがあります。これを避けるために、POSIXでは同一ファイルシステムにおいてrenameがアトミックであることを利用して、新しいファイルの内容を別のファイルとして保存して、全て終わったあとで本来のファイル名にリネームする手法が使われます。これを自分で書くのは意外と大変なので、Goならnatefinch/atomicを使えば良いでしょう。

また、最近のOSにはファイルの書き込みバッファが存在するため、バッファを使わない一部の例外を除いて、bufio.Writer.Flushなどでフラッシュしてもファイルには書き込まれていない状態が起こり得ます。これがどういった原理なのかは以下の記事が分かりやすいと思います。

*1:Windowssyscall_windows.goPlan 9const_plan9.go辺り

GitをPlan 9に移植した話

2018年の夏頃から、時間をみつけてはPlan 9にGitを移植していて、概ね動いたので公開する。ソースコードはこの辺りにある。

GitへのPull requestが個人的な平成最後のPull requestだった。

なぜ移植するのか

Plan 9コミュニティの努力によって、PythonMercurialは移植されていたけれど、ここ数年はGitHubでホストされるプロジェクトが多く、Gitが使えないと困るようになってきた。一応、今でもgit-wrapperソースコードのダウンロードは可能だけれど、これはGitHubからzipをダウンロードすることで擬似的に git コマンドを再現しているだけなので、Pull requestを送りたい場合には使えない。

Plan 9でGitを使うためには、公式の git クライアントを移植する方法と、最初から実装する方法の2つあると思う。今回の移植を始める前は、OpenSSLやLibcurlなど、最低限必要なライブラリも全てPlan 9には移植されていなかったし、公式 git クライアントの大部分はCで実装されているので、どうせなら、もっと安全で実装効率の良い言語を使って、必要な部分だけ実装した方がメンテも楽になると思っていた。このアプローチではdgitというGoで再実装しているプロジェクトが存在していて、cloneやmergeなどの基本的なオペレーションは行えるし、今も比較的活発に開発されている。 また、git/fsはCでPlan 9のファイルサーバとして実装しているもので、Plan 9アプリケーションとしてのアプローチは一番正しい。

だけども、独自に実装をしてしまうと、git のアップデートに追従できなくなるんじゃないかという懸念があった。これがGitではなく、RFCみたいな標準仕様があってアップデートも年単位だったなら、Goなどの新しい言語で実装した方がモチベーションも上がるし面倒がないと思う。だけど、Gitは公式のテストケースはあるものの頻繁にアップデートされていくし、それに対してPlan 9開発者は非常に少ないので、公式のコードから離れることは、長期的にみると追従する労力が大きくなって、どこかで限界がくるんじゃないかと思う。だから最善はPlan 9対応のパッチを公式 git クライアントに取り込んでもらうことで、次点ではForkして本家のアップデートに追従しやすくすることじゃないだろうか。今はGitHubがあってパッチも送りやすくなったので、リジェクトされるかもしれないけれど、そんなことで気負いせずに出してみて損はないと思っている。

現在の状況

現在、Plan 9に移植したGitクライアントは、

  • git-add
  • git-commit
  • git-log
  • git-diff
  • git-clone
  • git-push
  • git-fetch

など、よく使うコマンドは動くようになった。本当は git-add -pgit-rebase -i も欲しいけど、こういった一部のサブコマンドはシェルスクリプトPerlで書かれていて、Plan 9のcontrib indexにあるPerlはとても古いのでおそらく動かない。Perlを移植するのはとても大変なので、コードが小さいのであれば、PerlからGoやPythonに書き換えてもいいかなと思う。

Gitを移植する副産物として、opensslcurl コマンドも移植できたし、それなりに動くpthreadも実装できたのは、大変だったけれども結果的には良かった。