mizdra's blog

ぽよぐらみんぐ

普段使いできる保護レイヤー「restricted shell」の紹介

これは はてなエンジニア Advent Calendar 2025 1日目の記事です。


はてなでフロントエンドエキスパートをしている id:mizdra です。普段は JavaScript を書いてて、趣味で色々なツールを作ってます。

ところで最近、npmjs.com へのサプライチェーン攻撃が話題ですね。以前から npmjs.com ではサプライチェーン攻撃が発生していましたが、今年は規模が大きいものが頻繁に発生しています。

マルウェアをインストールしない・実行しないことが一番ではあるのですが、昨今の npmjs.com への攻撃の様子を踏まえると、そうも言っていられません。そのため、何個も保護レイヤーを設ける「多層防御」という考えが重要です。マルウェアをインストールしない・実行しないための対策とは別に、仮にマルウェアが実行されても、その影響を抑える対策があると良いでしょう。

最も手軽な対策は、ファイルシステム上に機密情報を置かないことです。具体的には、以下のようなものです。

どれも比較的無理なくできるものなので、ぜひやってみると良いでしょう。

一方で、Sandbox を利用するという対策もあります。

  • Development Container (a.k.a. Dev Container) を使う
    • Docker コンテナの中で開発する
  • ブラウザベースの開発環境を使う
    • GitHub Codespaces, Stackblitz, vscode.dev など

Sandbox 内には、そのリポジトリのファイルしか mount されません。そのため、マルウェアの影響をそのリポジトリの中だけに限定できます *1

Sandbox を使った対策の欠点

Sandbox は便利な反面、導入が面倒な技術です。例えば、ホスト側からポートにアクセスできるよう明示的な許可が必要です。ボリュームの mount の設定も必要です。Development Container の場合は、中でシェルを起動して作業できるようになってますから、そのシェルから使う ツール類の設定なども必要でしょう。場合によっては、アプリケーション側のコードの改修も必要かもしれません。

Sandbox は導入と運用にそれなりのコストが掛かってしまいます。マルウェアに対する良い対策だと分かっていても、中々導入は難しいのです。

「restricted shell」の紹介

とはいえ、やはり Sandbox 相当のものは欲しいです。そこで Sandbox と同じような効果が得られて、もっと手軽に使えるものを作ってみました。それが「restricted shell」です。はじめに言っておきますが、macOS 限定、かつ ghq でリポジトリを管理しているユーザ限定です。

restricted shell が動作する様子。

リポジトリを cd すると、restricted shell という制限の掛かったサブシェルが起動します。その中では今いるリポジトリから他のリポジトリのファイルを読み取れなくなります。

他のリポジトリのファイルの読み取りが禁止されるだけで、それ以外の制限はありません。そのため、以下のようなことができます。

  • npm install できる
    • ネットワークアクセスは自由
  • ^R でシェルの履歴が見れる
    • ホームディレクトリ配下のファイルは読み取り制限なし
  • プロセスも起動し放題

Sandbox というよりは、非常に緩い保護レイヤーのようなものです。普段使いできるように、どのリポジトリでも使えるように、意図的に緩い制限にしています。

とはいえ制限を解除して作業したいこともあるでしょう。そこで脱出ハッチとして exit で restricted shell を終了できるようにしてます。また、rsh で再度 restricted shell を起動することも可能です。

exit で restricted shell を終了、rsh で再度起動する様子。

あとサブシェル (restricted shell) から抜ける時に、サブシェルの履歴とカレントディレクトリを親シェルに引き継ぐようにしてます。そのおかげで、restricted shell を終了してもシームレスに作業を継続できます。

普段使いするものなので、使い勝手には拘って設計してます。

仕組み

Apple Seatbelt という技術を使っています。macOS に搭載されているセキュリティ保護機能で、ルールベースでプロセスに制限をかけられます。

yuzuhara.hatenablog.jp

blog.syum.ai

今回はこの Apple Seatbelt で、特定のディレクトリへのアクセスが制限されたサブシェルを立ち上げています。Apple Seatbelt による制限は、親プロセスから子プロセスへと継承されます。そのため、サブシェル内から起動されるあらゆるプロセス (ls .. など) が制限を継承し、ls .. などがエラーでコケるようになります。

実装

実装は Apple Seatbelt の設定ファイル、.zshrc、プロンプトの設定 (starship.toml) に分かれています。まず Apple Seatbelt の設定ファイルから。

;; .config/sandbox/restricted-shell.sb
(version 1)
(allow default)

(deny file-read*
  (subpath (param "GHQ_ROOT"))
)

(allow file-read*
  (subpath (param "REPOSITORY"))
)

;; starship がリポジトリの親ディレクトリのメタデータを読みに行くようなので、例外的に許可。
;; 本当はもうちょっと厳しくしたい。
(allow file-read-metadata
  (subpath (param "GHQ_ROOT"))
)

REPOSITORY が今 cd してるリポジトリのパスを表すパラメータで、GHQ_ROOT が ghq のルートディレクトリを表すパラメータです。一括で GHQ_ROOT の読み取りを禁止し、その上で REPOSITORY を例外的に許可してます。

.zshrc は以下の通り。

PREV_REPOSITORY=""
function start-restricted-shell-if-needed() {
  local ghq_root="$(ghq root)"
  local repository=""
  [[ "$PWD" =~ ^$ghq_root/[^/]+/[^/]+/[^/]+ ]] && repository="$MATCH"
  
  if [[ -z "$repository" ]]; then
    PREV_REPOSITORY=""
  elif [[ "$RESTRICTED_SHELL" != "1" && "$repository" != "$PREV_REPOSITORY" ]]; then
    PREV_REPOSITORY="$repository"
    echo "info: Start a restricted shell. (To exit, type 'exit'.)"
    RESTRICTED_SHELL=1 sandbox-exec -f ~/.config/sandbox/restricted-shell.sb -D GHQ_ROOT="$ghq_root" -D REPOSITORY="$repository" "$SHELL"
    fc -R
    cd "$(</tmp/rsh-pwd)"
  fi
}
function restricted-shell-exit() {
  if [[ "$RESTRICTED_SHELL" == "1" ]]; then
    pwd > /tmp/rsh-pwd
  fi
}
autoload -Uz add-zsh-hook
add-zsh-hook precmd start-restricted-shell-if-needed
add-zsh-hook zshexit restricted-shell-exit
alias rsh='PREV_REPOSITORY="" start-restricted-shell-if-needed'

