【俗・】Deno に“守り”のコントリビュートをしてきた話
Deno に“守り”のコントリビュートをしてきた話の続編です。読んでない方はまずそちらから御覧ください。
primordials.js について
Deno が提供している API は多くが JavaScript で実装されています。もし何も考えずに単純な JavaScript のコードで API を実装してしまっていた場合、ネイティブのプロトタイプメソッドが変更されてしまったときに影響を受けてしまいます。
この問題を回避するため Deno の内部コードでは primordials.js というものが用意されてあります。
継続的な“守り”のコントリビュート
前回の記事から半年弱が経ちましたが、相変わらず継続的に primordials.js 周りの“守り”のコントリビュートを続けていました。また振り返っていきたいと思います。
for-of
をやめてパフォーマンスの問題を緩和
5. 前回 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 エラーを潰していくのが大変でした……。
SafeRegExp
の導入
8. ECMAScript には主要な機能が他のプロパティに依存する形で定義されているものがあります。例えば Set
のコンストラクタは "add"
メソッドを使うように定義しているため Set.prototype.add
を汚染するとコンストラクタに影響を及ぼします。このため primordials.js では SafeSet
というラップしたクラスが提供されています。
さて例によって仕様を眺めていたところ RegExp
のメソッドはその大部分が "exec"
メソッドに依存していることに気づきました。しかし primordials.js でそのラッパークラスが提供されていません。
というわけで SafeRegExp
というラッパークラスを primordials.js に追加し、内部コードではこちらを使うようにしました。
セーフなラッパーを使うように検知
ついでに primordials.js のセーフなラッパーがちゃんと使われているかどうかを検知する機能を deno_lint の prefer-primordials ルールに追加しました。
RegExp
を受け取る String
のメソッド
9. 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