@quolu/lattice 0.74.0 → 0.76.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/lattice.mjs CHANGED
@@ -185,8 +185,9 @@ await scheduleRuntimeErrorReport();
185
185
  * 送信を有効にしていない端末では、設定fileの有無を1回見るだけで終わる。hookは打鍵ごとに走るので見ない。
186
186
  */
187
187
  async function scheduleRuntimeErrorReport() {
188
+ // `reporting verify`の直後は送らない。受け口は1分に1回まで受けるので、続けて送ると断られる。
188
189
  if (help !== null || args[0] === '--version' || args[0] === 'hooks'
189
- || (args[0] === 'runtime-errors' && args[1] === 'report')) return;
190
+ || (args[0] === 'runtime-errors' && (args[1] === 'report' || (args[1] === 'reporting' && args[2] === 'verify')))) return;
190
191
  try {
191
192
  const { runtimeErrorReportingEnabled } = await import('../src/runtime-errors.mjs');
192
193
  if (!runtimeErrorReportingEnabled()) return;
@@ -222,7 +223,7 @@ function workingDirectory() {
222
223
  async function runRuntimeErrorsCli(rest) {
223
224
  const runtimeErrors = await import('../src/runtime-errors.mjs');
224
225
  const usage = () => {
225
- process.stderr.write(`${JSON.stringify({ schema: 'lattice.cli_error.v2', code: 'USAGE', message: 'usage: lattice runtime-errors <snapshot [--after-cursor N] [--limit N]|ack <cursor>|diagnostics|resolve <fingerprint>|reopen <fingerprint>|compact|report|reporting <status|enable|disable>> --json' })}\n`);
226
+ process.stderr.write(`${JSON.stringify({ schema: 'lattice.cli_error.v2', code: 'USAGE', message: 'usage: lattice runtime-errors <snapshot [--after-cursor N] [--limit N]|ack <cursor>|diagnostics|resolve <fingerprint>|reopen <fingerprint>|compact|report|reporting <status|enable|disable|verify>> --json' })}\n`);
226
227
  return 2;
227
228
  };
228
229
  const options = { version: packageJson.version };
@@ -251,6 +252,12 @@ async function runRuntimeErrorsCli(rest) {
251
252
  result = runtimeErrors.setRuntimeErrorStatus(words[1], words[0] === 'resolve' ? 'resolved' : 'open', options);
252
253
  } else if (words[0] === 'compact' && words.length === 1) {
253
254
  result = runtimeErrors.compactRuntimeErrors(options);
255
+ } else if (words[0] === 'reporting' && words.length === 2 && words[1] === 'verify') {
256
+ // 空の報告を1通送って経路を確かめる。受領まで確かめられた時だけexit 0。
257
+ const reporting = await import('../src/runtime-error-reporting.mjs');
258
+ result = await reporting.verifyRuntimeErrorReporting(options);
259
+ process.stdout.write(`${JSON.stringify(result)}\n`);
260
+ return result.outcome === 'verified' ? 0 : 1;
254
261
  } else if (words[0] === 'reporting' && words.length === 2 && ['status', 'enable', 'disable'].includes(words[1])) {
255
262
  const reporting = await import('../src/runtime-error-reporting.mjs');
256
263
  result = words[1] === 'status' ? reporting.runtimeErrorReportingStatus(options)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quolu/lattice",
3
- "version": "0.74.0",
3
+ "version": "0.76.0",
4
4
  "description": "Schedulability compiler for multi-agent development: observe real code boundaries, refactor the conflicting seam, recompile the plan for parallel execution",
5
5
  "author": {
6
6
  "name": "Quo / クオ at kitepon.dev",
package/src/cli-help.mjs CHANGED
@@ -198,6 +198,7 @@ Commands:
198
198
  compact
199
199
  report 未受領の記録をBugHubの受け口へ送る(送信を有効にした端末だけ)
200
200
  reporting <status|enable|disable>
201
+ reporting verify 空の報告を1通送り、合鍵と受け口までの経路を確かめる(記録には触れない)
201
202
  `,
202
203
  bridge: `Usage: lattice bridge <command> [options] --json
203
204
 
@@ -315,7 +316,7 @@ const SUBCOMMAND_USAGE = Object.freeze({
315
316
  'runtime-errors reopen': 'runtime-errors reopen <fingerprint> --json',
316
317
  'runtime-errors compact': 'runtime-errors compact --json',
317
318
  'runtime-errors report': 'runtime-errors report --json',
318
- 'runtime-errors reporting': 'runtime-errors reporting <status|enable|disable> --json',
319
+ 'runtime-errors reporting': 'runtime-errors reporting <status|enable|disable|verify> --json',
319
320
  'bridge setup': 'bridge setup --listen <IP> [--port <49152..65535|auto>] [--dashboard|--upstream <URL>] [--hub <URL>|none] [--allow-host <host>...] --json',
320
321
  'bridge reconfigure': 'bridge reconfigure [--listen <IP>] [--port <49152..65535|auto>] [--dashboard|--upstream <URL>] [--hub <URL>|none] [--allow-host <host>...] --json',
321
322
  'bridge status': 'bridge status --json',
@@ -1,10 +1,10 @@
1
1
  import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
2
2
  import {
3
- existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync,
3
+ existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync,
4
4
  } from 'node:fs';
5
5
  import http from 'node:http';
6
6
  import https from 'node:https';
7
- import { homedir } from 'node:os';
7
+ import { homedir, platform as hostPlatform } from 'node:os';
8
8
  import { dirname, join } from 'node:path';
9
9
 
10
10
  import {
@@ -12,12 +12,14 @@ import {
12
12
  acknowledgeRuntimeErrors,
13
13
  ensureSafeDir,
14
14
  ensureSafeFile,
15
+ replaceStoreFile,
15
16
  runtimeErrorCollectionSupported,
16
17
  runtimeErrorReportingConfigPath,
17
18
  runtimeErrorReportingEnabled,
18
19
  runtimeErrorsDiagnostics,
19
20
  runtimeErrorsSnapshot,
20
21
  runtimeErrorsStatePath,
22
+ windowsLocalAppData,
21
23
  } from './runtime-errors.mjs';
22
24
 
23
25
  /**
@@ -29,6 +31,8 @@ import {
29
31
  * - 受領済みにするのは、200・`accepted: true`・`report_id`一致・応答の署名一致がそろった時だけ。
30
32
  * そろわなければ「届いたか不明」として記録を未受領のまま残し、後から今の累計を送り直す。
31
33
  * - 送るのは`runtime-errors snapshot`が出す項目だけ。message本文・path・引数・stackは記録に無い。
34
+ * - `reporting verify`は、記録を載せない空の報告を1通送って、合鍵・署名・受け口までの経路を確かめる。
35
+ * 受け口は受領を記録するだけで、issueも通知も動かない。storeの記録と受領の印には触れない。
32
36
  */
33
37
 
34
38
  const REPORT_SCHEMA_VERSION = '1.0';
@@ -51,13 +55,34 @@ const OUTCOMES = Object.freeze(['delivered', 'nothing_pending', 'disabled', 'uns
51
55
  const plain = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
52
56
  const exact = (value, keys) => Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
53
57
 
54
- export function productCredentialPath(env = process.env) {
55
- return join(env.HOME || homedir(), '.config', 'bughub', 'product-credentials', `${PRODUCT_ID}.json`);
58
+ /** 合鍵の置き場(BugHubの契約)。Windowsは`%LOCALAPPDATA%\bughub\product-credentials\`。 */
59
+ export function productCredentialPath(env = process.env, platform = hostPlatform()) {
60
+ const base = platform === 'win32' ? join(windowsLocalAppData(env), 'bughub') : join(env.HOME || homedir(), '.config', 'bughub');
61
+ return join(base, 'product-credentials', `${PRODUCT_ID}.json`);
56
62
  }
57
63
 
58
64
  /**
59
- * 合鍵のfileを読む。BugHubの持ち主が置く形(本人所有・0600・symlinkでない)以外は使わない。
60
- * 秘密は戻り値の中だけに留め、結果や記録へ写さない。
65
+ * Windowsの合鍵が本人・SYSTEM・Administratorsだけのものか。DACLの読み取りは出力fileを置く場所が要るので、
66
+ * 絞ったstoreのフォルダを使う(無ければ作る)。合鍵のフォルダへは何も書かない。
67
+ */
68
+ function windowsCredentialUnsafeReason(path, options) {
69
+ const storeDir = dirname(options.storePath ?? runtimeErrorsStatePath(options.env ?? process.env));
70
+ try {
71
+ ensureSafeDir(storeDir);
72
+ } catch {
73
+ return 'acl_unverifiable';
74
+ }
75
+ try {
76
+ ensureSafeFile(path, storeDir);
77
+ return null;
78
+ } catch {
79
+ return 'acl_not_owner_only';
80
+ }
81
+ }
82
+
83
+ /**
84
+ * 合鍵のfileを読む。BugHubの持ち主が置く形(symlinkでなく、POSIXは本人所有・0600、Windowsは
85
+ * 本人・SYSTEM・Administratorsだけに権限)以外は使わない。秘密は戻り値の中だけに留め、結果や記録へ写さない。
61
86
  */
62
87
  export function readProductCredential(options = {}) {
63
88
  const path = options.credentialPath ?? productCredentialPath(options.env ?? process.env);
@@ -68,8 +93,13 @@ export function readProductCredential(options = {}) {
68
93
  return error?.code === 'ENOENT' ? { status: 'missing' } : { status: 'unsafe', reason: 'unreadable' };
69
94
  }
70
95
  if (!stats.isFile() || stats.isSymbolicLink()) return { status: 'unsafe', reason: 'not_regular_file' };
71
- if ((stats.mode & 0o777) !== 0o600) return { status: 'unsafe', reason: 'mode_not_0600' };
72
- if (typeof process.getuid === 'function' && stats.uid !== process.getuid()) return { status: 'unsafe', reason: 'owner_mismatch' };
96
+ if (hostPlatform() === 'win32') {
97
+ const reason = windowsCredentialUnsafeReason(path, options);
98
+ if (reason !== null) return { status: 'unsafe', reason };
99
+ } else {
100
+ if ((stats.mode & 0o777) !== 0o600) return { status: 'unsafe', reason: 'mode_not_0600' };
101
+ if (typeof process.getuid === 'function' && stats.uid !== process.getuid()) return { status: 'unsafe', reason: 'owner_mismatch' };
102
+ }
73
103
  let value;
74
104
  try {
75
105
  value = JSON.parse(readFileSync(path, 'utf8'));
@@ -151,7 +181,7 @@ function writeDelivery(options, state) {
151
181
  const temporary = join(dirname(path), `.runtime-errors-delivery-${process.pid}-${randomBytes(6).toString('hex')}`);
152
182
  try {
153
183
  writeFileSync(temporary, `${JSON.stringify(state)}\n`, { mode: 0o600, flag: 'wx' });
154
- renameSync(temporary, path);
184
+ replaceStoreFile(temporary, path);
155
185
  } finally {
156
186
  rmSync(temporary, { force: true });
157
187
  }
@@ -206,7 +236,7 @@ export function setRuntimeErrorReporting(enabled, options = {}) {
206
236
  const temporary = join(dirname(path), `.runtime-error-reporting-${process.pid}-${randomBytes(6).toString('hex')}`);
207
237
  try {
208
238
  writeFileSync(temporary, `${JSON.stringify({ schema: REPORTING_CONFIG_SCHEMA, enabled })}\n`, { mode: 0o600, flag: 'wx' });
209
- renameSync(temporary, path);
239
+ replaceStoreFile(temporary, path);
210
240
  } finally {
211
241
  rmSync(temporary, { force: true });
212
242
  }
@@ -247,6 +277,41 @@ function parseJson(text) {
247
277
  }
248
278
  }
249
279
 
280
+ /**
281
+ * 報告を1通、署名して送り、受領を確かめる。`accepted`を返すのは、200・`accepted: true`・`report_id`一致・
282
+ * 応答の署名一致がそろった時だけ。戻り値に秘密・key_id・宛先は入れない。
283
+ */
284
+ async function sendReport({ url, keyId, secret }, snapshot, options) {
285
+ const now = nowMs(options);
286
+ const ts = Math.floor(now / 1000);
287
+ // `observed_at`は、載せる記録のどの時刻よりも前にしない(BugHubは観測より後の記録を断る)。秒へ切り捨てると、
288
+ // 記録と同じ1秒の中で送った時に記録より前へずれる。`ts`とは同じ時刻から作り、差はBugHubの許す幅(10分)に収まる。
289
+ const observed = Math.max(now, ...snapshot.runtime_errors.map((record) => Date.parse(record.last_seen)),
290
+ ...snapshot.resolutions.map((resolution) => Date.parse(resolution.resolved_at)));
291
+ const report = buildRuntimeErrorReport({ snapshot, version: options.version ?? 'unknown',
292
+ reportId: options.reportId ?? randomUUID(), observedAt: new Date(observed).toISOString() });
293
+ const bodyBytes = Buffer.from(JSON.stringify(report), 'utf8');
294
+ if (bodyBytes.length > REPORT_MAX_BYTES) return { outcome: 'too_large', reason: 'report_too_large', http_status: null };
295
+
296
+ const response = await post(url, {
297
+ 'Content-Type': 'application/json',
298
+ 'Content-Length': String(bodyBytes.length),
299
+ Authorization: `BugHub-HMAC-SHA256 key_id=${keyId}, ts=${ts}, sig=${signReport(secret, String(ts), bodyBytes)}`,
300
+ }, bodyBytes, options.timeoutMs ?? REQUEST_TIMEOUT_MS);
301
+
302
+ if (response.status === null) return { outcome: 'unconfirmed', reason: response.reason, http_status: null };
303
+ const body = parseJson(response.body);
304
+ if (response.status === 200) {
305
+ if (body === null || body.accepted !== true || body.report_id !== report.report_id
306
+ || !receiptSignatureMatches(secret, report.report_id, body.received_at, body.sig)) {
307
+ return { outcome: 'unconfirmed', reason: 'receipt_not_verified', http_status: 200 };
308
+ }
309
+ return { outcome: 'accepted', reason: null, http_status: 200 };
310
+ }
311
+ const code = typeof body?.error === 'string' && REJECTION_CODE.test(body.error) ? body.error : 'unrecognized_response';
312
+ return { outcome: response.status >= 500 ? 'unconfirmed' : 'rejected', reason: code, http_status: response.status };
313
+ }
314
+
250
315
  /**
251
316
  * 未受領の記録を1回送る。`options.auto`の時だけ送る時機の制限を見る(手で打った`report`は見ない
252
317
  * ——多すぎればBugHubが429で断る)。戻り値に秘密・key_id・宛先は入れない。
@@ -277,13 +342,6 @@ export async function reportRuntimeErrors(options = {}) {
277
342
  if (reason !== null) return result('throttled', { reason });
278
343
  }
279
344
 
280
- // `ts`と`observed_at`は同じ時刻から作る(BugHubは10分より離れた組を断る)。
281
- const ts = Math.floor(nowMs(options) / 1000);
282
- const report = buildRuntimeErrorReport({ snapshot: pending, version: options.version ?? 'unknown',
283
- reportId: options.reportId ?? randomUUID(), observedAt: new Date(ts * 1000).toISOString() });
284
- const bodyBytes = Buffer.from(JSON.stringify(report), 'utf8');
285
- if (bodyBytes.length > REPORT_MAX_BYTES) return result('rejected', { reason: 'report_too_large', sent });
286
-
287
345
  const finish = (outcome, extra = {}) => {
288
346
  try {
289
347
  writeDelivery(options, { schema: DELIVERY_SCHEMA, last_attempt_at: new Date(nowMs(options)).toISOString(),
@@ -292,44 +350,47 @@ export async function reportRuntimeErrors(options = {}) {
292
350
  return result(outcome, { sent, acknowledged_through: pending.cursor.acknowledged_through, ...extra });
293
351
  };
294
352
 
295
- const { credential: { url, keyId, secret } } = credential;
296
- const response = await post(url, {
297
- 'Content-Type': 'application/json',
298
- 'Content-Length': String(bodyBytes.length),
299
- Authorization: `BugHub-HMAC-SHA256 key_id=${keyId}, ts=${ts}, sig=${signReport(secret, String(ts), bodyBytes)}`,
300
- }, bodyBytes, options.timeoutMs ?? REQUEST_TIMEOUT_MS);
301
-
302
- if (response.status === null) return finish('unconfirmed', { reason: response.reason });
303
- const body = parseJson(response.body);
304
- if (response.status === 200) {
305
- if (body === null || body.accepted !== true || body.report_id !== report.report_id
306
- || !receiptSignatureMatches(secret, report.report_id, body.received_at, body.sig)) {
307
- return finish('unconfirmed', { reason: 'receipt_not_verified', http_status: 200 });
308
- }
309
- try {
310
- const acknowledged = acknowledgeRuntimeErrors(pending.cursor.next, options);
311
- return finish('delivered', { http_status: 200, acknowledged_through: acknowledged.cursor.acknowledged_through });
312
- } catch {
313
- // 届いているが受領済みを残せなかった。次の送信が同じ累計を送り直す(BugHubは二重に数えない)。
314
- return finish('unconfirmed', { reason: 'ack_not_recorded', http_status: 200 });
315
- }
353
+ const answer = await sendReport(credential.credential, pending, options);
354
+ if (answer.outcome === 'too_large') return result('rejected', { reason: 'report_too_large', sent });
355
+ if (answer.outcome !== 'accepted') return finish(answer.outcome, { reason: answer.reason, http_status: answer.http_status });
356
+ try {
357
+ const acknowledged = acknowledgeRuntimeErrors(pending.cursor.next, options);
358
+ return finish('delivered', { http_status: 200, acknowledged_through: acknowledged.cursor.acknowledged_through });
359
+ } catch {
360
+ // 届いているが受領済みを残せなかった。次の送信が同じ累計を送り直す(BugHubは二重に数えない)。
361
+ return finish('unconfirmed', { reason: 'ack_not_recorded', http_status: 200 });
316
362
  }
317
- const code = typeof body?.error === 'string' && REJECTION_CODE.test(body.error) ? body.error : 'unrecognized_response';
318
- if (response.status >= 500) return finish('unconfirmed', { reason: code, http_status: response.status });
319
- return finish('rejected', { reason: code, http_status: response.status });
363
+ }
364
+
365
+ /**
366
+ * 記録を載せない空の報告を1通送り、合鍵・署名・受け口までの経路を確かめる(`reporting verify`)。
367
+ * 送る条件は`report`と同じで、送信を有効にしていない端末と合鍵の無い端末はnetworkへ触れない。
368
+ * storeの記録・受領の印・送る時機の記録には触れない。受け口は端末×製品ごとに1分に1回まで受けるので、
369
+ * 直前に送っていれば429で断られる。
370
+ */
371
+ export async function verifyRuntimeErrorReporting(options = {}) {
372
+ if (!runtimeErrorCollectionSupported(options)) return result('unsupported');
373
+ if (!runtimeErrorReportingEnabled(options)) return result('disabled');
374
+ const credential = readProductCredential(options);
375
+ if (credential.status === 'missing') return result('credential_missing');
376
+ if (credential.status !== 'ok') return result('credential_unsafe', { reason: credential.reason });
377
+ const answer = await sendReport(credential.credential, { runtime_errors: [], resolutions: [] }, options);
378
+ if (answer.outcome === 'accepted') return result('verified', { http_status: 200 });
379
+ return result(answer.outcome, { reason: answer.reason, http_status: answer.http_status });
320
380
  }
321
381
 
322
382
  /**
323
383
  * 自動送信の入口が、子processを起こす前に見る軽い判定。設定が無い端末(外の利用者)では、
324
- * 設定fileの有無を1回見るだけで終わる。
384
+ * 設定fileの有無を1回見るだけで終わる。合鍵は、送るものがある時だけ確かめる——Windowsでは
385
+ * 合鍵の確認が外のprogramを起こすので、毎回のCLI実行には載せない。
325
386
  */
326
387
  export function runtimeErrorAutoReportDue(options = {}) {
327
388
  if (!runtimeErrorReportingEnabled(options)) return false;
328
- if (readProductCredential(options).status !== 'ok') return false;
329
389
  try {
330
390
  const diagnostics = runtimeErrorsDiagnostics(options);
331
391
  if (diagnostics.status !== 'ready' || diagnostics.pending_count === 0) return false;
332
- return throttleReason(options, readDelivery(options), diagnostics.high_watermark) === null;
392
+ if (throttleReason(options, readDelivery(options), diagnostics.high_watermark) !== null) return false;
393
+ return readProductCredential(options).status === 'ok';
333
394
  } catch {
334
395
  return false;
335
396
  }
@@ -1,10 +1,14 @@
1
1
  import { createHash, randomBytes } from 'node:crypto';
2
2
  import {
3
- existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync,
3
+ existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync,
4
4
  } from 'node:fs';
5
5
  import { homedir, platform as hostPlatform, arch as hostArch } from 'node:os';
6
6
  import { dirname, join } from 'node:path';
7
7
 
8
+ import {
9
+ daclIsOwnerOnly, daclIsProtected, isAclScratchName, readWindowsDacl, restrictWindowsDirToOwner, windowsSelfSid,
10
+ } from './windows-owner-only.mjs';
11
+
8
12
  /**
9
13
  * opt-in runtime error store(親plan L6要件。Caveat `caveat.runtime_errors.v1` と同型の工場契約)。
10
14
  *
@@ -15,8 +19,9 @@ import { dirname, join } from 'node:path';
15
19
  * - privacy by design: 保存するのは固定catalogの `error_code` / `message_template` のみ。
16
20
  * 生message・path・引数を保存しない。
17
21
  * - retention: fingerprint集約(同一原因はcount/last_seen更新)+ack済みresolvedの30日compact。
18
- * - POSIX専用: Lattice runtimeはWindows nativeでunsupported(親plan L6)。owner-onlyを証明できない
19
- * 環境では `store_unsafe` でfail closedする。
22
+ * - owner-only: storeは本人だけが触れる形でしか使わない。POSIXはmode(0700・0600)と所有者、Windowsは
23
+ * DACL(本人・SYSTEM・Administratorsだけ、ADR 0194)で確かめ、確かめられなければ `store_unsafe` で
24
+ * fail closedする。
20
25
  */
21
26
 
22
27
  const RUNTIME_ERRORS_SCHEMA = 'lattice.runtime_errors.v1';
@@ -54,7 +59,9 @@ const plain = (value) => typeof value === 'object' && value !== null && !Array.i
54
59
  const exact = (value, keys) => Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
55
60
  const validTime = (value) => typeof value === 'string' && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value;
56
61
  const validVersion = (value) => typeof value === 'string' && /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(value);
57
- const validOs = (value) => typeof value === 'string' && ['darwin', 'linux'].includes(value);
62
+ // 収集に対応するOS。storeを本人だけが触れる形で置けると確かめられるものだけを載せる。
63
+ const SUPPORTED_OS = Object.freeze(['darwin', 'linux', 'win32']);
64
+ const validOs = (value) => typeof value === 'string' && SUPPORTED_OS.includes(value);
58
65
  const validArch = (value) => typeof value === 'string' && ['x64', 'arm64', 'arm'].includes(value);
59
66
 
60
67
  export function defaultFactoryReporterConfigPath(env = process.env) {
@@ -62,9 +69,19 @@ export function defaultFactoryReporterConfigPath(env = process.env) {
62
69
  return join(env.XDG_CONFIG_HOME || join(home, '.config'), 'dotagents', 'factory-reporter.json');
63
70
  }
64
71
 
65
- export function runtimeErrorsStatePath(env = process.env) {
66
- const home = env.HOME || homedir();
67
- return join(env.XDG_STATE_HOME || join(home, '.local', 'state'), 'lattice', 'runtime-errors.json');
72
+ /** Windowsで利用者ごとのdataを置く場所(`%LOCALAPPDATA%`)。 */
73
+ export function windowsLocalAppData(env = process.env) {
74
+ return env.LOCALAPPDATA || join(env.USERPROFILE || homedir(), 'AppData', 'Local');
75
+ }
76
+
77
+ /**
78
+ * storeの置き場。Windowsは`%LOCALAPPDATA%\Lattice\runtime-errors\`という専用のフォルダへ置く
79
+ * ——`%LOCALAPPDATA%\Lattice`には他の機能のfileがあり、フォルダごと本人だけに絞れない。
80
+ */
81
+ export function runtimeErrorsStatePath(env = process.env, platform = hostPlatform()) {
82
+ if (env.XDG_STATE_HOME) return join(env.XDG_STATE_HOME, 'lattice', 'runtime-errors.json');
83
+ if (platform === 'win32') return join(windowsLocalAppData(env), 'Lattice', 'runtime-errors', 'runtime-errors.json');
84
+ return join(env.HOME || homedir(), '.local', 'state', 'lattice', 'runtime-errors.json');
68
85
  }
69
86
 
70
87
  function canonicalReporting(value) {
@@ -77,19 +94,19 @@ function canonicalReporting(value) {
77
94
  return !value.enabled || (value.endpoint !== undefined && value.credential_file !== undefined);
78
95
  }
79
96
 
80
- // このOSで収集に対応するか。Windowsはstoreの所有者と権限をPOSIXの形で確かめられない(`ensureSafeDir`)ので
81
- // 対応しない。設定が有効でも記録は作らず、工場へは`disabled`でなく`unsupported`と答える——設定は有効なのに
82
- // 製品が黙って無効と答えると、受け側は故障と区別できない。
83
- const collectionSupported = (options = {}) => (options.platform ?? hostPlatform()) !== 'win32';
97
+ // このOSで収集に対応するか。対応しないOSでは、設定が有効でも記録は作らず、`disabled`でなく`unsupported`と
98
+ // 答える——設定は有効なのに製品が黙って無効と答えると、受け側は故障と区別できない。
99
+ const collectionSupported = (options = {}) => SUPPORTED_OS.includes(options.platform ?? hostPlatform());
84
100
  const inactiveCollection = (options = {}) => (collectionSupported(options) ? 'disabled' : 'unsupported');
85
101
 
86
102
  export function runtimeErrorCollectionSupported(options = {}) {
87
103
  return collectionSupported(options);
88
104
  }
89
105
 
90
- export function runtimeErrorReportingConfigPath(env = process.env) {
91
- const home = env.HOME || homedir();
92
- return join(env.XDG_CONFIG_HOME || join(home, '.config'), 'lattice', 'runtime-error-reporting.json');
106
+ export function runtimeErrorReportingConfigPath(env = process.env, platform = hostPlatform()) {
107
+ if (env.XDG_CONFIG_HOME) return join(env.XDG_CONFIG_HOME, 'lattice', 'runtime-error-reporting.json');
108
+ if (platform === 'win32') return join(windowsLocalAppData(env), 'Lattice', 'runtime-error-reporting.json');
109
+ return join(env.HOME || homedir(), '.config', 'lattice', 'runtime-error-reporting.json');
93
110
  }
94
111
 
95
112
  /**
@@ -175,18 +192,63 @@ function assertPosix(info, mode) {
175
192
  if ((info.mode & 0o777) !== mode || (typeof process.getuid === 'function' && info.uid !== process.getuid())) throw Error('store_unsafe');
176
193
  }
177
194
 
195
+ const windows = () => hostPlatform() === 'win32';
196
+
197
+ /**
198
+ * Windowsのフォルダが本人・SYSTEM・Administratorsだけのもので、親からの継承を切ってあるか確かめる。
199
+ * 作ったばかりのフォルダは親の権限を継いでいるので、絞る。ただし、他のaccountが触れる形なのに中身がある
200
+ * フォルダは、その中身を信用できないので、絞らずに止める。
201
+ */
202
+ function ensureWindowsDirOwnerOnly(dir) {
203
+ const sid = windowsSelfSid();
204
+ const settled = (sddl) => daclIsOwnerOnly(sddl, sid) && daclIsProtected(sddl);
205
+ const current = readWindowsDacl(dir, dir);
206
+ if (settled(current)) return;
207
+ if (!daclIsOwnerOnly(current, sid) && readdirSync(dir).some((name) => !isAclScratchName(name))) throw Error('store_unsafe');
208
+ restrictWindowsDirToOwner(dir, sid);
209
+ if (!settled(readWindowsDacl(dir, dir))) throw Error('store_unsafe');
210
+ }
211
+
178
212
  export function ensureSafeDir(dir) {
179
- if (hostPlatform() === 'win32') throw Error('store_unsafe');
180
213
  mkdirSync(dir, { recursive: true, mode: 0o700 });
181
214
  const stats = lstatSync(dir);
215
+ // Windowsのjunctionも`isSymbolicLink`で落ちる。
182
216
  if (!stats.isDirectory() || stats.isSymbolicLink()) throw Error('store_unsafe');
183
- assertPosix(stats, 0o700);
217
+ if (windows()) ensureWindowsDirOwnerOnly(dir);
218
+ else assertPosix(stats, 0o700);
184
219
  }
185
220
 
186
- export function ensureSafeFile(path) {
221
+ /**
222
+ * `scratchDir`はWindowsだけが使う: DACLの読み取りが出力fileを置くフォルダ。既定はそのfileのフォルダ。
223
+ * storeの外のfile(合鍵)を確かめる時は、絞ってあるstoreのフォルダを渡す。
224
+ */
225
+ export function ensureSafeFile(path, scratchDir = dirname(path)) {
187
226
  const stats = lstatSync(path);
188
227
  if (!stats.isFile() || stats.isSymbolicLink()) throw Error('store_unsafe');
189
- assertPosix(statSync(path), 0o600);
228
+ if (!windows()) {
229
+ assertPosix(statSync(path), 0o600);
230
+ return;
231
+ }
232
+ const sid = windowsSelfSid();
233
+ // 出力fileを置くフォルダを他のaccountが書けるなら、読んだDACLを信用できない。先にそれを確かめる。
234
+ if (!daclIsOwnerOnly(readWindowsDacl(scratchDir, scratchDir), sid)
235
+ || !daclIsOwnerOnly(readWindowsDacl(path, scratchDir), sid)) throw Error('store_unsafe');
236
+ }
237
+
238
+ /**
239
+ * 一時fileを本番の名前へ置き換える。Windowsは、読み手が開いている宛先へのrenameを一時的に断るので、
240
+ * 少し待って繰り返す(`fs-publish.mjs`と同じ事情)。
241
+ */
242
+ export function replaceStoreFile(source, destination) {
243
+ for (let attempt = 0; ; attempt++) {
244
+ try {
245
+ renameSync(source, destination);
246
+ return;
247
+ } catch (error) {
248
+ if (!windows() || !['EPERM', 'EACCES', 'EBUSY'].includes(error?.code) || attempt >= 7) throw error;
249
+ sleepSync(Math.min(64, 2 ** attempt));
250
+ }
251
+ }
190
252
  }
191
253
 
192
254
  const RECORD_KEYS = Object.freeze(['product', 'product_version', 'component', 'error_code', 'message_template', 'severity', 'fingerprint', 'count', 'first_seen', 'last_seen', 'state_schema_version', 'os', 'arch', 'status', 'resolved_at', 'reason_code', 'sequence']);
@@ -235,8 +297,9 @@ function writeStore(path, store) {
235
297
  const temporary = join(dirname(path), `.runtime-errors-${process.pid}-${randomBytes(6).toString('hex')}`);
236
298
  try {
237
299
  writeFileSync(temporary, `${JSON.stringify(store)}\n`, { mode: 0o600, flag: 'wx' });
238
- assertPosix(statSync(temporary), 0o600);
239
- renameSync(temporary, path);
300
+ // Windowsの一時fileはフォルダの権限を引き継ぐ。置き換えた後の`ensureSafeFile`が確かめる。
301
+ if (!windows()) assertPosix(statSync(temporary), 0o600);
302
+ replaceStoreFile(temporary, path);
240
303
  ensureSafeFile(path);
241
304
  } finally {
242
305
  rmSync(temporary, { force: true });
@@ -256,9 +319,15 @@ function lock(path, fn) {
256
319
  writeFileSync(lockPath, `${process.pid}\n`, { mode: 0o600, flag: 'wx' });
257
320
  break;
258
321
  } catch (error) {
259
- if (!plain(error) || error.code !== 'EEXIST') throw error;
322
+ // Windowsは、消している最中のlockと同じ名前の作成を`EEXIST`でなく`EPERM`で断る。
323
+ if (!plain(error) || !(error.code === 'EEXIST' || (windows() && ['EPERM', 'EACCES'].includes(error.code)))) throw error;
260
324
  let age = 0;
261
- try { age = Date.now() - lstatSync(lockPath).mtimeMs; } catch { continue; }
325
+ try {
326
+ age = Date.now() - lstatSync(lockPath).mtimeMs;
327
+ } catch {
328
+ if (Date.now() >= deadline) throw Error('store_locked');
329
+ continue;
330
+ }
262
331
  // crash残置lockの恒久ロックを避ける唯一の明示救済。閾値未満は正当な並行writerとして待つ。
263
332
  if (age > LOCK_STALE_MS) { try { unlinkSync(lockPath); } catch {} continue; }
264
333
  if (Date.now() >= deadline) throw Error('store_locked');
@@ -0,0 +1,95 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { readFileSync, rmSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+
6
+ /**
7
+ * Windowsで「本人・SYSTEM・Administratorsだけが触れる」ことを確かめる(ADR 0194)。
8
+ *
9
+ * POSIXのmode・uidに当たるものがWindowsには無いので、DACL(誰に何を許すかの一覧)を読む。
10
+ * - 読み方は`icacls <path> /save <file>`。SDDLという、表示言語に依らない形で返る。
11
+ * `icacls <path>`の画面表示は名前が表示言語で変わるので使わない。
12
+ * - 自分のSIDは`whoami /user`で得る。どちらも`%SystemRoot%\System32`の実物を絶対pathで呼ぶ
13
+ * ——PATHに置かれた同名のprogramへ答えを作らせない。
14
+ * - 所有者は読まない(`icacls`は返さない)。空のフォルダを絞る時に所有者を本人へ替える。
15
+ */
16
+
17
+ const SELF_SID = /^S-1-[0-9]+(?:-[0-9]+)+$/;
18
+ const SYSTEM_SID = 'S-1-5-18';
19
+ const ADMINISTRATORS_SID = 'S-1-5-32-544';
20
+ const COMMAND_TIMEOUT_MS = 10_000;
21
+ // `icacls /save`の出力fileの名前。フォルダが空かを見る時、同時に走る別processのこのfileは数えない。
22
+ const SCRATCH_NAME = /^\.acl-\d+-[0-9a-f]{12}$/;
23
+
24
+ let cachedSelfSid = null;
25
+
26
+ function systemTool(name, env = process.env) {
27
+ return join(env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows', 'System32', name);
28
+ }
29
+
30
+ function run(tool, args) {
31
+ const result = spawnSync(systemTool(tool), args, { encoding: 'utf8', windowsHide: true, timeout: COMMAND_TIMEOUT_MS });
32
+ if (result.error || result.status !== 0) throw Error('store_unsafe');
33
+ return result.stdout;
34
+ }
35
+
36
+ /** このprocessを動かしている利用者のSID。process中は変わらないので1回だけ聞く。 */
37
+ export function windowsSelfSid() {
38
+ if (cachedSelfSid === null) {
39
+ const sid = run('whoami.exe', ['/user', '/fo', 'csv', '/nh']).trim().split(',').at(-1).replaceAll('"', '');
40
+ if (!SELF_SID.test(sid)) throw Error('store_unsafe');
41
+ cachedSelfSid = sid;
42
+ }
43
+ return cachedSelfSid;
44
+ }
45
+
46
+ /**
47
+ * SDDLのDACLが、本人・SYSTEM・Administratorsへの許可だけで出来ているか。
48
+ * 読めない形(DACL無し・拒否・条件つき・object用のACE)はすべて「確かめられない」として通さない。
49
+ */
50
+ export function daclIsOwnerOnly(sddl, selfSid) {
51
+ if (typeof sddl !== 'string' || !SELF_SID.test(selfSid)) return false;
52
+ const match = /^D:(?:P|AR|AI)*((?:\([^()]*\))+)$/.exec(sddl);
53
+ if (match === null) return false;
54
+ const trusted = new Set([selfSid, 'SY', SYSTEM_SID, 'BA', ADMINISTRATORS_SID]);
55
+ return match[1].slice(1, -1).split(')(').every((ace) => {
56
+ const fields = ace.split(';');
57
+ return fields.length === 6 && fields[0] === 'A' && fields[3] === '' && fields[4] === '' && trusted.has(fields[5]);
58
+ });
59
+ }
60
+
61
+ /** 親からの継承を切ってあるか(`P`)。切ってあれば、親の権限が後から変わっても降りてこない。 */
62
+ export function daclIsProtected(sddl) {
63
+ return typeof sddl === 'string' && /^D:(?:AR|AI)*P/.test(sddl);
64
+ }
65
+
66
+ export const isAclScratchName = (name) => SCRATCH_NAME.test(name);
67
+
68
+ /**
69
+ * `path`のDACLをSDDLで読む。`icacls`はfileへしか書き出せないので、出力は`scratchDir`へ置いてすぐ消す。
70
+ * `scratchDir`には、他のaccountが書けないフォルダ(絞ったstoreのフォルダ)を渡す——他のaccountが書ける
71
+ * 場所へ置くと、読む前に中身を書き換えられる。
72
+ */
73
+ export function readWindowsDacl(path, scratchDir) {
74
+ const scratch = join(scratchDir, `.acl-${process.pid}-${randomBytes(6).toString('hex')}`);
75
+ try {
76
+ run('icacls.exe', [path, '/save', scratch]);
77
+ const lines = readFileSync(scratch, 'utf16le').replaceAll('\ufeff', '').split(/\r?\n/).filter((line) => line !== '');
78
+ // 1行目が名前、2行目がSDDL。それ以外の形は読めなかったものとして扱う。
79
+ if (lines.length !== 2) throw Error('store_unsafe');
80
+ return lines[1];
81
+ } finally {
82
+ rmSync(scratch, { force: true });
83
+ }
84
+ }
85
+
86
+ /**
87
+ * フォルダを本人・SYSTEM・Administratorsだけに絞る。親からの継承を切り、所有者を本人にする。
88
+ * 中に作るfileはこの権限を引き継ぎ、renameで置き換えた後も保たれる。
89
+ * 誰かがそのフォルダへ直接足した権限は消さない——呼び出し側が絞った後のDACLを読み直して、残っていれば止める。
90
+ */
91
+ export function restrictWindowsDirToOwner(dir, selfSid) {
92
+ const sids = [...new Set([selfSid, SYSTEM_SID, ADMINISTRATORS_SID])];
93
+ run('icacls.exe', [dir, '/inheritance:r', '/grant:r', ...sids.map((sid) => `*${sid}:(OI)(CI)F`)]);
94
+ run('icacls.exe', [dir, '/setowner', `*${selfSid}`]);
95
+ }