色々書いてありますが、重要なのは add-zsh-hook precmd ... の部分と、start-restricted-shell-if-needed 関数の中身です。zsh の precmd hook を使い、プロンプトが表示される際にリポジトリのディレクトリかどうかチェックしてます。そして、リポジトリのディレクトリなら RESTRICTED_SHELL=1 sandbox-exec -f ~/.config/sandbox/restricted-shell.sb ... $SHELL でサブシェルを起動してます。

コードの他の部分は、 サブシェルの履歴とカレントディレクトリの引き継ぎ、rsh コマンドに関するものです。色々な機能がありますが、これ全部で 25 行で済んでます。

最後に、プロンプトの設定 (starship.toml) です。RESTRICTED_SHELL=1 なら鍵の絵文字を出してるだけです。これの導入は必須ではないですが、あると便利です。

format = """
$time\
$cmd_duration\
$jobs\
$status\
$username\
$hostname\
$localip\
$shlvl\
$directory\
$git_branch\
$git_state\
$git_metrics\
$git_status\
$custom\
$sudo\
$shell\
$character"""

[custom.restricted_shell]
command = "echo 🔒"
when = ''' test "$RESTRICTED_SHELL" = 1 '''

starship を使ってない人は、なんか上手いことやってください。

実際使ってみてどうか

まだ使い始めて 1 週間くらいですが、趣味でも業務でも苦なく使えています。普段使いできる保護レイヤーとしての役目は果たせてそうです。

ちなみに open . で Finder でディレクトリを開くのも、ちゃんと動きます。当初は Finder が Apple Seatbelt の制限を引き継ぐせいで上手く動かないのでは、と思っていたのですが、制限を引き継がずに起動されるようです。open で起動されたアプリケーションは、シェルではなく launchd が親プロセスになり、その結果 Apple Seatbelt による保護が効かないようです。保護を回避できて安全性が下がってしまってますが、普段使いする分にはこちらのほうが都合が良いとも言えます。

ちょっとしたトラブルもありました。VS Code をシェルから起動する code コマンドが上手く動作しないのです。具体的には、code . と打っても、カレントディレクトリを workspace として開いてくれません。

カレントディレクトリが workspace として開かれない様子。

code コマンドの内部では open -n で VS Code を起動するのですが、その -n オプション (既にアプリケーションが起動していても新しいインスタンスを起動するオプション) の挙動が Apple Seatbelt の有無で変わるようです。

どうやら Apple Seatbelt では (allow default) していても、強制的に課される制限があるようです。id:mizdra が確認した限りでは、ps コマンドや top コマンドの実行が制限されていました。

;; ~/.config/sandbox/allow-default.sb
(version 1)
(allow default)
$ sandbox-exec -f ~/.config/sandbox/allow-default.sb ps
sandbox-exec: execvp() of 'ps' failed: Operation not permitted
$ sandbox-exec -f ~/.config/sandbox/allow-default.sb top
sandbox-exec: execvp() of 'top' failed: Operation not permitted

恐らく、他のプロセスの情報の取得が制限されているのだと思います。そのため open -n のようなコマンドが動かないのでしょう。

色々調べたところ、code コマンドを使わずに直接 open コマンドで VS Code を開けば、問題を回避できるようでした。僕は以下のような alias を貼るようにしてます。

if [[ "$RESTRICTED_SHELL" == "1" ]]; then
  alias code='open -a "Visual Studio Code"'
fi

とはいえこれも完璧ではありません。code --wait のようなオプションが動作しなくなってしまいます *2EDITOR="code --wait" を使ってる人は EDITOR="vim" に切り替えたりと、追加の対策を導入する必要があります。

code . が動かないのはちょっと面倒ですが、回避策もあるのでまあ...という感じですね。

面白いところ

ちょっと面白いのは、restricted-shell.sb を編集することで自由に保護レベルをカスタマイズできるところです。.zshrcrestricted-shell.sb の書き換えを禁止したり、opendocker の起動を禁止したり。好みに応じて変えられます。

やろうと思えばうんと厳しくもできます。Apple Seatbelt では (deny default) と書くと、デフォルトで全てが禁止され、allowlist のような形でルールを記述するモードになります。そうすれば、Sandbox のような厳しい制限も課せると思います。まあ普段使いするには厳しいでしょうが... それやるくらいなら、普通に Development Container 使ったら良いと思います。

macOS 以外への移植

Apple Seatbelt 相当のものがあれば、他の OS へも移植できるはずです。

僕は詳しくないですが、Linux だと AppArmor というものが使えるそうです。後日同僚の id:Windymelt さんが Linux 版の記事を書いて下さるようなので、それを待って下さい。

それ以外の OS についても、興味がある人がいたら移植してみてください。

大事なこと

もちろん、restricted shell は完璧ではありません。restricted shell は (少なくとも今回紹介した sb ファイルでは) ファイルアクセスくらいしか制限してません。マルウェアからネットワークリクエストも、プロセスの起動も仕放題です。open コマンドを使えば保護を回避できます。マルウェアが本気を出せば、きっと色々なことができてしまうでしょう。Sandbox と呼ぶにはお粗末なものです。

セキュリティ対策に銀の弾丸はありません。様々な対策を幾重にも導入し、地道にリスクを下げていくべきです。例えば、以下のようなことをやると良いでしょう。

