【俗・】Deno に“守り”のコントリビュートをしてきた話
Deno に“守り”のコントリビュートをしてきた話の続編です。読んでない方はまずそちらから御覧ください。
primordials.js について
Deno が提供している API は多くが JavaScript で実装されています。もし何も考えずに単純な JavaScript のコードで API を実装してしまっていた場合、ネイティブのプロトタイプメソッドが変更されてしまったときに影響を受けてしまいます。
この問題を回避するため Deno の内部コードでは primordials.js というものが用意されてあります。
継続的な“守り”のコントリビュート
前回の記事から半年弱が経ちましたが、相変わらず継続的に primordials.js 周りの“守り”のコントリビュートを続けていました。また振り返っていきたいと思います。
5. for-of をやめてパフォーマンスの問題を緩和
前回 primordials.js の SafeArrayIterator 等の紹介をしました。しかし実はこの SafeArrayIterator 等は生成コストがあり、パフォーマンスに問題があることがわかりました。
とりあえず for-of については伝統的な for 文へと置き換えることで SafeArrayIterator 等の出番を減らし、ある程度パフォーマンスの問題を緩和しました[1]。
6. 配列形式の分割代入
前回の記事を書きながら気づいたのですが、Symbol.iterator メソッドはスプレッド構文、for-of の他にも配列形式の分割代入でも実行されてしまいます。
// Array.prototype[Symbol.iterator] を書き換える
const original = Array.prototype[Symbol.iterator];
Array.prototype[Symbol.iterator] = function () {
console.log("bang!");
return original.call(this);
};
const [a, b] = [1, 2]; // bang!
const [c, ...d] = [3, 4]; // bang!
これは deno_lint で代入演算子の右辺が SafeArrayIterator 等でラップされているかを検知する機能を追加すれば解決します……が、前述した通りパフォーマンスに問題があります。
ということで配列形式の分割代入を検知しオブジェクト形式の分割代入を使うようにサジェストするようにしました。ただしレスト構文がある場合についてはどうしようもないので SafeArrayIterator 等でラップするようサジェストします。
const { 0: a, 1: b } = [1, 2];
const [c, ...d] = new SafeArrayIterator([3, 4]);
配列もオブジェクトの一種[2]なのでこれでうまくいきます。
7. アクセサプロパティ
ECMAScript に ArrayBuffer.prototype.byteLength など、アクセサとして定義されているプロパティがあります。これらはもちろん汚染できてしまうため、primordials.js 的には ArrayBufferPrototypeGetByteLength など適切な函数を使うべきですが徹底されていませんでした。
これらは TypeScript の型情報を扱えば簡単に検知できそうですが、現状 deno_lint では TypeScript の AST を扱うことは出来るものの型情報を扱うことができません[3]。そこで単にプロパティ名で判定することにしました。
これだと Deno の内部コードで "byteLength" のような同じプロパティ名を使った場合に誤検知を引き起こしますが、その場合は明示的に // deno-lint-ignore prefer-primordials コメントを付けて無視することとし、安全側に倒すこととしました。
メソッドも同様に対応
アクセサプロパティ同様にメソッドについても徹底させるべくプロパティ名で検知するようにしました。こちらは数が多いので deno_lint の実装というよりかは一つずつ Deno の内部コードから lint エラーを潰していくのが大変でした……。
8. SafeRegExp の導入
ECMAScript には主要な機能が他のプロパティに依存する形で定義されているものがあります。例えば Set のコンストラクタは "add" メソッドを使うように定義しているため Set.prototype.add を汚染するとコンストラクタに影響を及ぼします。このため primordials.js では SafeSet というラップしたクラスが提供されています。
さて例によって仕様を眺めていたところ RegExp のメソッドはその大部分が "exec" メソッドに依存していることに気づきました。しかし primordials.js でそのラッパークラスが提供されていません。
というわけで SafeRegExp というラッパークラスを primordials.js に追加し、内部コードではこちらを使うようにしました。
セーフなラッパーを使うように検知
ついでに primordials.js のセーフなラッパーがちゃんと使われているかどうかを検知する機能を deno_lint の prefer-primordials ルールに追加しました。
9. RegExp を受け取る String のメソッド
ECMAScript には String.prototype.split のように引数に文字列と RegExp のいずれかを受け取るメソッドがあります。現状の仕様によるとそのどっちを受け取ったかを判定するのに Symbol.split メソッドの有無で判定しています。
つまり String.prototype[Symbol.split] を汚染することで String.prototype.split に文字列を渡した際に影響を与えることができることを発見しました。
String.prototype[Symbol.split] = () => "foo";
"bar baz".split(" "); // "foo"
これは primordials.js の StringPrototypeSplit を使ったとしても解決しません。
色々と回避方法を考えましたが、これは Deno の問題というよりかは ECMAScript 側の問題だと思ったため、プリミティブ値に対しては Symbol メソッドの有無をチェックしないようにする Normative Change のリクエストを出しました。
Normative Change を通すには TC39 meeting での合意が必要で、まだまだ時間がかかりそうです。気長に待とうと思います。
終わりに
前回に引き続き、JavaScript アプリケーション開発ではあまり触れないところの話をしました。楽しんでいただけたら幸いです。
他にも仕様に関する様々な記事を書いていたり、毎回の TC39 meeting の決定をまとめていたりします。よろしければそちらもご覧ください。
おまけ
Object.prototype.toJSON を追加してプロトタイプ汚染することで JSON.stringify を使い物にできなくすることができます。
Object.prototype.toJSON = () => "foo";
JSON.stringify({ bar: "baz" }); // '"foo"'
誰か ECMAScript にこの問題を回避する Normative Change を送ってみてもいいかもしれません。自分にはいい方法が思いつきませんでした。
【2023/06/12 追記】
2023/05 の TC39 meeting の議事録が公開され、Decorator Metadata のために Function.prototype[Symbol.metadata] に non-configurable かつ non-writable な null を定義する議論から Object.prototype.toJSON にも似た方法で対処できるのではないかと思い、議論場所を作りました。
-
本質的な解決方法としてプロトタイプが汚染されてないか都度チェックして、
SafeArrayIterator等でラップするかどうかを判断するようにすればよいのではと考えています(issue)。 ↩︎ -
ECMAScript では配列は Array Exotic Objects として定義されています。
lengthプロパティを特別扱いしますが、普通のオブジェクトと同じように扱うことが出来ます。 ↩︎ -
現状 TypeScript の型チェックは Microsoft による tsc しかありません。つまり JavaScript エンジンを使わなければ型チェック出来ないことを意味します。swc のメイン開発者である kdy1 氏が Rust による型チェッカー stc を実装中で、これが実用化されれば将来的に deno_lint でも型情報が扱えるようになるかもしれません。 ↩︎
Discussion