pmsec 2026.11.2 → 2026.11.4
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/cli.mjs +8 -3
- package/src/tools/npm.mjs +3 -1
- package/src/util/io.mjs +21 -45
- package/src/util/lines.mjs +41 -10
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -188,8 +188,10 @@ function runDoctor(targets, json, ctx, out) {
|
|
|
188
188
|
platform: ctx.platform,
|
|
189
189
|
uid, euid, username,
|
|
190
190
|
home: ctx.home,
|
|
191
|
-
|
|
192
|
-
|
|
191
|
+
// This port resolves paths from HOME only; PMSEC_HOME is a bash/powershell
|
|
192
|
+
// feature (it needs the root->owner chown handback those ports implement).
|
|
193
|
+
pmsecHome: null,
|
|
194
|
+
pmsecHomeSource: "HOME",
|
|
193
195
|
tools,
|
|
194
196
|
ok: okFlag,
|
|
195
197
|
};
|
|
@@ -201,7 +203,7 @@ function runDoctor(targets, json, ctx, out) {
|
|
|
201
203
|
out.write(` platform : ${ctx.platform}\n`);
|
|
202
204
|
if (isPosix) out.write(` uid/euid : ${uid}/${euid}${username ? ` (${username})` : ""}\n`);
|
|
203
205
|
out.write(` HOME : ${ctx.home}\n`);
|
|
204
|
-
out.write(` PMSEC_HOME :
|
|
206
|
+
out.write(` PMSEC_HOME : (not supported by this port — using HOME)\n\n`);
|
|
205
207
|
for (const t of tools) {
|
|
206
208
|
const flag = t.parentWritable ? "ok " : "BLOCK ";
|
|
207
209
|
const ownerSuffix = t.exists && t.owner !== null ? ` (uid=${t.owner})` : "";
|
|
@@ -299,6 +301,9 @@ export async function run(argv, {
|
|
|
299
301
|
try { targets = selectTools(opts.only); }
|
|
300
302
|
catch (e) { err.write(`pmsec: ${e.message}\n`); return 2; }
|
|
301
303
|
const ctx = { env, home, platform };
|
|
304
|
+
if (env.PMSEC_HOME) {
|
|
305
|
+
err.write(`pmsec: PMSEC_HOME is ignored by the node port; paths resolve from HOME (${home}). Use the bash port for root-context redirection.\n`);
|
|
306
|
+
}
|
|
302
307
|
try {
|
|
303
308
|
if (opts.mode === "check") return await runCheck(targets, opts, ctx, out, err);
|
|
304
309
|
if (opts.mode === "disable") return await runDisable(targets, opts.json, ctx, out, err);
|
package/src/tools/npm.mjs
CHANGED
|
@@ -29,9 +29,11 @@ export async function read(ctx) {
|
|
|
29
29
|
const p = path(ctx);
|
|
30
30
|
const raw = await readSafe(p);
|
|
31
31
|
const value = readKey(raw, key);
|
|
32
|
+
// npm stores whole days. Anything else is reported as unparseable (null) so
|
|
33
|
+
// `--check` fails instead of letting NaN slip past the `< days` comparison.
|
|
32
34
|
return {
|
|
33
35
|
path: p, configured: value,
|
|
34
|
-
days: value
|
|
36
|
+
days: value !== null && /^\d+$/.test(value) ? Number(value) : null,
|
|
35
37
|
extras: readExtras(raw, extras)
|
|
36
38
|
};
|
|
37
39
|
}
|
package/src/util/io.mjs
CHANGED
|
@@ -1,44 +1,26 @@
|
|
|
1
|
-
import { mkdir, readFile,
|
|
2
|
-
import { dirname
|
|
1
|
+
import { mkdir, readFile, rename, lstat, unlink, open } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
|
-
import { spawnSync } from "node:child_process";
|
|
5
4
|
import { randomBytes } from "node:crypto";
|
|
6
|
-
import { homedir } from "node:os";
|
|
7
5
|
|
|
8
6
|
export async function readSafe(path) {
|
|
9
|
-
|
|
7
|
+
// Editors on Windows may prepend a BOM; it would hide the first header or key.
|
|
8
|
+
try { return (await readFile(path, "utf8")).replace(/^/, ""); }
|
|
10
9
|
catch (e) { if (e.code === "ENOENT") return ""; throw e; }
|
|
11
10
|
}
|
|
12
11
|
|
|
13
|
-
function isPermErr(e) { return e?.code === "EACCES" || e?.code === "EPERM"; }
|
|
14
|
-
|
|
15
12
|
async function isSymlink(p) {
|
|
16
13
|
try { return (await lstat(p)).isSymbolicLink(); }
|
|
17
14
|
catch { return false; }
|
|
18
15
|
}
|
|
19
16
|
|
|
20
|
-
function underHome(p, home) {
|
|
21
|
-
const r = resolve(p);
|
|
22
|
-
const h = resolve(home);
|
|
23
|
-
return r === h || r.startsWith(h + sep);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
17
|
export function shellQuote(s) { return `'${String(s).replace(/'/g, `'\\''`)}'`; }
|
|
27
18
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
return false;
|
|
34
|
-
}
|
|
35
|
-
if (!underHome(path, home)) {
|
|
36
|
-
err.write(`pmsec: refusing to chown ${path} outside HOME (${home}); fix ownership manually.\n`);
|
|
37
|
-
return false;
|
|
38
|
-
}
|
|
39
|
-
err.write(`pmsec: ${path} not writable; running \`sudo chown -h $(id -u):$(id -g) ${shellQuote(path)}\`. You may be prompted for your password.\n`);
|
|
40
|
-
const r = spawnSync("sudo", ["chown", "-h", `${process.getuid()}:${process.getgid()}`, path], { stdio: "inherit" });
|
|
41
|
-
return r.status === 0;
|
|
19
|
+
function symlinkError(what, p) {
|
|
20
|
+
const e = new Error(`refusing to write ${what} through symlink ${p}`);
|
|
21
|
+
e.code = "ELOOP";
|
|
22
|
+
e.path = p;
|
|
23
|
+
return e;
|
|
42
24
|
}
|
|
43
25
|
|
|
44
26
|
async function atomicReplace(path, text) {
|
|
@@ -58,24 +40,18 @@ async function atomicReplace(path, text) {
|
|
|
58
40
|
}
|
|
59
41
|
}
|
|
60
42
|
|
|
61
|
-
|
|
43
|
+
// Permission errors propagate to the caller, which prints a chown hint. pmsec
|
|
44
|
+
// never escalates privileges itself: an unwritable config under HOME is an
|
|
45
|
+
// operator decision (or a symptom worth seeing), not something to sudo past.
|
|
46
|
+
export async function writeAtomic(path, text, { backup = true } = {}) {
|
|
62
47
|
await mkdir(dirname(path), { recursive: true });
|
|
63
|
-
if (existsSync(path) && await isSymlink(path))
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
catch (e) {
|
|
71
|
-
const target = existsSync(path + ".bak") ? path + ".bak" : path;
|
|
72
|
-
if (!isPermErr(e) || !(await reclaim(target, home, err))) throw e;
|
|
73
|
-
await atomicReplace(path + ".bak", await readFile(path, "utf8"));
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
try { await atomicReplace(path, text); }
|
|
77
|
-
catch (e) {
|
|
78
|
-
if (!isPermErr(e) || !(await reclaim(path, home, err))) throw e;
|
|
79
|
-
await atomicReplace(path, text);
|
|
48
|
+
if (existsSync(path) && await isSymlink(path)) throw symlinkError("config", path);
|
|
49
|
+
const bak = path + ".bak";
|
|
50
|
+
if (backup && existsSync(path) && !existsSync(bak)) {
|
|
51
|
+
// A dangling symlink fails existsSync; rename() would replace the link
|
|
52
|
+
// itself, but refuse anyway so a planted link is visible, not silently clobbered.
|
|
53
|
+
if (await isSymlink(bak)) throw symlinkError("backup", bak);
|
|
54
|
+
await atomicReplace(bak, await readFile(path, "utf8"));
|
|
80
55
|
}
|
|
56
|
+
await atomicReplace(path, text);
|
|
81
57
|
}
|
package/src/util/lines.mjs
CHANGED
|
@@ -12,14 +12,17 @@ function headerName(line) {
|
|
|
12
12
|
return m ? m[1].split(".").map(p => unquote(p.trim())).join(".") : null;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
// YAML nests by indentation, so only unindented `key:` lines are top-level.
|
|
16
|
+
function indentRe(sep) { return sep === ":" ? "^" : String.raw`^\s*`; }
|
|
17
|
+
|
|
15
18
|
function entryKey(line, sep) {
|
|
16
|
-
const m = line.match(new RegExp(String.raw
|
|
19
|
+
const m = line.match(new RegExp(String.raw`${indentRe(sep)}${KEY}\s*${sep}`));
|
|
17
20
|
return m ? unquote(m[1]) : null;
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
// Value after the separator with any trailing comment removed.
|
|
21
24
|
function entryValue(line, sep) {
|
|
22
|
-
const m = line.match(new RegExp(String.raw
|
|
25
|
+
const m = line.match(new RegExp(String.raw`${indentRe(sep)}${KEY}\s*${sep}\s*(.*?)\s*$`));
|
|
23
26
|
if (!m) return null;
|
|
24
27
|
const v = m[2];
|
|
25
28
|
const close = { '"': '"', "'": "'", "{": "}" }[v[0]];
|
|
@@ -30,6 +33,20 @@ function entryValue(line, sep) {
|
|
|
30
33
|
return v.replace(/\s+#.*$/, "");
|
|
31
34
|
}
|
|
32
35
|
|
|
36
|
+
// Copy of `lines` with the body of TOML multi-line strings blanked, so text
|
|
37
|
+
// inside them is never taken for a header or key. Indices stay aligned.
|
|
38
|
+
function scanView(lines) {
|
|
39
|
+
let open = null;
|
|
40
|
+
return lines.map(line => {
|
|
41
|
+
const inside = open !== null;
|
|
42
|
+
for (const m of line.matchAll(/"""|'''/g)) {
|
|
43
|
+
if (open === null) open = m[0];
|
|
44
|
+
else if (open === m[0]) open = null;
|
|
45
|
+
}
|
|
46
|
+
return inside ? "" : line;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
33
50
|
function rangeForSection(lines, section) {
|
|
34
51
|
if (!section) {
|
|
35
52
|
for (let i = 0; i < lines.length; i++) if (SECTION.test(lines[i])) return [0, i];
|
|
@@ -54,7 +71,17 @@ function indexOfKey(lines, range, key, sep) {
|
|
|
54
71
|
|
|
55
72
|
// TOML forbids `a.b = x` under [s] when [s.a] exists or `a` is an inline table,
|
|
56
73
|
// so target the subtable, or report the inline-table line to edit in place.
|
|
74
|
+
// A table defined at the root through dotted keys (`s.k = v`) or an inline
|
|
75
|
+
// table (`s = { ... }`) cannot also get a [s] header, so keep editing it there.
|
|
57
76
|
function resolve(lines, key, sep, section) {
|
|
77
|
+
if (section && sep === "=" && rangeForSection(lines, section) === null) {
|
|
78
|
+
const [, end] = rangeForSection(lines, null);
|
|
79
|
+
for (let i = 0; i < end; i++) {
|
|
80
|
+
const k = entryKey(lines[i], sep) || "";
|
|
81
|
+
if (k.startsWith(`${section}.`)) return { key: `${section}.${key}`, section: null };
|
|
82
|
+
if (k === section && (entryValue(lines[i], sep) || "").startsWith("{")) return { key, section: null, inline: i, head: section };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
58
85
|
if (section && sep === "=" && key.includes(".")) {
|
|
59
86
|
const dot = key.indexOf(".");
|
|
60
87
|
const head = key.slice(0, dot), rest = key.slice(dot + 1);
|
|
@@ -87,7 +114,7 @@ function indexOfFirstSection(lines) {
|
|
|
87
114
|
|
|
88
115
|
export function readKey(text, key, opts = {}) {
|
|
89
116
|
const sep = opts.sep || "=";
|
|
90
|
-
const lines = text.split(/\r?\n/);
|
|
117
|
+
const lines = scanView(text.split(/\r?\n/));
|
|
91
118
|
const r = resolve(lines, key, sep, opts.section || null);
|
|
92
119
|
if (r.inline !== undefined) {
|
|
93
120
|
const pair = inlinePairs(lines[r.inline]).find(p => entryKey(p, sep) === r.key);
|
|
@@ -103,7 +130,8 @@ export function setKey(text, key, valueLine, opts = {}) {
|
|
|
103
130
|
const sep = opts.sep || "=";
|
|
104
131
|
const trailing = text.endsWith("\n") || text === "";
|
|
105
132
|
const lines = text === "" ? [] : text.replace(/\r?\n$/, "").split(/\r?\n/);
|
|
106
|
-
const
|
|
133
|
+
const scan = scanView(lines);
|
|
134
|
+
const r = resolve(scan, key, sep, opts.section || null);
|
|
107
135
|
const section = r.section;
|
|
108
136
|
if (r.key !== key) valueLine = r.key + valueLine.slice(key.length);
|
|
109
137
|
if (r.inline !== undefined) {
|
|
@@ -114,19 +142,21 @@ export function setKey(text, key, valueLine, opts = {}) {
|
|
|
114
142
|
return lines.join("\n") + "\n";
|
|
115
143
|
}
|
|
116
144
|
key = r.key;
|
|
117
|
-
let range = rangeForSection(
|
|
145
|
+
let range = rangeForSection(scan, section);
|
|
118
146
|
if (range === null) {
|
|
119
147
|
if (lines.length && lines[lines.length - 1] !== "") lines.push("");
|
|
120
148
|
lines.push(`[${section}]`, valueLine);
|
|
121
149
|
} else {
|
|
122
|
-
const idx = indexOfKey(
|
|
150
|
+
const idx = indexOfKey(scan, range, key, sep);
|
|
123
151
|
if (idx >= 0) {
|
|
124
152
|
lines[idx] = valueLine;
|
|
125
153
|
} else if (section) {
|
|
126
154
|
lines.splice(range[0], 0, valueLine);
|
|
127
155
|
} else {
|
|
128
|
-
const firstSec = indexOfFirstSection(
|
|
156
|
+
const firstSec = indexOfFirstSection(scan);
|
|
157
|
+
// Reuse the blank line that already separates the root keys from the first table.
|
|
129
158
|
if (firstSec < 0) lines.push(valueLine);
|
|
159
|
+
else if (firstSec > 0 && lines[firstSec - 1] === "") lines.splice(firstSec - 1, 0, valueLine);
|
|
130
160
|
else lines.splice(firstSec, 0, valueLine, "");
|
|
131
161
|
}
|
|
132
162
|
}
|
|
@@ -137,7 +167,8 @@ export function removeKey(text, key, opts = {}) {
|
|
|
137
167
|
const sep = opts.sep || "=";
|
|
138
168
|
const trailing = text.endsWith("\n");
|
|
139
169
|
const lines = text === "" ? [] : text.replace(/\r?\n$/, "").split(/\r?\n/);
|
|
140
|
-
const
|
|
170
|
+
const scan = scanView(lines);
|
|
171
|
+
const r = resolve(scan, key, sep, opts.section || null);
|
|
141
172
|
if (r.inline !== undefined) {
|
|
142
173
|
const pairs = inlinePairs(lines[r.inline]);
|
|
143
174
|
const kept = pairs.filter(p => entryKey(p, sep) !== r.key);
|
|
@@ -145,9 +176,9 @@ export function removeKey(text, key, opts = {}) {
|
|
|
145
176
|
lines[r.inline] = inlineLine(lines[r.inline], r.head, kept);
|
|
146
177
|
return { text: lines.join("\n") + (trailing ? "\n" : ""), removed: true };
|
|
147
178
|
}
|
|
148
|
-
const range = rangeForSection(
|
|
179
|
+
const range = rangeForSection(scan, r.section);
|
|
149
180
|
if (range === null) return { text, removed: false };
|
|
150
|
-
const idx = indexOfKey(
|
|
181
|
+
const idx = indexOfKey(scan, range, r.key, sep);
|
|
151
182
|
if (idx < 0) return { text, removed: false };
|
|
152
183
|
lines.splice(idx, 1);
|
|
153
184
|
if (idx < lines.length && lines[idx] === "") lines.splice(idx, 1);
|