心構えや勉強も大事です。

  • 信頼できる package をインストールするよう心がける
  • 脆弱性や攻撃の情報に目を光らせ、発見されたらすぐに対応する
  • セキュリティに関する書籍を読む

restricted shell はあくまで保護レイヤーの 1 つです。基本的なセキュリティ対策を疎かにしないよう気をつけましょう。

おわりに

この記事では「restricted shell」を紹介しました。本当にそのような緩い保護レイヤーで役に立つのか、疑問に感じる人も居るかもしれません。一方で、普段使いできる追加の保護レイヤーと考えると、面白い仕組みではないかと思ってます。

僕自身まだ1週間しか使ってないので、本当に使えるものなのか正直よく分かってません。しばらく使ってみて、使用感を確かめたいと思ってます。そのような感じですが、もし興味あれば使ったり、参考にしてみてください。

はてなエンジニア Advent Calendar 2025 2日目の担当は id:pokutuna さんです。

合わせて読みたい

www.mizdra.net

*1:もちろんリポジトリ内に GitHub の Token があって、それにすべてのリポジトリの読み取り権限が付いていたりしたら、その限りではないですが...

*2:--wait のハンドリングが code コマンドの中で行われているためです

YAPC::Fukuoka 2025 で登壇しました

2025/11/14〜15 に福岡工業大学で開催された、YAPC::Fukuoka 2025 に参加してきました。

yapcjapan.org

登壇

ありがたいことにプロポーザルが無事通り、「機密情報の漏洩を防げ! Webフロントエンド開発で意識すべき漏洩パターンとその対策」というタイトルで登壇させて頂きました。

speakerdeck.com

Web フロントエンドはユーザに直接面している領域故に、機密情報の漏洩が起きやすいです。また Next.js など、最近 Web フロントエンドの開発で使われているフレームワークでは、特に機密情報を漏洩させやすいポイントが存在します。そして、Perl の Xslate や Ruby の erb など、テンプレートエンジンを使い慣れている人ほどそれにハマりやすい...と私は考えています。

そこでこの発表では、従来技術 (Xslate や erb) と比較して Next.js などがなぜ漏洩を起こしやすいのか、漏洩を防ぐにはどうしたら良いのか、その他細かい実用的なテクニックについて紹介しています。

id:mizdra は普段の業務で、フロントエンドのエキスパートとして機密情報の漏洩が起きないように注意を払っています。とはいえ、エキスパートだけが注意を払って取り組むのは、健全とは言えません。やはりチーム全体で取り組みたいものです。そのためにも、チームのメンバーにまずはこれを読んでと渡せる資料が欲しかった!

今回のスライドは、まさにその入門資料として使って頂けると思います。ぜひ皆さんのチームでも、このスライドを紹介してみてください。

面白かったセッション

id:sosukesuzuki さんのセッションが良かったです。「時間を可能な限り捻出して、集中して使う」というのは、すごく分かるなーと思いながら聞いてました。

speakerdeck.com

id:Songmu さんの deck の話も良かったです。コツコツ改善を入れて大きな成果が出るまでの道筋が面白かった。

www.docswell.com

観光

YAPC の前日に現地入りして、下関・福岡観光していました。

まず博多駅から特急ソニックで小倉駅まで行き、そこから JR 山陽本線で下関駅に行きました。

JR 山陽本線ですが、門司駅と下関駅の間は海底トンネルを通るようでした。九州から本州にトンネルで渡る体験は面白かったですね。あと途中にデッドセクションがあって電気が消えたりして、なるほどな〜となってました。

www.ku-hibino.com

下関駅前はこんな感じ。

下関駅前の様子。

その後はオーヴィジョン海峡ゆめタワーに。

オーヴィジョン海峡ゆめタワーを下から見上げた様子。

なぜタワーに行ったかというと、僕が「All-Japan タワーズ スタンプラリー」をやっているからです。全国の技術イベント行くついでにやると面白いかなと思ってやってみています。

無事スタンプゲットです。押すのミスってぼやけちゃった。

タワーからは関門海峡が見えました。こうしてみるとすごく狭い海峡なんですね。

どうでもよいですが、スタンプラリーを主催する「全日本タワー連盟」の会議が、当日このタワーで行われていたようです。こんな偶然あるんですね。

そのまま西へと移動し、関門トンネル人道へ。歩いて九州へと渡りました。

その後は門司駅まで行って、電車や新幹線を使い博多へ。博多からタクシーで福岡タワーへ。そう、スタンプラリー2箇所目です。

福岡タワーの様子。夜はライトアップされていました。見えづらいですが、左側にクリスマスツリーの模様が描かれてます。

スタンプゲット。フータくんだそうです。

福岡タワーは夜景が綺麗でした。オススメです。

とまあ観光はこんな感じでした。本当は下関でフグ食べたり、博多で博多ラーメン食べたりしたかったのですが、そのような時間は全く無かった!!! 博多と下関の移動がかなり掛かってしまった... 博多-小倉間の移動には特急ソニックではなく、新幹線を使ったほうが良いです。

名物の食べ物食べられなかったの悔しいので、いつの日かリベンジしたいですね。

JSConf JP

YAPC に続けて何故かJSConf JP があり、YAPC が終わった次の日の朝、福岡から東京まで飛んで駆けつける...ということをやってました。なんか今年の YAPC はフロントエンドや JavaScript 畑の人が多かった気がします。どっちも行ってる人そこそこ見かけました。皆さん元気いっぱいですごい。

JSConf JP はともかく、YAPC で自分と似たような領域の人と沢山会えて、色々お話できて嬉しかったです。来年も色々な方と会えると嬉しいですね。

おわりに

良い発表ができて、面白い発表が聞けて、いろんな人と話せて楽しかったです。来年はベストスピーカー賞取りたいね、と yusukebe さんと話して盛り上がっていたので、頑張りたいと思います。ベストスピーカーが取りやすいトーク、みたいなのはあるんだろうけど、やっぱり自分らしい発表で取れると格好良いなー。

