@knowttl/mg-axi 0.1.4 → 0.1.6
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/README.md +303 -1268
- package/axi-credential.schema.json +65 -0
- package/dist/app-auth.js +9 -4
- package/dist/auth-status.js +20 -0
- package/dist/auth.js +24 -10
- package/dist/catalogue.js +16 -10
- package/dist/cli.js +57 -26
- package/dist/credentials.js +78 -0
- package/dist/docs.js +10 -11
- package/dist/doctor.js +10 -8
- package/dist/graph-session.js +15 -11
- package/dist/hook.js +9 -32
- package/dist/msal-app-provider.js +27 -101
- package/dist/msal-provider.js +67 -54
- package/dist/mutations.js +42 -53
- package/dist/paths.js +17 -0
- package/dist/private-file.js +227 -0
- package/dist/profiles.js +180 -134
- package/dist/redact.js +26 -0
- package/dist/secrets.js +37 -0
- package/dist/setup.js +2 -2
- package/docs/coverage.md +1 -1
- package/package.json +4 -6
- package/skills/mg-axi/SKILL.md +14 -11
package/dist/mutations.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { closeSync, existsSync, fsyncSync,
|
|
1
|
+
import { closeSync, existsSync, fsyncSync, openSync, unlinkSync } from "node:fs";
|
|
2
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { AxiError } from "axi-sdk-js";
|
|
6
6
|
import { encodeGraphPathSegment, GRAPH_HOST, redactGraphValue } from "./graph-session.js";
|
|
7
|
-
import { validateApplicationProfile, validateDelegatedProfile } from "./profiles.js";
|
|
7
|
+
import { booleanEnv, movedError, validateApplicationProfile, validateDelegatedProfile } from "./profiles.js";
|
|
8
|
+
import { statePath } from "./paths.js";
|
|
9
|
+
import { PrivateFiles } from "./private-file.js";
|
|
8
10
|
// Shared mutation coordinator for reviewed named mutation families.
|
|
9
11
|
// The gate order follows az-axi's write gates as the reference: read-only default,
|
|
10
12
|
// allowWrites plus a scope allowlist, preview, --execute, --confirm,
|
|
@@ -15,15 +17,19 @@ import { validateApplicationProfile, validateDelegatedProfile } from "./profiles
|
|
|
15
17
|
export const READ_ONLY_ENV = "MG_AXI_READ_ONLY";
|
|
16
18
|
export const WRITE_LOG_ENV = "MG_AXI_WRITE_LOG";
|
|
17
19
|
export function readOnlyForced(env = process.env) {
|
|
18
|
-
return
|
|
20
|
+
return booleanEnv(READ_ONLY_ENV, env);
|
|
19
21
|
}
|
|
20
22
|
// Mutation intents and outcomes are journaled here: MG_AXI_WRITE_LOG when
|
|
21
|
-
// set, otherwise
|
|
23
|
+
// set, otherwise the machine-local state directory. A blank override falls back.
|
|
22
24
|
export function resolveWriteLogPath(env = process.env) {
|
|
23
25
|
const override = env[WRITE_LOG_ENV]?.trim();
|
|
24
26
|
if (override)
|
|
25
27
|
return override;
|
|
26
|
-
|
|
28
|
+
const target = statePath("writes.log", env);
|
|
29
|
+
const legacy = join(homedir(), ".mg-axi", "writes.log");
|
|
30
|
+
if (!existsSync(target) && existsSync(legacy))
|
|
31
|
+
movedError("Write log", legacy, target, "The moved journal keeps its intent and outcome history");
|
|
32
|
+
return target;
|
|
27
33
|
}
|
|
28
34
|
export const mutationFetchTransport = async (request) => {
|
|
29
35
|
const response = await fetch(request.url, {
|
|
@@ -155,8 +161,8 @@ function decodeMutationBody(response) {
|
|
|
155
161
|
// send needs a coordinator authorization minted after the write gates pass.
|
|
156
162
|
// The bound profile copy is private; no profile snapshot is exposed.
|
|
157
163
|
export function createMutationSender(args) {
|
|
158
|
-
const { delegated, application, transport } = args;
|
|
159
|
-
const bound = args.profile.mode === "application" ? validateApplicationProfile(args.profile) : validateDelegatedProfile(args.profile);
|
|
164
|
+
const { delegated, application, transport, files = new PrivateFiles() } = args;
|
|
165
|
+
const bound = args.profile.mode === "application" ? validateApplicationProfile(args.profile, files) : validateDelegatedProfile(args.profile, files);
|
|
160
166
|
async function credential(scopes, signal) {
|
|
161
167
|
signal?.throwIfAborted();
|
|
162
168
|
if (bound.mode === "delegated") {
|
|
@@ -165,14 +171,14 @@ export function createMutationSender(args) {
|
|
|
165
171
|
"Pass the operation's documented delegated permission using full https://graph.microsoft.com/ scope names",
|
|
166
172
|
]);
|
|
167
173
|
}
|
|
168
|
-
return (await delegated.credential(
|
|
174
|
+
return (await delegated.credential(bound, scopes)).token;
|
|
169
175
|
}
|
|
170
176
|
if (scopes !== undefined) {
|
|
171
177
|
throw new AxiError("Application profiles use the configured Graph .default audience; delegated scopes are unavailable", "VALIDATION_ERROR", [
|
|
172
178
|
"mg-axi profile show --profile <name>",
|
|
173
179
|
]);
|
|
174
180
|
}
|
|
175
|
-
return (await application.credential(
|
|
181
|
+
return (await application.credential(bound)).token;
|
|
176
182
|
}
|
|
177
183
|
async function send(authorization, options) {
|
|
178
184
|
let handedOff = false;
|
|
@@ -240,8 +246,8 @@ export function createMutationSender(args) {
|
|
|
240
246
|
return sender;
|
|
241
247
|
}
|
|
242
248
|
function flushDirectory(path) {
|
|
243
|
-
// Node cannot open directory handles on Windows; journal
|
|
244
|
-
//
|
|
249
|
+
// Node cannot open directory handles on Windows; atomic journal writes
|
|
250
|
+
// still fsync the file before replacement.
|
|
245
251
|
if (process.platform === "win32")
|
|
246
252
|
return;
|
|
247
253
|
const fd = openSync(path, "r");
|
|
@@ -266,16 +272,16 @@ function serialize(value) {
|
|
|
266
272
|
}
|
|
267
273
|
}
|
|
268
274
|
export function createMutationCoordinator(args) {
|
|
269
|
-
const { profile, delegated, application, transport } = args;
|
|
275
|
+
const { profile, delegated, application, transport, files = new PrivateFiles() } = args;
|
|
270
276
|
const clock = args.clock ?? Date.now;
|
|
271
277
|
const journalPath = args.journalPath ?? resolveWriteLogPath();
|
|
272
|
-
const validated = profile.mode === "application" ? validateApplicationProfile(profile) : validateDelegatedProfile(profile);
|
|
278
|
+
const validated = profile.mode === "application" ? validateApplicationProfile(profile, files) : validateDelegatedProfile(profile, files);
|
|
273
279
|
const scope = Object.freeze({
|
|
274
280
|
tenantId: validated.tenantId,
|
|
275
281
|
allowWrites: validated.writes?.allowWrites === true,
|
|
276
282
|
operations: Object.freeze([...(validated.writes?.operations ?? [])]),
|
|
277
283
|
});
|
|
278
|
-
const sender = createMutationSender({ profile: validated, delegated, application, transport });
|
|
284
|
+
const sender = createMutationSender({ profile: validated, delegated, application, transport, files });
|
|
279
285
|
function enforceWriteGates(definition) {
|
|
280
286
|
// Gate 1: forced read-only overrides every profile.
|
|
281
287
|
// Gate 2: the profile must opt in through hand-edited configuration.
|
|
@@ -357,7 +363,7 @@ export function createMutationCoordinator(args) {
|
|
|
357
363
|
const line = JSON.stringify({ ...safe, intentKey: record.intentKey });
|
|
358
364
|
try {
|
|
359
365
|
const directory = dirname(journalPath);
|
|
360
|
-
const firstCreated =
|
|
366
|
+
const firstCreated = files.directory(directory);
|
|
361
367
|
if (firstCreated) {
|
|
362
368
|
let path = directory;
|
|
363
369
|
const parent = dirname(firstCreated);
|
|
@@ -367,48 +373,31 @@ export function createMutationCoordinator(args) {
|
|
|
367
373
|
}
|
|
368
374
|
flushDirectory(parent);
|
|
369
375
|
}
|
|
370
|
-
if (process.platform === "win32" && !existsSync(journalPath)) {
|
|
371
|
-
const journal = openSync(journalPath, "a", 0o600);
|
|
372
|
-
try {
|
|
373
|
-
fsyncSync(journal);
|
|
374
|
-
}
|
|
375
|
-
finally {
|
|
376
|
-
closeSync(journal);
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
376
|
const lockPath = `${journalPath}.lock`;
|
|
380
377
|
const lock = openSync(lockPath, "wx", 0o600);
|
|
381
378
|
try {
|
|
382
|
-
|
|
383
|
-
const
|
|
384
|
-
|
|
385
|
-
if (
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
`Intent '${record.id}' requires manual reconciliation; read back its target and never resend it`,
|
|
401
|
-
]);
|
|
402
|
-
}
|
|
379
|
+
files.protect(lockPath);
|
|
380
|
+
const contents = existsSync(journalPath) ? files.read(journalPath) : "";
|
|
381
|
+
if (record.kind === "intent") {
|
|
382
|
+
if (contents && !contents.endsWith("\n"))
|
|
383
|
+
throw new Error("Incomplete mutation journal");
|
|
384
|
+
const records = contents.split("\n").filter(Boolean)
|
|
385
|
+
.map(entry => JSON.parse(entry));
|
|
386
|
+
if (records.some(entry => !entry || !["intent", "outcome"].includes(entry.kind)
|
|
387
|
+
|| typeof entry.intentKey !== "string" || !/^[a-f0-9]{64}$/.test(entry.intentKey))) {
|
|
388
|
+
throw new Error("Invalid mutation journal");
|
|
389
|
+
}
|
|
390
|
+
// No automatic replay: a recreated coordinator refuses an intent
|
|
391
|
+
// recorded without a terminal outcome, and a duplicate intent id
|
|
392
|
+
// is never reserved twice.
|
|
393
|
+
if (records.some(entry => entry.kind === "intent" && entry.intentKey === record.intentKey)) {
|
|
394
|
+
throw new AxiError(`blocked: intent '${record.id}' was already reserved`, "ALREADY_EXECUTED", [
|
|
395
|
+
`Intent '${record.id}' requires manual reconciliation; read back its target and never resend it`,
|
|
396
|
+
]);
|
|
403
397
|
}
|
|
404
|
-
writeFileSync(journal, `${line}\n`);
|
|
405
|
-
fsyncSync(journal);
|
|
406
|
-
if (created)
|
|
407
|
-
flushDirectory(directory);
|
|
408
|
-
}
|
|
409
|
-
finally {
|
|
410
|
-
closeSync(journal);
|
|
411
398
|
}
|
|
399
|
+
files.write(journalPath, `${contents}${line}\n`);
|
|
400
|
+
flushDirectory(directory);
|
|
412
401
|
}
|
|
413
402
|
finally {
|
|
414
403
|
closeSync(lock);
|
|
@@ -416,7 +405,7 @@ export function createMutationCoordinator(args) {
|
|
|
416
405
|
}
|
|
417
406
|
}
|
|
418
407
|
catch (error) {
|
|
419
|
-
if (error instanceof AxiError && error.code === "ALREADY_EXECUTED")
|
|
408
|
+
if (error instanceof AxiError && (error.code === "ALREADY_EXECUTED" || error.code === "CONFIG_INSECURE"))
|
|
420
409
|
throw error;
|
|
421
410
|
if (record.kind === "intent") {
|
|
422
411
|
throw new AxiError(`Mutation intent '${record.id}' could not be recorded`, "INTENT_NOT_RECORDED", [
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { isAbsolute, join, win32 } from "node:path";
|
|
3
|
+
export function toolDirectory(kind, env = process.env, platform = process.platform, home = homedir()) {
|
|
4
|
+
if (platform === "win32") {
|
|
5
|
+
const base = env[kind === "config" ? "APPDATA" : "LOCALAPPDATA"];
|
|
6
|
+
return win32.join(base || win32.join(home, "AppData", kind === "config" ? "Roaming" : "Local"), "mg-axi");
|
|
7
|
+
}
|
|
8
|
+
const base = env[kind === "config" ? "XDG_CONFIG_HOME" : "XDG_STATE_HOME"];
|
|
9
|
+
return join(base && isAbsolute(base) ? base : join(home, kind === "config" ? ".config" : ".local/state"), "mg-axi");
|
|
10
|
+
}
|
|
11
|
+
export function configPath(env = process.env) {
|
|
12
|
+
const override = env.MG_AXI_CONFIG?.trim();
|
|
13
|
+
return override ? override : join(toolDirectory("config", env), "config.json");
|
|
14
|
+
}
|
|
15
|
+
export function statePath(name, env = process.env) {
|
|
16
|
+
return join(toolDirectory("state", env), name);
|
|
17
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { chmodSync, closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { dirname, isAbsolute, win32 } from "node:path";
|
|
5
|
+
import { AxiError } from "axi-sdk-js";
|
|
6
|
+
// LiteralPath and single-quoted literals keep file names out of PowerShell syntax.
|
|
7
|
+
export const windowsAcl = (path, directory, action) => {
|
|
8
|
+
const literal = `'${path.replace(/'/g, "''")}'`;
|
|
9
|
+
const script = `$ErrorActionPreference='Stop'; $p=${literal};
|
|
10
|
+
$item=Get-Item -LiteralPath $p -Force;
|
|
11
|
+
if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Reparse point' };
|
|
12
|
+
${action === "inspect" ? "" : `
|
|
13
|
+
$user=[Security.Principal.WindowsIdentity]::GetCurrent().User;
|
|
14
|
+
$ids=@($user.Value,'S-1-5-18','S-1-5-32-544');
|
|
15
|
+
${action === "restrict" ? `
|
|
16
|
+
$acl=[Security.AccessControl.${directory ? "DirectorySecurity" : "FileSecurity"}]::new();
|
|
17
|
+
$acl.SetOwner($user); $acl.SetAccessRuleProtection($true,$false);
|
|
18
|
+
foreach ($id in $ids) {
|
|
19
|
+
$sid=[Security.Principal.SecurityIdentifier]::new($id);
|
|
20
|
+
$rule=[Security.AccessControl.FileSystemAccessRule]::new($sid,'FullControl','${directory ? "ContainerInherit,ObjectInherit" : "None"}','None','Allow');
|
|
21
|
+
$acl.AddAccessRule($rule);
|
|
22
|
+
}; Set-Acl -LiteralPath $p -AclObject $acl;` : `
|
|
23
|
+
$acl=Get-Acl -LiteralPath $p;
|
|
24
|
+
if (!$acl.AreAccessRulesProtected -or $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value -ne $user.Value) { throw 'Unprotected ACL' };
|
|
25
|
+
$rules=@($acl.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier]));
|
|
26
|
+
if ($rules.Count -ne 3) { throw 'Unexpected ACL' };
|
|
27
|
+
foreach ($id in $ids) {
|
|
28
|
+
$matches=@($rules | Where-Object { $_.IdentityReference.Value -eq $id -and $_.AccessControlType -eq 'Allow' -and $_.FileSystemRights -eq 'FullControl' -and !$_.IsInherited -and $_.InheritanceFlags -eq '${directory ? "ContainerInherit, ObjectInherit" : "None"}' -and $_.PropagationFlags -eq 'None' });
|
|
29
|
+
if ($matches.Count -ne 1) { throw 'Unexpected grant' };
|
|
30
|
+
};`}`}`;
|
|
31
|
+
execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], { stdio: "pipe", windowsHide: true });
|
|
32
|
+
};
|
|
33
|
+
// All private-file permissions live here; Windows branches are injectable on Linux.
|
|
34
|
+
export class PrivateFiles {
|
|
35
|
+
platform;
|
|
36
|
+
acl;
|
|
37
|
+
uid;
|
|
38
|
+
constructor(platform = process.platform, acl = windowsAcl, uid = process.getuid?.()) {
|
|
39
|
+
this.platform = platform;
|
|
40
|
+
this.acl = acl;
|
|
41
|
+
this.uid = uid;
|
|
42
|
+
}
|
|
43
|
+
absolute(path) {
|
|
44
|
+
return this.platform === "win32" ? /^[A-Za-z]:[\\/]/.test(path) && win32.isAbsolute(path) : isAbsolute(path);
|
|
45
|
+
}
|
|
46
|
+
insecure(path) {
|
|
47
|
+
throw new AxiError(`Insecure file ${path}`, "CONFIG_INSECURE", [this.platform === "win32" ? `Re-run the tool's write to protect ${path}` : `chmod 600 '${path.replace(/'/g, "'\\''")}'`]);
|
|
48
|
+
}
|
|
49
|
+
check(path, secret = true, verifyAcl = false) {
|
|
50
|
+
try {
|
|
51
|
+
const stat = lstatSync(path);
|
|
52
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
53
|
+
this.insecure(path);
|
|
54
|
+
if (this.platform === "win32") {
|
|
55
|
+
try {
|
|
56
|
+
this.acl(path, false, verifyAcl ? "verify" : "inspect");
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
if (error instanceof AxiError)
|
|
60
|
+
throw error;
|
|
61
|
+
this.insecure(path);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
else if (stat.uid !== this.uid || (stat.mode & (secret ? 0o077 : 0o022)) !== 0)
|
|
65
|
+
this.insecure(path);
|
|
66
|
+
return stat;
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
const code = error.code;
|
|
70
|
+
if (code === "ENOENT")
|
|
71
|
+
throw error;
|
|
72
|
+
if (code === "EACCES" || code === "EPERM" || code === "ELOOP")
|
|
73
|
+
return this.insecure(path);
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
read(path, secret = true, verifyAcl = false) {
|
|
78
|
+
const checked = this.check(path, secret, verifyAcl);
|
|
79
|
+
let fd;
|
|
80
|
+
try {
|
|
81
|
+
fd = openSync(path, constants.O_RDONLY | (this.platform === "win32" ? 0 : constants.O_NOFOLLOW));
|
|
82
|
+
const opened = fstatSync(fd);
|
|
83
|
+
if (opened.dev !== checked.dev || opened.ino !== checked.ino)
|
|
84
|
+
this.insecure(path);
|
|
85
|
+
return readFileSync(fd, "utf8").replace(/^\uFEFF/, "");
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (error instanceof AxiError)
|
|
89
|
+
throw error;
|
|
90
|
+
const code = error.code;
|
|
91
|
+
if (code === "EACCES" || code === "EPERM" || code === "ELOOP")
|
|
92
|
+
this.insecure(path);
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
if (fd !== undefined)
|
|
97
|
+
try {
|
|
98
|
+
closeSync(fd);
|
|
99
|
+
}
|
|
100
|
+
catch { /* original error preserved */ }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
directory(path) {
|
|
104
|
+
try {
|
|
105
|
+
let firstCreated;
|
|
106
|
+
if (!existsSync(path)) {
|
|
107
|
+
const parent = dirname(path);
|
|
108
|
+
if (!existsSync(parent))
|
|
109
|
+
firstCreated = this.directory(parent);
|
|
110
|
+
mkdirSync(path, { mode: 0o700 });
|
|
111
|
+
firstCreated ??= path;
|
|
112
|
+
}
|
|
113
|
+
const stat = lstatSync(path);
|
|
114
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
115
|
+
this.insecure(path);
|
|
116
|
+
if (this.platform === "win32") {
|
|
117
|
+
try {
|
|
118
|
+
this.acl(path, true, "restrict");
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
if (error instanceof AxiError)
|
|
122
|
+
throw error;
|
|
123
|
+
this.insecure(path);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
if (stat.uid !== this.uid)
|
|
128
|
+
this.insecure(path);
|
|
129
|
+
chmodSync(path, 0o700);
|
|
130
|
+
}
|
|
131
|
+
return firstCreated;
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
if (error instanceof AxiError)
|
|
135
|
+
throw error;
|
|
136
|
+
const code = error.code;
|
|
137
|
+
if (code === "EACCES" || code === "EPERM" || code === "ELOOP")
|
|
138
|
+
this.insecure(path);
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
protect(path) {
|
|
143
|
+
try {
|
|
144
|
+
const stat = lstatSync(path);
|
|
145
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
146
|
+
this.insecure(path);
|
|
147
|
+
if (this.platform === "win32") {
|
|
148
|
+
try {
|
|
149
|
+
this.acl(path, false, "restrict");
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
if (error instanceof AxiError)
|
|
153
|
+
throw error;
|
|
154
|
+
this.insecure(path);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
if (stat.uid !== this.uid)
|
|
159
|
+
this.insecure(path);
|
|
160
|
+
chmodSync(path, 0o600);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
if (error instanceof AxiError)
|
|
165
|
+
throw error;
|
|
166
|
+
const code = error.code;
|
|
167
|
+
if (code === "EACCES" || code === "EPERM" || code === "ELOOP")
|
|
168
|
+
this.insecure(path);
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
write(path, content) {
|
|
173
|
+
this.directory(dirname(path));
|
|
174
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
175
|
+
let fd;
|
|
176
|
+
try {
|
|
177
|
+
try {
|
|
178
|
+
fd = openSync(temporary, "wx", this.platform === "win32" ? undefined : 0o600);
|
|
179
|
+
try {
|
|
180
|
+
if (this.platform === "win32")
|
|
181
|
+
this.acl(temporary, false, "restrict");
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
if (error instanceof AxiError)
|
|
185
|
+
throw error;
|
|
186
|
+
this.insecure(path);
|
|
187
|
+
}
|
|
188
|
+
writeFileSync(fd, content);
|
|
189
|
+
fsyncSync(fd);
|
|
190
|
+
closeSync(fd);
|
|
191
|
+
fd = undefined;
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
if (error instanceof AxiError)
|
|
195
|
+
throw error;
|
|
196
|
+
const code = error.code;
|
|
197
|
+
if (code === "EACCES" || code === "EPERM" || code === "ELOOP")
|
|
198
|
+
this.insecure(path);
|
|
199
|
+
throw error;
|
|
200
|
+
}
|
|
201
|
+
const deadline = Date.now() + 1000;
|
|
202
|
+
let delay = 10;
|
|
203
|
+
for (;;) {
|
|
204
|
+
try {
|
|
205
|
+
renameSync(temporary, path);
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
if (error.code === "ELOOP")
|
|
210
|
+
this.insecure(path);
|
|
211
|
+
if (this.platform !== "win32" || !["EPERM", "EBUSY", "EACCES"].includes(error.code ?? "") || Date.now() >= deadline)
|
|
212
|
+
throw error;
|
|
213
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.min(delay, deadline - Date.now()));
|
|
214
|
+
delay = Math.min(delay * 2, 100);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
finally {
|
|
219
|
+
if (fd !== undefined)
|
|
220
|
+
try {
|
|
221
|
+
closeSync(fd);
|
|
222
|
+
}
|
|
223
|
+
catch { /* original error preserved */ }
|
|
224
|
+
rmSync(temporary, { force: true });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|