PHX 言語仕様 (v0.2)
公式サイト: https://phx-lang.org
PHX は、PHP の「保存してすぐ動く」「Web に特化」「HTML に埋め込める」という良さを引き継ぎながら、 型安全性・セキュリティ・実行速度・保守性を言語の側で保証する Web 向けの言語です。 既存の PHP コードとの互換性はありませんが、PHP を書いてきた人がすぐ読めて書けることを目指しています。
1. 設計方針
| PHP の弱点 | PHX での解決 |
|---|---|
暗黙の型変換 ("1" + 1、"abc" == 0) |
暗黙の変換はしない。== は常に厳密比較。違う型どうしの比較はコンパイルエラー |
| null による実行時エラー | ?T 型。null チェック (!= null、is、??、?->) をしないと使えない |
| XSS | <?= ?> と Web ページへの echo は自動で HTML エスケープ。エスケープしないのは Html 型の値だけ |
eval、可変変数、extract、動的な include |
言語に存在しない。モジュールは静的な import、テンプレートはリテラルのパスだけ |
| SQL インジェクション | SQL は sql"..." リテラル(Sql 型)でしか書けず、埋め込んだ値は必ずプレースホルダになる |
| 文脈を無視したエスケープ | <?= ?> の位置(属性・URL・<script>・CSS)を判定し、エスケープでは守れない場所への挿入はコンパイルエラー |
| CSRF・セッション固定 | フォームの POST はサーバーがトークンを自動で検査。セッション ID はサーバーが発行したものだけを受け付ける |
| 何でもできるスクリプト | ファイル・環境変数へのアクセスは phx.toml の [permissions] で許可したものだけ |
| 注入されたスクリプトの実行 | 既定の Content-Security-Policy で、テンプレートに書いた <script> 以外のインラインスクリプトを止める |
| ヘッダーインジェクション | 改行を含むヘッダー値は ValueError |
| パストラバーサル | サーバーは public/ の外、ドットファイル、テンプレートを配信しない |
| リクエスト間で状態が漏れる | リクエストごとに新しいヒープ。静的プロパティもグローバル変数もない |
| 実行速度 | 静的型に特化したレジスタ型バイトコード VM |
| 大規模化すると保守しにくい | 型注釈の付いた関数、モジュールと export、読みやすいエラーメッセージ |
2. ファイル
.phx… スクリプトまたはモジュール。コードモードで始まる。先頭の<?phxは書いても書かなくてもよい。.phx.html… テンプレート。HTML モードで始まる。- コードモードで
?>を書くと HTML モードに切り替わる。HTML モードでは<?phx(または<?)でコードに、<?= 式 ?>で値の出力に戻る。?>の直後の改行 1 つは出力されない(PHP と同じ)。 - 文字コードは UTF-8。
3. 字句
- 変数は
$name。$$name(可変変数)はない。 - コメントは
// ...、# ...、/* ... */。 - 整数:
42、1_000_000、0xFF、0b1010、0o17。64 ビット。オーバーフローはArithmeticError。 - 浮動小数点数:
1.5、1e-3。 - 文字列:
'...'… 展開なし。エスケープは\'と\\だけ。"..."…$nameと{$式}を展開する。エスケープは\n \t \r \0 \e \\ \" \$ \{ \u{1F600}。
- 予約語:
function fn return if elseif else endif while endwhile do for endfor foreach endforeach as break continue echo class extends new public private protected readonly static true false null match default try catch finally throw import export use is instanceof interface implements abstract final enum case const
4. 型
| 型 | 説明 |
|---|---|
int |
64 ビット整数 |
float |
64 ビット浮動小数点数 |
string |
UTF-8 文字列 |
bool |
true / false |
null |
null だけの型。普通は ?T の一部として使う |
void |
戻り値がないことを表す(戻り値の型にだけ使える) |
Html |
安全な HTML として扱ってよい文字列。raw()、escape()、テンプレートが作る |
SafeUrl |
URL 属性の先頭に置いてよい Html。safe_url() が作る |
SafeJs |
<script> の中に置いてよい Html。json_html() が作る |
any |
どんな値でも入る。使う前に is で絞り込む必要がある |
list<T> |
要素の型が T の配列(0 から始まる連番) |
map<K, V> |
挿入順を保つ連想配列。K は int か string |
?T |
T または null(`T |
| `A | B` |
fn(A, B): R |
関数(クロージャ)の型 |
| クラス名 | オブジェクトの型。self はそのクラス自身 |
PHP の array は list<T> と map<K, V> に分かれています。mixed は any です。
代入できる条件
- 同じ型、サブクラスから親クラス、
Tから?Tやそれを含むユニオン、任意の型からany。 intはfloatが期待される場所では自動でfloatに変換される(唯一の暗黙の変換。値の意味が変わらないため)。list<int>をlist<float>に渡すことはできない(要素の型は不変)。- それ以外の変換は明示的に書く:
(int)$float、(float)$int、(string)$x、$s->toInt()、$s->toFloat()。(int)"12"のような文字列からのキャストはできない(->toInt()は数値でなければnullを返す)。(bool)キャストはない。
5. 変数
PHP と同じく、最初の代入で変数が宣言される。そのとき値の型が変数の型になり、あとから別の型は代入できない。
$count = 1; $count = "two"; // エラー: expected `int`, found `string`型を推論できないとき(
nullや空の[])は型を書いて宣言する。?User $found = null; list<string> $names = []; map<string, int> $counts = [];変数のスコープは関数全体(PHP と同じ)。ただし 一部の経路でしか代入されない変数は使えない。
if ($ok) { $msg = "hi"; } echo $msg; // エラー: variable `$msg` might not be defined hereforeachとcatchの変数は、同じ名前の既存の変数と型が違うとき、新しい変数としてそれを隠す (catch (A $e)とcatch (B $e)を続けて書ける)。$thisは非 static メソッドの中だけで使える。代入はできない。
型の絞り込み (narrowing)
条件によって、ローカル変数の型が狭められる。
function greet(?User $u): string {
if ($u == null) {
return "guest";
}
return $u->name; // ここでは $u は User
}
if ($v is int) { ... } // ここでは $v は int
$name = $req->query("name") ?? "world"; // string
$len = $user?->email?->len(); // ?int!= null、== null、is、!、&&、||、早期 return / throw / break / continue が絞り込みに使われる。
絞り込みの対象はローカル変数と、ローカル変数のプロパティ($this->user != null)。プロパティの絞り込みは、
関数やメソッドの呼び出し、プロパティへの代入があると解除される(呼び出しが値を変えたかもしれないため)。
6. 演算子
優先順位の高い順:
| 演算子 | 説明 |
|---|---|
() [] -> ?-> :: |
呼び出し、添字、メンバー |
** |
べき乗(右結合) |
is instanceof |
型の判定 |
! - ~ (int) など |
単項演算子、キャスト |
* / % |
/ は常に float を返す。整数の割り算は intdiv() |
+ - |
数値どうしだけ。int と float が混ざると float |
. |
文字列の連結。string、int、float を連結できる。Html . Html は Html |
<< >> |
ビットシフト |
.. ..= |
範囲(0..$n は $n を含まない、1..=$n は含む) |
< <= > >= |
数値どうし、または文字列どうし |
== != <=> |
== は常に厳密。=== は使わない(書くとエラーで == を案内する) |
& ^ | |
ビット演算(int だけ) |
&& |
論理積(bool だけ) |
|| |
論理和(bool だけ) |
?? |
左辺が null なら右辺。$m["k"] ?? $d はキーがなくても例外にならない |
? : |
三項演算子 |
- 真偽値への暗黙変換(truthiness)はない。
if ($xs)はエラーで、if (!$xs->isEmpty())と書く。 - 代入は式ではなく文:
=、+=、-=、*=、/=、%=、**=、.=、??=、++、--。 - 文字列の添字アクセス(
$s[0])はない。$s->slice(0, 1)を使う。
7. 制御構文
if (...) { } elseif (...) { } else if (...) { } else { }
while (...) { }
do { } while (...);
for ($i = 0; $i < 10; $i++) { }
foreach ($list as $value) { }
foreach ($list as $index => $value) { }
foreach ($map as $key => $value) { }
foreach (0..$n as $i) { } // 範囲は配列を作らずにループする
break; continue; break 2; continue 2;テンプレート向けの書き方も使える: if (...): ... elseif (...): ... else: ... endif;、
foreach (...): ... endforeach;、while (...): ... endwhile;、for (...): ... endfor;
foreach は開始時点の配列のコピーを回る(PHP と同じ値のセマンティクス)。
match
$text = match ($code) {
200, 201 => "ok",
404 => "not found",
default => "other",
};
$size = match (true) {
$n < 10 => "small",
default => "large",
};比較は ==(厳密)。どの腕にも当てはまらず default もないときは UnhandledMatchError。
例外
try {
risky();
} catch (NotFound | InvalidArgumentException $e) {
echo $e->getMessage();
}
throw new RuntimeException("boom");
$port = $config["port"] ?? throw new ValueError("port is required");try {
$db->execute(sql"...");
} finally {
$lock->release(); // return / break / continue / 例外のどれで抜けても実行される
}投げられるのは Exception かそのサブクラスだけ。
8. 関数
function greet(string $name, int $times = 1): string { ... }
greet("Ann");
greet(name: "Bob", times: 3); // 名前付き引数- 引数の型は必須。戻り値の型を省略すると
void。 - 値を返す関数は、すべての経路で
returnしないとエラー。 - デフォルト値は定数(リテラル、
null、[])だけ。 - 関数は値ではない。関数を渡すときはクロージャで包む。
- 型パラメータを持つジェネリック関数を書ける(§9 の「ジェネリック関数」)。
クロージャ
$double = fn(int $x): int => $x * 2; // 外側の変数を自動でキャプチャ
$names->map(fn($n) => $n->upper()); // 引数の型は文脈から推論される
$counter = function () use ($start): int { return $start + 1; };- キャプチャはすべて値渡し(作られた時点のコピー)。参照キャプチャ(
&$x)はない。 - メソッドの中で作ったクロージャは
$thisを使える。
9. クラス
class User {
private int $loginCount = 0;
public readonly string $email;
public function __construct(
public readonly int $id, // コンストラクタでのプロパティ昇格
public string $name,
string $email,
) {
$this->email = $email;
}
public function login(): void {
$this->loginCount++;
}
public static function guest(): User {
return new User(0, "guest", "guest@example.com");
}
}
class Admin extends User {
public function __construct(int $id, string $name) {
parent::__construct($id, $name, "admin@example.com");
}
}- プロパティには型が必須。
nullを許さずデフォルト値もないプロパティは、コンストラクタの先頭で代入しなければならない (コンパイル時にチェックされる)。 readonlyプロパティは、そのクラスのコンストラクタの中でしか代入できない。- 可視性は
public/protected/private(コンパイル時にチェック)。コンストラクタも同じで、private function __construct(...)のクラスはそのクラスの中(static メソッドなど)でしかnewできない (DatabaseはこのためDatabase::open()でしか作れない)。 - 単一継承。メソッドのオーバーライドは引数の型が同じで、戻り値の型が互換であること。親のコンストラクタを持つ
クラスで初期化の必要なプロパティがある場合、子のコンストラクタは
parent::__construct(...)を呼ぶ必要がある。 staticメソッドはClassName::method()で呼ぶ。static プロパティはない(リクエスト間で状態を共有させないため)。- オブジェクトは参照、
listとmapは値(コピーオンライト)。
interface と abstract
interface Shape {
const UNIT = "cm";
public function area(): float;
}
interface Named extends Shape {
public function name(): string;
}
abstract class Base implements Named {
public function label(): string {
return $this->name() . ": " . $this->area() . self::UNIT;
}
}
final class Square extends Base {
public function __construct(private float $side) {}
public function area(): float { return $this->side ** 2; }
public function name(): string { return "square"; }
}- interface はメソッドのシグネチャと定数だけを持つ。
extendsで複数の interface を継承できる。 abstractメソッドは本体を持たない。abstract メソッドを持つクラスはabstract classと宣言する。- 具象クラスが interface や abstract のメソッドを実装していない、またはシグネチャが合わないときはコンパイルエラー。
- interface、abstract クラス、enum は
newできない。 - interface 型の値へのメソッド呼び出しは名前で振り分けられる。クラス型の値へは vtable で振り分けられる。
enum
enum Suit {
case Hearts;
case Spades;
public function color(): string {
return match ($this) {
Suit::Hearts => "red",
Suit::Spades => "black",
};
}
}
enum Status: string { // int か string の値を持つ enum
case Active = "active";
case Banned = "banned";
}
Suit::Hearts->name; // "Hearts"
Status::Active->value; // "active"
Status::from("banned"); // Status::Banned(見つからなければ ValueError)
Status::tryFrom("x"); // null
Status::cases(); // list<Status>各 case は 1 回の実行(1 リクエスト)の中でただ 1 つのオブジェクトなので、== で比べられる。
定数
const MAX_ITEMS = 50; // ファイルの先頭レベル(export / import もできる)
class Config {
const VERSION = "1.0";
}
echo MAX_ITEMS, Config::VERSION, self::VERSION;定数の値はリテラル(int、float、string、bool、null)だけ。
ジェネリック関数
function first_or<T>(list<T> $xs, T $fallback): T {
return $xs->isEmpty() ? $fallback : $xs[0];
}
$n = first_or([5, 6], 0); // int
$s = first_or([], "none"); // string型引数は呼び出しの引数から推論される(最大 4 個)。関数の中では T は中身の分からない型として扱われ、
T の値に演算子やメソッドは使えない。ジェネリッククラスはまだない。
10. 例外クラス
すべてのファイルから使える。
Exception (getMessage(): string)
├── RuntimeException
│ └── DatabaseError SQL のエラー
├── LogicException
│ └── InvalidArgumentException
├── ArithmeticError 整数のオーバーフローなど
│ └── DivisionByZeroError 0 での割り算・剰余
├── IndexError list の範囲外アクセス
├── KeyError map にないキー
├── ValueError 不正な引数(ヘッダーの改行など)
├── UnhandledMatchError
├── PermissionError phx.toml で許可されていないファイル・環境変数へのアクセス
└── AssertionError assert() / assert_eq() の失敗実行時間の上限を超えたときなどの致命的なエラーは捕捉できない。
11. モジュール
// lib/posts.phx
export class Post { ... }
export function all_posts(): list<Post> { ... }
function helper(): int { ... } // export しないものは外から見えないimport lib.posts.{Post, all_posts};
import lib.posts.Post;
import std.http.{request, response};import a.b.{x, y};は、プロジェクトのルート(phx.tomlのあるディレクトリ、なければ実行するファイルの ディレクトリ)からのa/b.phxを読み込む。- import されるモジュールのトップレベルには宣言(
function、class、interface、enum、const、import) しか書けない。モジュールを読み込んでも副作用は起きない。 std.*は標準モジュール(std.http、std.db)。[dependencies]に書いたパッケージはimport パッケージ名.モジュールで読み込む。- 読み込めるのはプロジェクトとパッケージのディレクトリの中のファイルだけ(シンボリックリンクで外に出ることはできない)。
12. テンプレート
<?phx
import lib.posts.Post;
params(string $title, list<Post> $posts);
?>
<h1><?= $title ?></h1>
<?phx foreach ($posts as $post): ?>
<li><a href="/post?id=<?= $post->id ?>"><?= $post->title ?></a></li>
<?phx endforeach; ?>$html = render("views/list.phx.html", title: "Posts", posts: $posts); // Html
echo render("views/layout.phx.html", title: "Home", body: $html);- テンプレートは
params(...)で型付きの引数を宣言する。render()の呼び出しはコンパイル時に型チェックされる。 render()のパスは文字列リテラルで、プロジェクトのルートからの相対パス(..は使えない)。拡張子は.phx.html。render()はHtmlを返すので、別のテンプレートにそのまま渡して埋め込める(レイアウト)。
13. 出力とエスケープ
出力先には「テキスト」と「HTML」の 2 種類がある。
| 状況 | 出力先 |
|---|---|
phx run で実行したスクリプト |
テキスト(--html を付けると HTML) |
| Web サーバーが実行するページ | HTML(response()->type("application/json") などで HTML 以外にするとテキスト) |
| テンプレートの中 | 常に HTML |
HTML の出力先に string を書くと & < > " ' がエスケープされる。Html 型の値と、テンプレート中の
HTML 部分はそのまま出力される。echo と <?= ?> は同じ規則に従う。
escape($s)/htmlspecialchars($s)… エスケープ済みのHtmlを返す。raw($s)… 信頼できるマークアップをHtmlとして扱う(XSS に注意して使う)。nl2br($s)… エスケープしてから改行を<br>にしたHtml。safe_url($url)…http(s)、mailto、tel、相対 URL だけを通し、javascript:などは#にしたSafeUrl。json_html($value)…<script>の中に置いても安全な JavaScript の値(JSON)をSafeJsで返す。 文字列の"はそのままなので、属性値の中には置けない(コンパイルエラー。escape(json_encode($v))を使う)。SafeUrlとSafeJsはHtmlとしても使える(逆はできない)。escape()の結果はHtmlなので、 URL や<script>の中には置けない。HTML のエスケープではjavascript:やスクリプトのコードを止められないため。Htmlとstringは.で連結できない(エラーでescape()を案内する)。- エスケープを外す Content-Type は
text/plain、application/json、text/csv、text/css、text/javascript、text/markdown、text/calendar、application/pdf、application/octet-stream、画像・音声・動画などに限る (どれもブラウザが HTML として表示しない型。X-Content-Type-Options: nosniffも常に付く)。 HTML・XML・SVG・知らない型ではエスケープしたまま。
HTML の文脈の検査
HTML エスケープが守れるのは、要素の中身と引用符で囲んだ属性値だけ。<?= ?> と echo がそれ以外の場所に
あるとコンパイルエラーになる。場所の判定はブラウザの構文解析(WHATWG)に合わせている:
<textarea>・<title> などの中身、<script> の中の <!--、<!--> や --!> のコメント、<svg> の中、
</scripts> のように終了タグに見えて違うもの。
| 場所 | 例 | 扱い |
|---|---|---|
| 要素の中身・引用符付きの属性値 | <p><?= $s ?></p>、title="<?= $s ?>" |
エスケープして出力 |
| 引用符なしの属性値 | href=<?= $u ?> |
エラー(引用符で囲む) |
| タグの中(属性の間) | <div <?= $attrs ?>> |
エラー |
| URL 属性の先頭 | href="<?= $u ?>"、src="<?= $u ?>" |
SafeUrl(safe_url($u))と数値だけ。前に書いた文字列に / ? # : があれば先頭ではない(href="/post?id=<?= $id ?>" は可)。href="j<?= ?>" や j のような文字参照だけでは先頭のまま |
javascript: / data: の URL |
href="javascript:go(<?= $v ?>)" |
エラー(数値は可) |
<script> の中 |
var x = <?= $v ?>; |
SafeJs(json_html($v))と数値だけ |
<style>・style="..." |
エラー(数値は可) | |
| イベント属性 | onclick="go(<?= $v ?>)" |
エラー。data-* 属性に入れて JavaScript から読む |
<iframe srcdoc="..."> |
エラー(値が別の HTML 文書として解析される) |
- 関数・クロージャの本体、
if・ループのブロックは、始まったときと同じ HTML の状態で終わらなければならない (<script>や属性をブロックの中で開いたまま終えるとエラー)。呼び出し順や繰り返しで文脈がずれるのを防ぐ。 - 式と文の入れ子は 500 段まで(深すぎるソースで処理系のスタックがあふれるのを防ぐ)。
14. Web サーバー
phx serve examples/site --port 8080- サイトのディレクトリには
public/が必要。URL はファイル名で決まる:/→public/index.phx、/about→public/about.phxまたはpublic/about/index.phx、/about.phxも可。 - 静的ファイルとして配信するのは既知の拡張子(html、css、js、json、txt、画像、フォント、pdf、音声、動画、wasm)だけ。
- 配信しないもの:
public/の外、.や_で始まるファイルとディレクトリ(_partial.phxは部品に使える)、.phx.html(テンプレート)、知らない拡張子(.php、.bak、.sqlなど)。URL はファイル名と完全に同じ綴り でなければならない(INDEX.PHXや Windows の短縮名、::$DATAでは開けない)。 - リクエストごとに新しい VM(新しいヒープ)で実行する。コンパイル済みのバイトコードだけが共有される。
- ファイルを保存すると次のリクエストで再コンパイルされる(依存する全ファイルの更新時刻を見る)。
- 上限: 実行時間 30 秒(
--time-limit)、メモリ 128 MB([security] memory_limit_mb)、本文 8 MB、ヘッダーの受信 10 秒、 本文の受信 30 秒、同時接続 1024、クエリ・フォームの項目 1000 個。 [security] requests_per_minuteを書くと、1 つの接続元アドレスから 1 分間に受け付けるリクエストをその数までにする (超えると429 Too Many RequestsとRetry-After)。既定は無制限。- 既定で付けるヘッダー:
X-Content-Type-Options: nosniff、X-Frame-Options: SAMEORIGIN、Referrer-Policy: strict-origin-when-cross-origin、Content-Security-Policy(下の「Content-Security-Policy」)。secure_cookies = trueなら HSTS。セッションや Cookie を使うレスポンスにはCache-Control: private, no-store。Server/X-Powered-Byのようなバージョン情報は出さない。スクリプトはContent-Lengthなどの通信用ヘッダーを設定できない。 - 開発モードではコンパイルエラーと実行時エラーの詳細をブラウザに表示する。開発モードが既定になるのは
127.0.0.1/localhostで待ち受けるときだけ(--dev/--prodで明示できる)。
std.http
import std.http.{request, response, session};
$req = request();
$req->method; // "GET"
$req->path; // "/post"
$req->query("id"); // ?string
$req->form("name"); // ?string (urlencoded / multipart)
$req->header("Accept"); // ?string(名前の大文字小文字は区別しない)
$req->cookie("theme"); // ?string
$req->file("avatar"); // ?UploadedFile
$req->json(); // any
$req->body; // string
$req->ip; // string(接続元の IP アドレス。リバースプロキシの後ろではプロキシのアドレス)
$req->isPost(); // bool
response()->status(404);
response()->header("Cache-Control", "no-store");
response()->type("text/plain; charset=utf-8");
response()->cookie("theme", "dark");
response()->redirect("/login"); // サイト内のパスだけ
response()->redirectExternal("https://example.com/"); // 外部の URL
response()->json($value);セッション
import std.http.session;
session()->set("user_id", (string)$user->id);
$id = session()->get("user_id"); // ?string
session()->regenerate(); // ログイン直後に呼ぶ(セッション固定攻撃の対策)
session()->flash("notice"); // 読んだら消えるメッセージ
session()->destroy(); // ログアウト- 値はサーバー側に保存され、Cookie には 256 ビットの乱数の ID だけが入る
(
PHXSESSID、HttpOnly、SameSite=Lax、[security] secure_cookies = trueでSecure)。 - サーバーが発行していない ID は受け付けない。1 セッションの値は合計 64 KB まで。
[security] session_ttl秒アクセスがないと消える。アクセスが続いても 24 時間で消える。- 既定の保存先はメモリで、サーバーを再起動すると消える。
[security] session_store = "sessions/"のように プロジェクト内のディレクトリを書くと、1 セッション 1 ファイルで保存する。再起動しても残り、同じディレクトリを使う 複数のサーバープロセスで共有できる。ファイル名は ID のハッシュなので、ディレクトリの一覧から ID は分からない。public/の中は指定できない。
CSRF 対策
POST・PUT・PATCH・DELETE のうち、ブラウザがほかのサイトから送れる形式
(application/x-www-form-urlencoded、multipart/form-data、text/plain、Content-Type なし)のリクエストは、
スクリプトを実行する前にサーバーがトークンを確認する。トークンがない、または違うときは 403 になる。
<form method="post">
<?= csrf_field() ?> <!-- <input type="hidden" name="_csrf" value="..."> -->
...
</form>JavaScript から送るときは csrf_token() の値を X-CSRF-Token ヘッダーに入れる。
JSON のリクエストは CORS のプリフライトが必要なので、ほかのサイトからは送れない。
トークンはブラウザごとの Cookie(PHXCSRF)の HMAC なので、攻撃者がその Cookie を書き換えられないことが前提になる。
本番では HTTPS で配信して secure_cookies = true にする(Cookie が __Host- 付きになり、平文の通信や
サブドメインから上書きされなくなる)。
[security] csrf = false で無効にできる。
Cookie
request()->cookie("theme"); // ?string
response()->cookie("theme", "dark", maxAge: 86400 * 30); // HttpOnly; SameSite=Lax が既定
response()->deleteCookie("theme");名前に使えない文字、改行、SameSite=None で Secure なし、は ValueError。
アップロード
$file = request()->file("avatar"); // ?UploadedFile
if ($file != null) {
echo $file->name, $file->type, $file->size;
$file->saveTo("storage/avatars/" . random_token(16) . ".png"); // [permissions].write が必要
}name はクライアントが送ったファイル名からディレクトリ部分と制御文字を取り除いたもの。
保存先のパスには使わないこと。リクエスト本文は合計 8 MB まで。
JSON
$data = request()->json(); // any(不正な JSON は ValueError)
if ($data is map) {
$name = $data["name"] ?? null;
if ($name is string) { ... }
}
response()->json(["ok" => true]);データベース(std.db)
import std.db.Database;
$db = Database::open("data/app.db"); // [permissions].write = ["data/"] が必要。":memory:" も可
$db->execute(sql"CREATE TABLE IF NOT EXISTS posts (id INTEGER PRIMARY KEY, title TEXT)");
$db->execute(sql"INSERT INTO posts (title) VALUES ({$title})");
$id = $db->lastInsertId();
foreach ($db->query(sql"SELECT id, title FROM posts WHERE id > {$min} ORDER BY id") as $row) {
echo $row->int("id"), $row->string("title"); // ?int, ?string
}
$one = $db->first(sql"SELECT * FROM posts WHERE id = {$id}"); // ?Row
$db->transaction(function () use ($db): void {
$db->execute(sql"UPDATE posts SET title = 'x'");
}); // 例外が出たら ROLLBACK- SQL は
sql"..."リテラル(Sql型)でしか書けない。{$x}で埋め込んだ値は必ずプレースホルダとして 渡されるので、SQL インジェクションは起こらない。stringをquery()に渡すことはできない。 Sqlどうしは.で連結できる(パラメータも引き継がれる)。Sqlとstringは連結できない。- 列名やテーブル名を動的に決めたいときは
sql_ident($name)(英数字と_だけを許可して引用する)。 ATTACHは無効になっていて、許可されたファイル以外のデータベースには触れない。- SQL のエラーは
DatabaseError。
日付・暗号・エンコード
| 関数 | 説明 |
|---|---|
date($format, ?$ts) / gmdate(...) |
PHP と同じ書式(Y-m-d H:i:s、D, d M Y、c、U など)。date はサーバーのローカル時刻 |
mktime($h, $i, $s, $m, $d, $y), strtotime($s): ?int, checkdate($m, $d, $y) |
strtotime は ISO 形式と now/today/tomorrow/yesterday |
random_int($min, $max), random_token($bytes = 32) |
OS の暗号論的乱数(rand() は暗号用途に使わない) |
password_hash($pw), password_verify($pw, $hash) |
Argon2id |
hash("sha256" | "sha384" | "sha512", $data), hash_hmac($algo, $data, $key), hash_equals($a, $b) |
hash_equals は比較時間が内容に依存しない |
base64_encode, base64_decode: ?string, urlencode, rawurlencode, urldecode, http_build_query |
|
json_decode($s): any |
深さ 128 まで |
Content-Security-Policy
ページのレスポンスには既定で次のポリシーが付く({nonce} はレスポンスごとの 'nonce-...'。'nonce-{nonce}' と書いても同じ)。
default-src 'self'; script-src 'self' {nonce}; style-src 'self' 'unsafe-inline';
img-src 'self' data: https:; font-src 'self' data:; object-src 'none'; base-uri 'self';
form-action 'self'; frame-ancestors 'self'- テンプレート(
.phx.htmlと.phxの HTML 部分)に書いた<script>タグには、PHX が自動でnonce="..."を付ける。そのため、自分で書いたインラインスクリプトはそのまま動き、エスケープをすり抜けて 注入されたスクリプト(raw()に入った<script>など)は動かない。 onclick="..."のようなイベント属性とjavascript:の URL は止まる。addEventListenerを使う。 テンプレートに書くとコンパイル時に警告が出る(cspでインラインのコードを許可しているときは出ない)。- 文字列から組み立てた
<script>に nonce が必要なときはcsp_nonce()を使う (raw("<script nonce=\"" . csp_nonce() . "\">...</script>"))。 - 静的ファイルとエラーページには nonce のないポリシーが付く(静的な
.htmlのインラインスクリプトは動かない)。 - 変更:
[security] csp = "off"で付けない。csp = "default-src 'self'; script-src 'self' {nonce}"のように 書くとそのポリシーを使う({nonce}は'nonce-...'になる)。
レート制限
if (!rate_limit("login:" . request()->ip, 5, 60)) { // 60 秒に 5 回まで
response()->status(429);
echo "しばらく待ってからやり直してください。";
return;
}
// ログインに成功したら
rate_limit_reset("login:" . request()->ip);rate_limit($key, $max, $seconds)は、そのキーの今の時間枠でまだ$max回に達していなければ 1 回数えてtrue、 達していればfalseを返す。カウンターはサーバープロセスの中で共有され、再起動で消える。- 同じキーでも時間枠の長さごとに別に数える(1 分 5 回と 1 日 50 回を両方かけられる)。キーが 10 万個を超えると、 上限に達していないものから捨てるので、大量のキーを使っても制限中のキーは外れない。
requests_per_minuteは IPv6 のアドレスを /64 ごとにまとめて数える。- サイト全体の制限は
[security] requests_per_minute(§14)。
権限(phx.toml)
スクリプトがヒープの外に触れるものは、すべて phx.toml で許可したものだけ。何も書かなければ何も許可されない。
[permissions]
read = ["content/"] # file_get_contents / file_exists で読めるディレクトリ
write = ["data/", "storage/"] # file_put_contents / unlink / saveTo / Database::open(読み取りも可)
env = ["APP_*"] # getenv で読める環境変数(末尾 * は前方一致)- パスはプロジェクトからの相対パスで、
..と絶対パスは使えない。 - 許可したディレクトリの下にあるシンボリックリンクとジャンクションはたどらない(リンクを経由するパスは
PermissionError)。 パスは許可したディレクトリから 1 段ずつ、リンクを拒否しながら開くので、検査と使用の間にディレクトリを すり替えられることもない。 - 開けるのは通常のファイルで、ハードリンクが 1 本だけのもの。外のファイルへのハードリンクを置かれても読み書きしない。
- ファイル名に
:(Windows の代替データストリーム)、末尾のドットや空白、CONNULCOM1などのデバイス名は使えない。 - Unix では、ほかのユーザーが書き込めるディレクトリ(
o+w)は使えない。 - コードとして読み込まれる場所は
writeに指定できない:public/、プロジェクトのルート、.で始まるディレクトリ (.phx-cache/、.git/など)、vendor/、[dependencies]のパッケージのディレクトリ。 - 許可されていないときは
PermissionError(メッセージにphx.tomlに書くべき内容が出る)。
phx.toml
[project]
name = "blog"
[security]
csrf = true # 既定: true
secure_cookies = false # HTTPS で配信するときは true
session_ttl = 7200 # 秒
memory_limit_mb = 128 # 1 リクエスト(1 回の実行)あたり
csp = "default" # "off" か、{nonce} を含められる独自のポリシー
requests_per_minute = 0 # 接続元アドレスごとの上限(0 は無制限)
session_store = "memory" # またはディレクトリ("sessions/")
[permissions]
write = ["data/"]
[dependencies]
utils = { path = "../utils" }
markdown = { git = "https://example.com/markdown.git", rev = "v1.2.0" }phx.toml のあるディレクトリがプロジェクトのルートになる。知らないキーはエラー(書き間違いに気づける)。
phx serve は起動時に読み込むので、変更したらサーバーを再起動する。
15. 組み込み関数
グローバル関数
| 関数 | 説明 |
|---|---|
count(list|map): int |
要素数 |
strlen(string): int |
バイト数(文字数は ->len()) |
implode(string $sep, list<string>): string / explode(string $sep, string): list<string> |
結合 / 分割 |
str_repeat, str_contains, str_starts_with, str_ends_with, str_replace(search, replace, subject) |
PHP と同じ |
strtoupper, strtolower, ucfirst, trim |
PHP と同じ |
json_encode(any): string |
オブジェクトは public プロパティだけを出力 |
dump(any): void |
var_dump に相当 |
raw(string): Html, escape(string): Html, htmlspecialchars(string): Html, nl2br(string): Html |
§13 |
intdiv, abs, min, max, floor, ceil, round, sqrt, fmod |
数値 |
number_format(float, int $decimals = 0): string |
1,234.50 |
range(int, int): list<int> |
両端を含む |
time(): int, microtime(): float |
現在時刻 |
rand(int, int): int |
疑似乱数(暗号用途には使わない。random_int を使う) |
json_decode(string): any, json_html(any): SafeJs, safe_url(string): SafeUrl |
§13、§14 |
csrf_token(): string, csrf_field(): Html |
§14 |
csp_nonce(): string |
このレスポンスの CSP の nonce(§14) |
rate_limit(string, int, int): bool, rate_limit_reset(string) |
§14 |
date, gmdate, mktime, strtotime, checkdate |
日付(§14 の表) |
random_int, random_token, password_hash, password_verify, hash, hash_hmac, hash_equals |
暗号(§14 の表) |
base64_encode, base64_decode, urlencode, rawurlencode, urldecode, http_build_query |
エンコード |
file_get_contents, file_put_contents, file_exists, unlink, getenv |
[permissions] が必要 |
sql_ident(string): Sql |
列名・テーブル名 |
assert(bool, ?string), assert_eq(any, any, ?string) |
テスト用 |
string のメソッド
len()(文字数)、isEmpty()、upper()、lower()、ucfirst()、trim()、ltrim()、rtrim()、
contains($s)、startsWith($s)、endsWith($s)、replace($search, $replace)、split($sep)、
slice($start, $length?)(文字単位、負の値は末尾から)、indexOf($s): ?int、repeat($n)、
padStart($len, $pad)、padEnd($len, $pad)、toInt(): ?int、toFloat(): ?float、chars()
list のメソッド
len()、isEmpty()、contains($v)、indexOf($v): ?int、first(): ?T、last(): ?T、
slice($start, $length?)、reverse()、concat($other)、unique()、sorted()、sortBy(fn($a, $b) => int)、
map(fn($x) => ...)、filter(fn($x) => bool)、find(fn($x) => bool): ?T、any(...)、all(...)、
join($sep)、sum()
要素の追加は $xs[] = $v;、更新は $xs[$i] = $v;(範囲外は IndexError)。
map のメソッド
len()、isEmpty()、has($k)、get($k): ?V、keys()、values()、merge($other)
追加・更新は $m[$k] = $v;。存在しないキーの読み取り $m[$k] は KeyError、$m[$k] ?? $default は例外にならない。
PHP の関数名で書いたときは、エラーメッセージで代わりの書き方を案内する
(例: strpos → ->indexOf()、isset → ??、var_dump → dump()、eval → 使えない)。
16. ツール
| コマンド | 説明 |
|---|---|
phx run file.phx |
型検査して実行(--html、--time-limit 秒) |
phx check file.phx |
型検査だけ(import しているモジュールとテンプレートも) |
phx serve dir |
Web サーバー(--port、--host、--prod、--time-limit) |
phx test [paths] |
*_test.phx の test_* 関数を実行(1 テストごとに新しい VM) |
phx fmt [paths] [--check] |
インデントと空白を整える(文字列・コメント・HTML は変えない) |
phx lsp |
エディタ用の Language Server(入力中のエラー表示、ホバーで型を表示) |
phx install |
[dependencies] を取得して phx.lock に固定 |
phx migrate file.php |
PHP を機械的に変換し、手作業が必要な箇所を一覧にする |
phx disasm file.phx |
バイトコードを表示 |
phx parse file.phx |
構文木を表示 |
テスト
// tests/posts_test.phx
import lib.posts.find_post;
function test_find_post(): void {
assert_eq(find_post(2)?->title, "Types without ceremony");
assert(find_post(99) == null, "missing posts are null");
}assert(bool, ?message) と assert_eq(expected, actual, ?message) が失敗すると AssertionError。
パッケージ
[dependencies]
textutils = { git = "https://github.com/example/textutils.git", rev = "v1" }
local = { path = "../shared" }import textutils.strings.slugify; // vendor/textutils/strings.phxgitのパッケージはvendor/<名前>に取得され、正確なコミットがphx.lockに記録される。 2 回目以降は lock のコミットを取得し、違っていればエラーになる。- 使える URL は
https://、ssh://、git@host:...、file://だけ。 - パッケージは PHX のソースコードで、インストール時にパッケージのコードが実行されることはない。
- パッケージの依存はアプリの
phx.tomlに並べて書く(推移的な依存の解決はまだない)。
移行
phx migrate legacy/page.php # page.phx を書き出す<?php→<?phx、===→==、array(...)→[...]、isset($x)→($x != null)、$_GET/$_POST/$_COOKIE/$_SESSION
→ request()/session()、var_dump/print_r→dump、print→echo を変換し、require や global には
TODO(migrate) コメントを付ける。そのあと型検査を実行して、型注釈などの手作業が必要な箇所を表示する。
17. 実行の仕組み
- 字句解析・構文解析(手書きの再帰下降 + Pratt パーサ)
- 型検査(名前解決、推論、絞り込み、確定代入の解析)→ 型付き中間表現 (HIR)
- レジスタ型バイトコードへのコンパイル(
AddI、LtI、JumpIfNotLtIImmなどの型に特化した命令) - 実行
- インタプリタ: 安全な Rust だけで書かれている。文字列・list・map は参照カウント + コピーオンライト。
- JIT(Cranelift): すべての値が
int/boolの関数(数値計算のループや再帰)はネイティブコードになる。 オーバーフロー、0 での剰余、負のシフト、再帰の深さ、実行時間・メモリの上限は JIT でも同じように検査される。PHX_JIT=0で無効にできる。JIT の中で起きた例外のスタックトレースは呼び出し元までになる。
- バイトコードキャッシュ:
phx runとphx serveはコンパイル結果を.phx-cache/に保存し、 ソースファイルとphx本体が変わっていなければ再利用する。- キャッシュはユーザーごとの鍵(
%LOCALAPPDATA%\phx\cache.key、~/.cache/phx/cache.key)の HMAC で 署名し、署名が合わないものは読まない。ほかの人から受け取ったプロジェクトに.phx-cache/が入っていても、 ソースと違うコードが動くことはない。 - エントリはスクリプトとプロジェクトごと。依存するすべてのファイルの内容(SHA-256)を記録し、読み込むときに 確かめる(更新時刻を保つコピーや、同じ長さの書き換えでも古いコードは使われない)。
.phx-cache/がリンクなら使わない。中のファイルも、リンクやハードリンクなら読み書きしない。
- キャッシュはユーザーごとの鍵(
実行時の上限
| 上限 | 既定 | 超えたとき |
|---|---|---|
| 実行時間 | serve: 30 秒(--time-limit)、run: なし |
致命的エラー(捕捉できない) |
| メモリ | 128 MB([security] memory_limit_mb) |
致命的エラー |
| 呼び出しの深さ | 10,000 | 致命的エラー |
| リクエスト本文 | 8 MB | 413 |
unsafe を使っているのは、メモリ使用量を数えるアロケータ(phx_vm/src/alloc.rs)と JIT
(phx_jit、生成したコードの呼び出し)だけ。
18. 今後の予定
- ジェネリッククラス、アノテーション(属性)
- 非同期 I/O、HTTP クライアント(
[permissions].net) - JIT の対象拡大(float・文字列を扱う関数)、AOT コンパイル
- 推移的な依存関係の解決、パッケージレジストリ
- LSP の定義ジャンプ・補完、
phx fmtの行の折り返し - 複数プロセスで共有できるレート制限、データベースに保存するセッション
セキュリティ監査の結果と残っているリスクは security-audit.ja.md を参照。