ghq+peco とゴミ置き場

ghq は、git clone したリポジトリを特定のディレクトリ規則に従って管理するコマンドです。そして peco は、インクリメンタル検索をするコマンドです。この 2 つを組み合わせると、git clone したリポジトリを簡単に cd できます。

# ~/.gitconfig
[ghq]
    root = ~/src
# ~/.zshrc
function peco-src() {
  local selected_dir=$(ghq list | peco --query "$LBUFFER")
  if [[ -n "$selected_dir" ]]; then
    BUFFER="cd $(ghq root)/$selected_dir"
    zle accept-line
  fi
  zle redisplay
}
zle -N peco-src
stty stop undef # disable builtin "^s" keybind
bindkey '^s' peco-src

ghq+peco でディレクトリを切り替える様子。

数多くのブログで紹介されている有名なテクニックですので、使っている人も多いでしょう。使ってない人は是非使ってみて下さい。

ゴミ置き場

ghq+peco により、バージョン管理システム (VCS) で管理されるリポジトリを整理できる訳ですが、現実には VCS で管理されないものも扱いたくなります。例えば、ライブラリの動作確認のためのリポジトリや、ライブラリの不具合を再現するためのリポジトリとかです。ちょっとしたコードは書きたいけど、わざわざ git 管理するほどでもない、みたいなやつです。

そうしたリポジトリを管理するため、id:mizdra~/src/localhost/gomi ディレクトリを作ってます。

~/src
├── github.com
│   ├── x-motemen
│   │   └── repository-1
│   └── mizdra
│        ├── repository-1
│        └── repository-2
└── localhost
     └── gomi
          ├── repository-1
          └── repository-2

ゴミ置き場なので gomi です。直球ですね。

気軽に ~/src/localhost/gomi に移動できるよう、zsh で auto_cd と名前付きディレクトリを設定しています。これで ~gomi と打つだけで移動できます *1

# ~/.zshrc
setopt auto_cd
hash -d gomi=~/src/localhost/gomi

