@quolu/lattice 0.74.0 → 0.75.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/package.json +1 -1
- package/src/runtime-error-reporting.mjs +42 -13
- package/src/runtime-errors.mjs +91 -22
- package/src/windows-owner-only.mjs +95 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quolu/lattice",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.75.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",
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
2
2
|
import {
|
|
3
|
-
existsSync, lstatSync, mkdirSync, readFileSync,
|
|
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
|
/**
|
|
@@ -51,13 +53,34 @@ const OUTCOMES = Object.freeze(['delivered', 'nothing_pending', 'disabled', 'uns
|
|
|
51
53
|
const plain = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
52
54
|
const exact = (value, keys) => Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
|
|
53
55
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
+
/** 合鍵の置き場(BugHubの契約)。Windowsは`%LOCALAPPDATA%\bughub\product-credentials\`。 */
|
|
57
|
+
export function productCredentialPath(env = process.env, platform = hostPlatform()) {
|
|
58
|
+
const base = platform === 'win32' ? join(windowsLocalAppData(env), 'bughub') : join(env.HOME || homedir(), '.config', 'bughub');
|
|
59
|
+
return join(base, 'product-credentials', `${PRODUCT_ID}.json`);
|
|
56
60
|
}
|
|
57
61
|
|
|
58
62
|
/**
|
|
59
|
-
*
|
|
60
|
-
*
|
|
63
|
+
* Windowsの合鍵が本人・SYSTEM・Administratorsだけのものか。DACLの読み取りは出力fileを置く場所が要るので、
|
|
64
|
+
* 絞ったstoreのフォルダを使う(無ければ作る)。合鍵のフォルダへは何も書かない。
|
|
65
|
+
*/
|
|
66
|
+
function windowsCredentialUnsafeReason(path, options) {
|
|
67
|
+
const storeDir = dirname(options.storePath ?? runtimeErrorsStatePath(options.env ?? process.env));
|
|
68
|
+
try {
|
|
69
|
+
ensureSafeDir(storeDir);
|
|
70
|
+
} catch {
|
|
71
|
+
return 'acl_unverifiable';
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
ensureSafeFile(path, storeDir);
|
|
75
|
+
return null;
|
|
76
|
+
} catch {
|
|
77
|
+
return 'acl_not_owner_only';
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 合鍵のfileを読む。BugHubの持ち主が置く形(symlinkでなく、POSIXは本人所有・0600、Windowsは
|
|
83
|
+
* 本人・SYSTEM・Administratorsだけに権限)以外は使わない。秘密は戻り値の中だけに留め、結果や記録へ写さない。
|
|
61
84
|
*/
|
|
62
85
|
export function readProductCredential(options = {}) {
|
|
63
86
|
const path = options.credentialPath ?? productCredentialPath(options.env ?? process.env);
|
|
@@ -68,8 +91,13 @@ export function readProductCredential(options = {}) {
|
|
|
68
91
|
return error?.code === 'ENOENT' ? { status: 'missing' } : { status: 'unsafe', reason: 'unreadable' };
|
|
69
92
|
}
|
|
70
93
|
if (!stats.isFile() || stats.isSymbolicLink()) return { status: 'unsafe', reason: 'not_regular_file' };
|
|
71
|
-
if ((
|
|
72
|
-
|
|
94
|
+
if (hostPlatform() === 'win32') {
|
|
95
|
+
const reason = windowsCredentialUnsafeReason(path, options);
|
|
96
|
+
if (reason !== null) return { status: 'unsafe', reason };
|
|
97
|
+
} else {
|
|
98
|
+
if ((stats.mode & 0o777) !== 0o600) return { status: 'unsafe', reason: 'mode_not_0600' };
|
|
99
|
+
if (typeof process.getuid === 'function' && stats.uid !== process.getuid()) return { status: 'unsafe', reason: 'owner_mismatch' };
|
|
100
|
+
}
|
|
73
101
|
let value;
|
|
74
102
|
try {
|
|
75
103
|
value = JSON.parse(readFileSync(path, 'utf8'));
|
|
@@ -151,7 +179,7 @@ function writeDelivery(options, state) {
|
|
|
151
179
|
const temporary = join(dirname(path), `.runtime-errors-delivery-${process.pid}-${randomBytes(6).toString('hex')}`);
|
|
152
180
|
try {
|
|
153
181
|
writeFileSync(temporary, `${JSON.stringify(state)}\n`, { mode: 0o600, flag: 'wx' });
|
|
154
|
-
|
|
182
|
+
replaceStoreFile(temporary, path);
|
|
155
183
|
} finally {
|
|
156
184
|
rmSync(temporary, { force: true });
|
|
157
185
|
}
|
|
@@ -206,7 +234,7 @@ export function setRuntimeErrorReporting(enabled, options = {}) {
|
|
|
206
234
|
const temporary = join(dirname(path), `.runtime-error-reporting-${process.pid}-${randomBytes(6).toString('hex')}`);
|
|
207
235
|
try {
|
|
208
236
|
writeFileSync(temporary, `${JSON.stringify({ schema: REPORTING_CONFIG_SCHEMA, enabled })}\n`, { mode: 0o600, flag: 'wx' });
|
|
209
|
-
|
|
237
|
+
replaceStoreFile(temporary, path);
|
|
210
238
|
} finally {
|
|
211
239
|
rmSync(temporary, { force: true });
|
|
212
240
|
}
|
|
@@ -321,15 +349,16 @@ export async function reportRuntimeErrors(options = {}) {
|
|
|
321
349
|
|
|
322
350
|
/**
|
|
323
351
|
* 自動送信の入口が、子processを起こす前に見る軽い判定。設定が無い端末(外の利用者)では、
|
|
324
|
-
* 設定fileの有無を1
|
|
352
|
+
* 設定fileの有無を1回見るだけで終わる。合鍵は、送るものがある時だけ確かめる——Windowsでは
|
|
353
|
+
* 合鍵の確認が外のprogramを起こすので、毎回のCLI実行には載せない。
|
|
325
354
|
*/
|
|
326
355
|
export function runtimeErrorAutoReportDue(options = {}) {
|
|
327
356
|
if (!runtimeErrorReportingEnabled(options)) return false;
|
|
328
|
-
if (readProductCredential(options).status !== 'ok') return false;
|
|
329
357
|
try {
|
|
330
358
|
const diagnostics = runtimeErrorsDiagnostics(options);
|
|
331
359
|
if (diagnostics.status !== 'ready' || diagnostics.pending_count === 0) return false;
|
|
332
|
-
|
|
360
|
+
if (throttleReason(options, readDelivery(options), diagnostics.high_watermark) !== null) return false;
|
|
361
|
+
return readProductCredential(options).status === 'ok';
|
|
333
362
|
} catch {
|
|
334
363
|
return false;
|
|
335
364
|
}
|
package/src/runtime-errors.mjs
CHANGED
|
@@ -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
|
|
19
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
return
|
|
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
|
|
81
|
-
//
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
217
|
+
if (windows()) ensureWindowsDirOwnerOnly(dir);
|
|
218
|
+
else assertPosix(stats, 0o700);
|
|
184
219
|
}
|
|
185
220
|
|
|
186
|
-
|
|
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
|
-
|
|
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
|
-
|
|
239
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
+
}
|