0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PowerShell と Node の自動化で Windows のパス・UTF-8 BOM・Cookie・symlink で壊れた箇所を決定論的に避ける

0
Posted at

PowerShell と Node の自動化で Windows のパス・UTF-8 BOM・Cookie・symlink で壊れた箇所を決定論的に避ける

対象読者: Windows 上で PowerShell と Node.js を混在させた自動化スクリプトを運用していて、文字コード・ブラウザプロファイル・ファイル走査の事故を再現しづらい形で潰したい人。

既出確認: PUBLISHED.md の Qiita 欄は「GASで校務メールの定型文を自動生成する」「GASで『未提出一覧』を毎朝メールするミニ自動化」「GASで『校務メモ』をAI整形する最小構成」など、Google Apps Script と校務 AI 活用の記事が中心でした。この記事は PowerShell + Node + Windows のローカル自動化で起きた破損点に絞ります。

現象

PowerShell から Node を呼ぶ自動化で、次の3つが別々のタイミングで壊れました。

  1. PowerShell が出した UTF-8 BOM 付き JSON を Node 側で JSON.parse() して落ちる
  2. Chrome と Chromium のプロファイルを同じものとして扱い、Cookie DB のスキーマ差で読めない
  3. symlink を含むディレクトリを再帰走査し、同じ実体を重複処理したり、想定外の場所までたどる

どれも「たまたま動いた環境」を前提にすると再発します。回避策は、入力を受け取った直後に正規化し、判定できないものは処理対象から外すことです。

原因

UTF-8 BOM