VCS 管理しないものは ~gomi に置いていき、不定期に rm -rf ~gomi/* で削除しています。もし何らかの事情で ~gomi に置いたものを VCS 管理したくなったら、git push => rm ~gomi/repo => ghq get ... で ghq 管理のディレクトリへと移動する運用にしてます。

~gomi と ghq+peco の相性問題

~gomi は便利ではあるのですが、冒頭で紹介した ghq+peco を使ったディレクトリ移動のテクニックと組み合わせると、ちょっとした問題が起きます。~gomi にあるリポジトリが一覧されないのです。

~gomi 配下にリポジトリがあるが、ghq+peco によるリポジトリ切り替えの候補にそれが表示されない様子。

これは、リポジトリの一覧を取得するコマンドに ghq list を使用していることが原因です。ghq list は VCS 管理されているディレクトリしか返してくれないのです。ghq は VCS 管理されるリポジトリを整理するためのコマンドなので、自然な挙動ではあります。

とはいえ、~gomi のようなディレクトリを作っている身としては困ります。そこで id:mizdrafind $(ghq root) -mindepth 3 -maxdepth 3 -type d でリポジトリの一覧を取得しています。

# ~/.zshrc
function peco-src() {
  local ghq_root="$(ghq root)"
  local selected_dir=$(find $ghq_root -mindepth 3 -maxdepth 3 -type d | sed "s|$ghq_root/||" | peco --query "$LBUFFER")
  if [[ -n "$selected_dir" ]]; then
    BUFFER="cd $ghq_root/$selected_dir"
    zle accept-line
  fi
  zle redisplay
}
zle -N peco-src
stty stop undef # disable builtin "^s" keybind
bindkey '^s' peco-src

これで、ghq+peco によるリポジトリ切り替えの候補に ~gomi 配下のものが表示されます。

ちょっとした注意点

今回紹介したスクリプトは、https://<domain>/subpath/<org>/<repo>.git というサブパス付き URLを持ち、~/src/<domain>/subpath/<org>/<repo> に配置されるリポジトリには対応してません。-mindepth 3 -maxdepth 3 で深さ 3 にあるディレクトリを抽出してるからです。

サブパスを URL に含むリポジトリなんてそう滅多にないので、ほとんどの場合は問題ないはず...。どうしてもサブパスを URL に含むリポジトリを扱いたい人は、ghq listfind $ghq_root/localhost -mindepth 3 -maxdepth 3 -type d を組み合わせてみてください。ghq list はサブパスを持つディレクトリに対応してるので、それで問題を回避できるはずです。

*1:https://pocke.hatenablog.com/entry/2014/07/23/173811 も合わせて読むと面白いでしょう

Node.js におけるファイル読み取りエラーのクロスプラットフォーム対応の仕組み

Windows の Node.js で存在しないファイルを fs.readFileSync で読み取ろうとすると ENOENT が返ってくる。けど ENOENT は POSIX で定義されてるエラーコードであって、Windows のものではない。どこかで正規化されてるのか? という疑問が出てきたので調べてみた。

答え

Node.js の公式ドキュメントの error.errno の説明に答えが書いてあった。

https://nodejs.org/api/errors.html#errorerrno

On Windows the error number provided by the system will be normalized by libuv.

どうも libuv でエラーコードの正規化がされてるらしい。確かに libuv のコードを見ると、int uv_translate_sys_error(int sys_errno) 関数の中にエラーコードを正規化するコードが書かれていた。

// https://github.com/libuv/libuv/blob/12d1ed1380c59c5ec27503cf149833de6f0e6bb0/src/win/error.c#L134-L144 より
int uv_translate_sys_error(int sys_errno) {
  // ...
  switch (sys_errno) {
    // ...
    case ERROR_BAD_PATHNAME:                return UV_ENOENT;
    case ERROR_DIRECTORY:                   return UV_ENOENT;
    case ERROR_ENVVAR_NOT_FOUND:            return UV_ENOENT;
    case ERROR_FILE_NOT_FOUND:              return UV_ENOENT;
    case ERROR_INVALID_NAME:                return UV_ENOENT;
    case ERROR_INVALID_DRIVE:               return UV_ENOENT;
    case ERROR_INVALID_REPARSE_DATA:        return UV_ENOENT;
    case ERROR_MOD_NOT_FOUND:               return UV_ENOENT;
    case ERROR_PATH_NOT_FOUND:              return UV_ENOENT;
    case WSAHOST_NOT_FOUND:                 return UV_ENOENT;
    case WSANO_DATA:                        return UV_ENOENT;
    // ...
  }
  // ...
}

あれでも libuv ってイベントループを実装するためのライブラリじゃなかったっけ? fs.readFileSync は同期的な API でイベントループ関係ない気がするんだけど。 同期的な API も libuv に依存してるの?

答え2

...という疑問を持ったので調べてみたら、どうも libuv にはファイルシステムに関する機能もあるようだった。README の「Feature highlights」を見ると、「File system events」「Child processes」「Thread pool」など色々書かれてる。

libuv のコードを見てみると、Windows 向けに fs__read という関数が実装されてて、ここでファイルの読み取りやらエラーコードの正規化をしていることがわかる。

そしてこの fs__read 関数が fs.readFileSync の内部で呼ばれている、という訳。

同期的な API の中でも libuv の API が呼び出されてるの知らなかったので、勉強になった。

VS Code でデバッガーを使って oxc の挙動を観察したい

oxc の挙動を観察したいな〜と突然思って、oxc のデバッグ環境を VS Code で用意した。ちょっと躓いたのでメモを残しておく。

やりたいこと

  • oxc のテストを VS Code から実行したい
  • しかも VS Code 上から breakpoint を設定して、テストをステップ実行したい

Requirements

以下は事前にインストールしておく。

  • VS Code
  • Rust
  • rust-analyzer 拡張機能
    • rust-analyzer という Rust 向けの Language Server があって、それを VS Code から使うための拡張機能
    • VS Code からテストを実行するための UI も提供してくれる
      • プロジェクト内のテストケースを抽出して「Test Explorer」にそれを表示してくれたり
      • #[test] のすぐ下にインラインで「Run Test」「Debug (デバッガーを attach しながらテストを実行するモード)」ボタンを表示してくれたり
  • CodeLLDB 拡張機能
    • LLDB というデバッガーがあって、それを VS Code から使うための拡張機能

あと oxc のリポジトリも git clone して Contribution Guide を見ながらセットアップしておく。

breakpoint で止まらない

「Debug」ボタンからテストを実行しても、何故か breakpoint で止まらずに完走してしまう。

「Debug」ボタンからテストを実行したのに、breakpoint で止まらずに完走してしまう様子

最初は rust-analyzer/CodeLLDB 拡張機能の設定が不十分なのかと思ったけど、特に怪しい設定もない。となるとテスト時に実行されるバイナリがなんかおかしいのか? と疑い始める。

テスト時に実行されるバイナリ

じゃあテスト時に実行されるバイナリはどこにあるのか。これは CodeLLDB 拡張機能のログを見るとわかる。

まず VS Code の下部のメニューの「OUTPUT」をクリックし、その次に拡張機能のリストから「LLDB」を選択。そうして表示されるログの先頭のほうに、program という項目があり、その値がテスト時に実行されるバイナリのパスになってる。
OUTPUT > LLDB と辿っていた先に拡張機能のログがあり、そこに書いてある

この program の部分がテスト時に実行されるバイナリになってる。実際に引数を伴って実行してみると、テストが実行できる。

$ ./target/debug/deps/oxc_parser-992051aabfe995a6 module_record::module_record_tests::import_default --exact --show-output

running 1 test
test module_record::module_record_tests::import_default ... ok

successes:

successes:
    module_record::module_record_tests::import_default

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 53 filtered out; finished in 0.00s

バイナリの中身を見る

まあ多分シンボル情報が入ってないんじゃないか、と思うので中身を見てみる。macOS なら dsymutil コマンドなどでできるらしい。

$ dsymutil --statistics target/debug/deps/oxc_parser-992051aabfe995a6
warning: no debug symbols in executable (-arch arm64)
.debug_info section size (in bytes)
-------------------------------------------------------------------------------
Filename                                           Object         dSYM   Change
-------------------------------------------------------------------------------
-------------------------------------------------------------------------------
Total                                                  0b           0b    0.00%
-------------------------------------------------------------------------------

warning: no debug symbols in executable を見るに、やっぱりシンボル情報入ってなかったね... これなら breakpoint 仕掛けても止まらないの当然だ。

何故シンボル情報が含まれてないのか

普通テストを実行する際はデバッグビルドが実行されて、シンボル情報が埋め込まれるはず。なのに何故埋め込まれていないのか。その理由は oxc リポジトリの Cargo.toml に書いてあった

// https://github.com/oxc-project/oxc/blob/ea3f362173247454190a737b9c0737fb26f43a3f/Cargo.toml#L241-L244 より引用
[profile.test]
# Disabling debug info speeds up local and CI builds,
# and we don't rely on it for debugging that much.
debug = false

コメントなるほどね... debug = true にして無事解決しました。

tsx と Node.js Type Stripping の違い

tsx は TypeScript コードを事前トランスパイルすることなく、直接 Node.js で実行するためのツール。

ところで最近の Node.js には Type Stripping という機能が入った。これを使うと、tsx なしで TypeScript コードを事前トランスパイルせずに実行できる。

両者の違い

一見すると両者は機能的に同じものかのように思うけど、実は結構違いがある。

import specifier の指定方法が異なる

最も大きな違いは、「import specifier」の指定方法。import specifier というのは、以下の部分のこと。

import { add } './math';
//             ^^^^^^^^ こことか

const { sleep } = await import('./util');
//                             ^^^^^^^^ ここのこと

tsx は import specifier の様々な指定方法に対応しているが、Node.js Type Stripping はかなり限られている。math.ts というモジュールを参照する場合を例にすると...

指定方法 tsx Node.js Type Stripping
'./math' OK NG
'./math.ts' OK OK
'./math.js' OK NG

tsx は bundler がサポートしているような指定方法を同じようにサポートしてて、多くの人が慣れ親しんだ挙動になってる。一方 Node.js Type Stripping は .ts の指定が必須で、クセが強い。

何故このような仕様になってるかというと、1つは実行時のオーバーヘッドを減らすため。というのも、Node.js の ESM には import speficier の拡張子を明示しなければならないという制約がある。

A file extension must be provided when using the import keyword to resolve relative or absolute specifiers.

https://nodejs.org/api/esm.html#mandatory-file-extensions

拡張子が明示されていれば、CJS or ESM をすぐに判定できる。しかし省略されてると、推測するためのオーバーヘッドが掛かる。それを嫌って、拡張子の明示が強制されている。Type Stripping でも同じで、拡張子が明示されていれば CJS or ESM をすぐ判定できる。それに JavaScript or TypeScript どちらとして処理すれば良いかも、すぐ判定できる。そのために Type Stripping で拡張子の明示を必須としたい、という動機があるらしい。

他にも色々理由があるらしい。拡張子の省略を許可すると require() に破壊的変更を加えることになってしまうから避けたいとか、math.tsmath.js が共存してる時どっちを読むのか曖昧で良くないとか。以下の issue に書いてある。

以下の関連する issue にも様々な理由が書かれていそうだった。けどコメントが多すぎて僕には追いきれなかった...。

tsx は JSX 対応してるが、Node.js Type Stripping は非対応

Node.js Type Stripping は型注釈の削除だけをやる軽量な実装になっており、JSX がサポートされてない。

Node.js Type Stripping では TypeScript 固有の機能に非対応

Enum, experimentalDecorators, namespaces などには対応してない。Node.js Type Stripping は型注釈の削除だけをやるので、こういう JavaScript にない機能は一切サポートしない。

補足: node --experimental-transform-types

実は --experimental-transform-types を渡すと、単なる型注釈の削除だけでなく、TypeScript 固有の機能のトランスパイルもされる。これを使うと、enum や namespace なども使えるようになる。

とはいえ enum や namespace は esbuild や swc といったトランスパイラでもサポートされておらず、現代では使うべきではないと言われている。よって --experimental-transform-types を使わずに済むなら、そうしたほうが良いと思う。

Node.js Type Stripping は tsconfig.json の paths に非対応

tsx は対応してるけど、Node.js Type Stripping は非対応。

Node.js Type Stripping...というか Node.js 自体が Subpath patterns import に対応してるので、それを使うと import alias っぽいことはできる。

どっちを使えば良いの?

せっかく Node.js に組み込まれてる機能があるのだから、Type Stripping が使えるならそうしたほうが良いと思う。しかし、それができないものもある。

バックエンドサーバー

Node.js で実行されているものなので、Node.js Type Stripping を使ったら良いと思う。

npm package

原則として tsx も Node.js Type Stripping も使うべきではない。というのも、npm にはトランスパイル済みのコードをアップロードするべきだから。Node.js も Type Stripping のおかげで直接 .ts を実行できるようにはなったが、今のところ npm package の .ts は Type Stripping の対象外としている。

To discourage package authors from publishing packages written in TypeScript, Node.js will by default refuse to handle TypeScript files inside folders under a node_modules path. https://nodejs.org/docs/latest/api/typescript.html

tsx や Node.js Type Stripping で開発をして、そのままトランスパイルせずに npm に公開する...ということをしたところで、ユーザの手元で動かない。そのため npm package の開発では tsx や Node.js Type Stripping を使わずに、tsc で事前ビルドするほうが良いと思う。

とはいえどうしても Node.js Type Stripping 使いたい時もあると思う。テスト実行のために Node.js Type Stripping 使いたいとか。そのような場合は、以下のような構成にすると良い。

  • コードベース全体を .ts 付きの import specifier で書く
  • node --test "src/**/*.test.ts" でテスト実行
  • --rewriteRelativeImportExtensions を有効にして tsc で本番向けビルドをする

