Deno に“守り”のコントリビュートをしてきた話
primordials.js について
Deno が提供している API は多くが JavaScript で実装されています。もし何も考えずに単純な JavaScript のコードで API を実装してしまっていた場合、ネイティブのプロトタイプメソッドが変更されてしまったときに影響を受けてしまいます。
この問題を回避するため Deno の内部コードでは primordials.js というものが用意されてあります。一旦ネイティブのメソッドをキャッシュしておいて、通常のメソッド呼び出しを使わずにこちらを使うようになっています。
例えばコンソールで表示するために、Error
オブジェクトの cause
プロパティを辿って文字列化する実装の序盤を見てみましょう。
ここに出てくる ArrayPrototypeIncludes
や ArrayPrototypePush
がまさしく primordials.js から読み込んだ函数です。
もし仮に ArrayPrototypeIncludes(causes, err.cause)
という処理が causes.includes(err.cause)
となっていた場合、Array.prototype.includes
が書き換えられるとそちらが呼び出されてしまいます。それを回避するためにこのような内部実装になっているわけです。
primordials.js について詳しくは以前の記事を参照してください。
“守り”のコントリビュート
promordials.js に準拠したコードはとてもとても JavaScript らしくありません。一応 deno_lint に prefer-primordials という Deno の内部コードにのみ使われることが想定されたルールがあり、Deno に対する PR は全て CI によってチェックされるようになっていますが、それでも Deno にプロトタイプ汚染耐性のないコードが取り込まれてしまうことがあります。
このことに問題意識を持った私はプロトタイプ汚染を防ぐ“守り”のコントリビュートをしてきました。一つずつどういうことをやってきたか振り返っていきたいと思います。
Iterable
の扱い
1. Deno の内部コードで普通にスプレッド構文や for-of
が使われていました。しかしこれらのシンタックスは Symbol.iterator
メソッドを実行してしまいます。
// Array.prototype[Symbol.iterator] を書き換える
const original = Array.prototype[Symbol.iterator];
Array.prototype[Symbol.iterator] = function () {
console.log("bang!");
return original.call(this);
};
[1, 2, ...[3, 4]]; // bang!
primordials.js 的には配列をイテレートする際には SafeArrayIterator
クラスでラップすることになっていましたが prefer-primordials で検知できておらず、徹底されていませんでした。
// こうすると安全
[1, 2, ...new SafeArrayIterator([3, 4])];
というわけで deno_lint に PR を出し、ちゃんと prefer-primordials で検知されるようにしました。
instanceof
演算子
2. instanceof
演算子は Symbol.hasInstance
メソッドによってカスタマイズ可能です。
// 代入演算子では Function.prototype[Symbol.hasInstance]
// を書き換えようとして例外が発生する(書き換え不可能)
// Object.defineProperty を使えば回避できる
Object.defineProperty(Error, Symbol.hasInstance, {
value(target) {
// Error なのに配列かどうかチェックする処理にしてしまえる
return Array.isArray(target);
},
});
// true
[1, 2, 3] instanceof Error;
代わりに primordials.js の ObjectPrototypeIsPrototypeOf
(Object.prototype.isPrototypeOf
) を使えば安全になります。
// false
ObjectPrototypeIsPrototypeOf(Error.prototype, [1, 2, 3]);
問題提起したところ ah-yu さんが prefer-primordials で検知されるようにしてくれました。
Promise.prototype.then
が呼ばれてしまう
3. 書き換えた ある日のこと。Deno の CLI でなんとなく Promise.prototype.then
を書き換えたところ、それが呼ばれてしまうことを見つけてしまいました。
Deno v1.26.1 CLI で書き換えた Promise.prototype.then
が呼ばれている様子
これは原因がすぐにはわかりませんでしたが、ECMAScript の仕様を読みながら原因を探り、なんとか Promise.all
の問題だと突き止めました。Promise.all
が問答無用で引数の Promise
の "then"
メソッドを呼び出す仕様なため、PromiseAll
としてキャッシュしていても十分ではありませんでした。
ECMAScript の PerformPromiseAll
の仕様
そこで primordials.js に新たに SafePromiseAll
という函数を追加し、そちらを使うことで解決しました。
for-in
のプロパティ所有チェック漏れ
4. for-in
を使った場合[1]、そのオブジェクトの [[Prototype]]
が持つ列挙可能なプロパティも一緒に列挙してしまいます。
// Object.prototype を汚染
Object.prototype.foo = 1;
const obj = { bar: 2 };
for (const key in obj) {
console.log(key); // foo, bar
}
そのため for-in
を使う場合は一緒に Object.hasOwn
を使うなどして、ちゃんとプロパティを所有しているかどうかチェックする必要があります。
for (const key in obj) {
if (!Object.hasOwn(obj, key)) {
continue;
}
console.log(key);
}
Deno の内部コードで一部このチェックが漏れている箇所を発見したため修正しました。
また再発しないように deno_lint に ESLint の guard-for-in を実装しました。
終わりに
以上が「Deno に“守り”のコントリビュートをしてきた話」となります。
世の中、新しい機能を追加するといった所謂“攻め”のコントリビュートの方が目立ちますが、折角ならとこのような記事を書いてみました。
最後まで読んでいただきありがとうございました!
Discussion
続編を書きました。