PowerShell と Node の境界では、文字列として見る前に先頭の BOM を落としておかないと、Node 側で JSON の先頭文字が { ではなく \uFEFF になります。

PowerShell 側で BOM なし UTF-8 を明示し、Node 側でも読み込み直後に BOM を除去しておくと、どちらか片方の変更漏れで壊れにくくなります。

Chrome / Chromium の Cookie スキーマ

Chrome と Chromium は見た目が近くても、Cookie DB を同じ形式の永続ストアとして扱う前提にしない方が安全です。

自動化では「Cookie を読めたらログイン済み」と判定しがちですが、ここを雑にすると、別ブラウザ由来の DB を読み込んでスキーマ差で失敗します。ブラウザ種別とプロファイル種別を入力として固定し、違う組み合わせなら処理を止めます。

symlink 越しのファイル走査

再帰走査で symlink を通常ディレクトリと同じようにたどると、同じ実体を二重に処理したり、作業ディレクトリ外のファイルを拾うことがあります。

Windows では junction も絡むため、Dirent#isDirectory() だけで判断せず、isSymbolicLink() と realpath() で「たどるか」「重複か」を分けて扱います。

回避コード

1. PowerShell から BOM なし UTF-8 で JSON を書く

検証済みです。

$data = [ordered]@{
  ok = $true
  path = ".\input.json"
}

$json = $data | ConvertTo-Json -Depth 10
$utf8NoBom = [System.Text.UTF8Encoding]::new($false)
[System.IO.File]::WriteAllText(".\payload.json", $json, $utf8NoBom)

Node 側でも、読む直後に BOM を落とします。

検証済みです。

const fs = require("node:fs");

function readJsonUtf8(file) {
  const text = fs.readFileSync(file, "utf8").replace(/^\uFEFF/, "");
  return JSON.parse(text);
}

const payload = readJsonUtf8("./payload.json");
console.log(payload);

PowerShell 側の出力を直せない場面でも、Node 側の replace(/^\uFEFF/, "") が最後の受け皿になります。

2. Cookie DB は「ブラウザ種別」とセットで扱う

未検証コードです。ブラウザプロファイルを直接読む処理に入る前のガード例です。

const path = require("node:path");

const allowedProfiles = {
  chrome: [".browser-profile-chrome"],
  chromium: [".browser-profile-chromium"],
};

function assertProfileKind(browserKind, profileDir) {
  if (!Object.hasOwn(allowedProfiles, browserKind)) {
    throw new Error(`unsupported browser kind: ${browserKind}`);
  }

  const baseName = path.basename(profileDir);
  if (!allowedProfiles[browserKind].includes(baseName)) {
    throw new Error(
      `profile mismatch: browser=${browserKind}, profile=${baseName}`
    );
  }
}

assertProfileKind("chromium", ".browser-profile-chromium");

ここで重要なのは、Cookie DB の中身を読みに行く前に止めることです。スキーマの違いを吸収しようとせず、ブラウザ種別とプロファイルを別物として扱います。

3. symlink を再帰走査でたどらない

未検証コードです。ディレクトリ内の通常ファイルだけを集め、symlink は明示的にスキップします。

const fs = require("node:fs/promises");
const path = require("node:path");

async function listFilesNoSymlink(root) {
  const seenRealDirs = new Set();
  const files = [];

  async function walk(dir) {
    const realDir = await fs.realpath(dir);
    if (seenRealDirs.has(realDir)) return;
    seenRealDirs.add(realDir);

    const entries = await fs.readdir(dir, { withFileTypes: true });
    for (const entry of entries) {
      const fullPath = path.join(dir, entry.name);

      if (entry.isSymbolicLink()) {
        continue;
      }

      if (entry.isDirectory()) {
        await walk(fullPath);
        continue;
      }

      if (entry.isFile()) {
        files.push(fullPath);
      }
    }
  }

  await walk(root);
  return files;
}

listFilesNoSymlink(".").then((files) => {
  for (const file of files) console.log(file);
});

symlink を「便利なショートカット」として扱うのではなく、再帰走査の境界として扱います。必要な symlink だけを許可したい場合は、continue の前に許可リスト判定を入れる形にします。

確認方法

BOM の確認

PowerShell で BOM 付きのバイト列を作り、読み込み直後に \uFEFF を落として JSON として読めることを確認します。

$json = '{"ok":true,"path":".\\input.json"}'
$bytes = [Text.Encoding]::UTF8.GetPreamble() + [Text.Encoding]::UTF8.GetBytes($json)
$text = [Text.Encoding]::UTF8.GetString($bytes) -replace '^\uFEFF',''
$obj = $text | ConvertFrom-Json
$obj.ok

Node でも同じ確認ができます。

const raw = Buffer.from([
  0xef,
  0xbb,
  0xbf,
  ...Buffer.from(JSON.stringify({ ok: true, path: ".\\input.json" }), "utf8"),
]);

const text = raw.toString("utf8").replace(/^\uFEFF/, "");
console.log(JSON.parse(text).ok);

どちらも True または true が出れば、BOM 付き入力を受けても JSON として扱えています。

Cookie プロファイルの確認

Cookie DB の中身を読む前に、ブラウザ種別とプロファイル名の対応だけを確認します。

node .\check-profile-kind.js

Chrome 用プロファイルを Chromium として渡した場合にエラーで止まれば、混在を入口で防げています。

symlink 走査の確認

通常ディレクトリだけを走査し、symlink を含めても同じファイルが重複して出ないことを確認します。

node .\list-files-no-symlink.js

出力に symlink 配下のファイルが混ざらなければ、再帰走査の境界として機能しています。

まとめ

PowerShell と Node を混ぜた Windows 自動化では、壊れる場所を「実行処理の中」ではなく「境界」に寄せて潰すと扱いやすくなります。

  • PowerShell から書く JSON は BOM なし UTF-8 にする
  • Node は入力直後に BOM を落としてから JSON.parse() する
  • Chrome と Chromium の Cookie DB は同一視しない
  • symlink は通常ディレクトリとして再帰しない

環境差をなくすより、環境差が入る境界を固定して、判定不能なら止める。Windows のローカル自動化では、この方が再現性を保ちやすいです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?