これでコード上は import specifier を.ts 付きで書けて、最終的な成果物では .js にできる。しかし、一部ややこしい挙動もある。

一応 Node.js Type Stripping 使えなくはないし、使っても良いとは思うけど、オススメするかというと悩ましいな〜。どうしても Node.js Type Stripping を使いたくて、ややこしい挙動を理解した上で使うなら良いと思う。

スクリプトファイル

バッチファイルとか one-time script とかそういうの。基本的に Node.js で実行するものなので、Node.js Type Stripping が使えるならそうしたら良いと思う。

しかし Next.js を使っているプロジェクトで、スクリプトファイルと Next.js で一部モジュールを共有してる、とかだと話が変わってくる。例えば、以下のようなコードがあるとする。

// lib/prisma.ts
import { PrismaClient, type User } from '@prisma/client';
import { getDatabaseURL } from '@/lib/database';

export const prisma = new PrismaClient({
  datasourceUrl: getDatabaseURL(),
});

// 実際はこんな中身のない prisma の wrapper 書かないとは思うけど、仮でこういうのがあるとする。
export async function findUserById(id: string): Promise<User> {
  return prisma.user.findUnique({ where: id });
}

このモジュールは Next.js のコードから使われてて、かつスクリプトファイルからも使われているとする。

// scripts/ユーザ調査.ts
import { findUserById } from '@/lib/prisma';
console.log(await findUserById('123'));

何の変哲も無いスクリプトファイルに見えるけど、node scripts/ユーザ調査.ts するとコケる。

まず import { findUserById } from '@/lib/prisma'; が良くない。拡張子を省略をせず、tsconfig.jsonpaths も使わず、以下のように書くべき。

// scripts/ユーザ調査.ts
-import { findUserById } from '@/lib/database';
+import { findUserById } from '../lib/prisma.ts';
 console.log(await findUserById('123'));

依存先の lib/prisma.ts で拡張子の省略が行われるのも良くない。以下のように書くべき。

// lib/prisma.ts
 import { PrismaClient, type User } from '@prisma/client';
-import { getDatabaseURL } from '@/lib/database';
+import { getDatabaseURL } from './database.ts';
 // ...

このように bundler で実行してる部分とモジュールの共用をしようと思うと、コードベースの書き換えが必要になる。正直面倒だし、ややこしい。

拡張子は明示する、paths やめる、と書き換えていっても良いとは思うけど、それくらいならスクリプトファイルを tsx で実行したほうが楽な気はする。

CLI ツールの設定ファイル

eslint.config.ts, prettier.config.ts, vitest.config.ts など。これは場合による。そもそもこれらのファイルはユーザが実行するというよりは、CLI ツールが内部で読み取って、実行するタイプのもの。CLI ツール側で Node.js Type Stripping を使ったり、tsx を使ったりして実行している。TypeScript の実行に何を使っているかは、CLI ツールによって異なる。

例えば eslint.config.ts は jiti (tsx と同じようなやつ)、もしくは Node.js Type Stripping で実行される。

prettier.config.ts は Node.js Type Stripping で実行される。

vitest.config.ts は特殊で、Vite で bundle して .js に変換した後、Node.js で実行される。

vitest.config.ts は拡張子の省略はできるけど、prettier.config.ts はできない。eslint.config.ts は jiti で動かしてるなら省略できるけど、Node.js Type Stripping ならできない。難しいね...

まあ通常これらのファイルから他のモジュールを import することは稀なので、あんまり困らないとは思う。

おまけ: エディタによる import 文の補完を制御する

エディタ...というか TypeScript の Language Server には import 文を補完する機能がある。その補完で拡張子を省略するのか、明記するのかを制御するオプションが実はある。VS Code なら以下のオプションで制御できる。

  • "typescript.preferences.importModuleSpecifierEnding"
  • "javascript.preferences.importModuleSpecifierEnding"

あとは "@/lib/math" と補完するのか、"./math" と補完するのかを制御するオプションもある。

  • "typescript.preferences.importModuleSpecifier"
  • "javascript.preferences.importModuleSpecifier"

こういうのを上手く使うと、プロジェクト内で import 文の補完方法を上手く制御できるはず。

CSS Modules の拡張構文について

CSS Modules は、CSS をローカルスコープ化する仕組み。*.module.css に CSS を記述すると、bundler がクラスセレクターなどをユニークなものへと変換してくれる。クラスセレクターなどが *.module.css ファイルごとに異なる名前に変換され、擬似的にローカルスコープ化が実現される。

developer.hatenastaff.com

CSS Modules では、基本的には CSS の標準の構文をそのまま利用する。しかし、一部 CSS Modules 独自の構文がある。実際どのようなものがあるのかというのを、紹介する。

CSS Modules の公式ドキュメント (仕様) のリンクを貼りつつ紹介するが、公式ドキュメントの仕様は非常に緩く書かれているので注意すること。真の仕様を把握するには、postcss-modules などの実装も必ず見るように。

:local(), :global() 擬似クラス

擬似クラスの一種で、クラスセレクターをローカルスコープ化するのか、グローバルスコープ化するのかを切り替えるための構文。以下の公式ドキュメントで言及されてる。

以下のようにしてクラスセレクターを囲むと、そのスコープモードを変更できる。

:local(.button) {/* ... */}
/* Bootstrap の .btn-primary を使いたいので、:global(...) で囲む */
:global(.btn-primary) {/* ... */}

:global(...) は UI ライブラリ側で決め打ちされてるクラス名をそのまま使いたいなど、ローカルスコープ化されると困るときによく使う。一方 :local(...) については、基本的に付けても意味がない。通常 CSS Modules ではデフォルトでローカルスコープであるため。しかし css-loader ではデフォルトのスコープモードを変更することができる。デフォルトのスコープモードが "global" の場合、ローカルスコープ化するために :local(...) が使われる。

とはいえデフォルトのスコープモードを "global" にすることまずないので、:local(...) の出番はほぼないと思って良い。

参考情報:

:local, :global 擬似クラス

さっきのやつの括弧 () が無いバージョン。以下の公式ドキュメントで言及されてる。

括弧有りとの違いは、スコープモードの変更範囲である。括弧有りでは「括弧の中のものだけ」が、括弧無しでは「疑似クラスに続くもの全て」がスコープの変更対象となる。

/* a_1 はローカル、a_2, a_3 はグローバル */
.a_1 :global .a_2 .a_3 {}

/**
 * :local で途中でスコープモードを変更可能。
 * b_1, b_4 はローカル、b_2, b_3 はグローバル。
 */
.b_1 :global .b_2 .b_3 :local b_4 {}

...というのが表面的な仕様なのだけど、(CSS Modules の主要な実装である) postcss-modules ではもっと複雑な仕様になってる。セレクターリストや擬似クラスと組み合わせると、非常に奇妙な動作をする。ここで紹介するには難しすぎるので、興味ある人は css-modules-kit のテストケースにその挙動の説明が書いてあるので、それを見てほしい。

参考情報:

@value アットルール

アットルールの一種で、変数を宣言するための構文。以下の公式ドキュメントで言及されてる。

以下のようにして使う。

/* Header.module.css */
@value red: #FF0000;
@value headerHeight: 30px;

.header {
  height: headerHeight;
  background: red;
}

Value/Variables といっても bundle 時に展開されるもので、「bundle 時定数」「コンパイル時定数」などと言ったほうがわかりやすいかも。Sass の Variables にかなり近い。

ただし Sass の Variables と違い、変数が *.module.css から export される。以下のようにして、コンポーネントファイルから変数を参照できる。export される型は常に string

// Header.tsx
import styles from './Header.module.css';

styles.red; // '#FF0000'
styles.headerHeight; // '30px'

また、他の *.module.css から変数を import することもできる。

/* common.module.css */
@value red: #FF0000;
@value white: #FFFFFF;
/* Header.module.css */
@value red, white from './common.module.css';
@value headerHeight: 30px;

.header {
  height: headerHeight;
  background: red;
  color: white;
}

参考情報:

composes プロパティ

mixin みたいなことをするやつ。以下の公式ドキュメントで言及されてる。

あるルールセットで定義されてるプロパティを、別のルールセットに取り込める。

/* https://github.com/css-modules/css-modules/blob/master/docs/composition.md より引用 */
.className {
  color: green;
  background: red;
}

.otherClassName {
  composes: className;
  color: yellow;
}

上記のコードは以下と同じ意味になる。

.className {
  color: green;
  background: red;
}

.otherClassName {
  background: red;
  color: yellow;
}

複数のルールセットをまとめて取り込んだり、複数の他のファイルのものを取り込むこともできる。

.a_1 {
  color: green;
  background: red;
}
.a_2 {
  composes: common_1 from './common.module.css';
}
.a_3 {
  composes: a_1 a_2;
}

composes: global_1 from global; みたいにグローバルから取り込む機能もあるらしいが、正直どういう挙動をするのか id:mizdra はよくわかってない。どのスタイルシートから取り込まれるの?

@keyframes :local(<custom-ident>), @keyframes :global(<custom-ident>)

実は CSS Modules は、デフォルトで @keyframes で定義した名前をローカルスコープ化する。ローカルスコープ化されるということは、それをグローバルスコープ化したい時もある訳で、そのための構文がこれ。CSS 標準では @keyframes <custom-ident> だけど、CSS Modules ではそれを拡張して :local():global() で囲めるようにしてる。

公式ドキュメントでの言及は僅かで、以下で @keyframes :global(<custom-ident>) という構文があることが匂わされてるくらい。

@keyframes :global(<custom-ident>) だけでなく、デフォルトのスコープモードが "global" の時のために、@keyframes :local(<custom-ident>) も用意されてる。

参考情報:

animation, animation-name プロパティ

@keyframes で定義した名前は animation-name と、その一括指定プロパティである animation からも参照できるので、それも拡張されてる。公式ドキュメントでの言及はちょっとだけ。

@keyframes と同じく、アニメーションの名前を :local(), :global() で囲めるような拡張がされてるだけ。

var(<custom-property-name> from <string>)

実は lgithningcss では、カスタムプロパティ もローカルスコープ化される。

/* https://lightningcss.dev/css-modules.html#local-css-variables より引用 */
:root {
  --accent-color: hotpink;
}
.button {
  background: var(--accent-color);
}

加えて他の *.module.css のカスタムプロパティを参照する機能もある。そのための拡張構文が var(<custom-property-name> from <string>) である。

/* https://lightningcss.dev/css-modules.html#local-css-variables より引用・一部改変 */
.button {
  background: var(--accent-color from './vars.module.css');
}

ちなみにこの機能は lightningcss 独自のもので、postcss-modules などの他の実装ではサポートされてない。CSS Modules の公式ドキュメントでも一切言及がない。

:import, :export

ICSS で使われる構文として、:import, :export がある。ICSS は postcss-modules 内で使われる中間表現で、import/export するクラスセレクターなどが明示的に記述される。その明示的な記述を行うための拡張構文がこれ。

詳しくは以下のリポジトリの README を見ると雰囲気がわかる。

まず人間が手で書くことはないので、忘れて良い。

拡張構文の問題点

これらの拡張構文だが、問題点が色々ある。例えば @keyframes :global(.foo) が奇妙という点。セレクターを書く場所じゃないのに、擬似クラスの構文 (:global()) が使われていて変だと思う。

xxx from './vars.module.css' のような構文が animation プロパティなどで使えなくて、一貫性がないのも微妙。そもそも仮に animation: slide-in from './vars.module.css' と書けたとして、どれがアニメーションの名前で、easing-function の名前なのか定まらなくて、破綻してしまうと思う。

拡張構文があることで将来の CSS の新構文と衝突してしまう恐れがあるのも問題だと思う。@value アットルールや composes プロパティなど全く独自のものならともかく、var(), @keyframes, animation-name など既存の構文を拡張しているものは、結構危ういと思う。できるだけ CSS の標準の構文を使用することで CSS の仕様変更に追従しやすい、というのが CSS Modules の良さだと個人的には思ってて、そこが損なわれてるのが勿体ないと思う。

理想的にはどうあるべきか

:global(), xxx from global, xxx from <string> といった記法を廃止すべきだと思う。代わりに、以下のように「ある識別子をグローバルスコープにするのか、他のファイルから import してくるのか」を示せる構文を追加する。

@cm-global btn-primary, slide-in;
@cm-import red, flex from './common.module.css';

/* a_1 はローカル, btn-primary はグローバル */
.a_1 .btn-primary {}

/* slide-in はグローバル */
@keyframes slide-in {}

/* a_2 はローカル, red は common.module.css のもの */
.a_2 .red {}

.a_3 {
  /* flex は common.module.css のもの */
  composes: flex;
}

一応似たような提案が以下の Issue で行われているようだった。議論が進むと良いなー。

ポケットモンスター・ポケモン・Pokémon・は任天堂・クリーチャーズ・ゲームフリークの登録商標です.

当ブログは @mizdra 個人により運営されており, 株式会社ポケモン及びその関連会社とは一切関係ありません.