@push.rocks/smartdaemon 2.11.0 → 2.13.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.
@@ -0,0 +1,272 @@
1
+ import * as plugins from './smartdaemon.plugins.js';
2
+ import { isCleanText } from './systemd.definition.js';
3
+ import { captureUserEnvironment, inspectRuntimeDirectory, isSafeDirectory } from './systemd.userenvironment.js';
4
+
5
+ export interface ISystemdTransientScopeOptions {
6
+ /** The calling user's manager; the only manager a transient scope is started in. */
7
+ scope: 'user';
8
+ /** Absolute, normalized program path, executed directly: no shell, no PATH search. */
9
+ executable: string;
10
+ /** Literal arguments, at most 128 of at most 32768 characters each, without NUL. */
11
+ args: readonly string[];
12
+ /** Absolute, normalized directory the program starts in. */
13
+ workingDirectory: string;
14
+ /**
15
+ * The program's complete environment. Nothing of the calling process is inherited: the program
16
+ * receives exactly these variables, the manager's `XDG_RUNTIME_DIR` and systemd's
17
+ * `INVOCATION_ID`. Names match `[A-Za-z_][A-Za-z0-9_]*` (at most 128 characters); values are
18
+ * strings of at most 32768 characters without NUL; at most 256 variables. `XDG_RUNTIME_DIR`,
19
+ * `INVOCATION_ID` and names starting with `SYSTEMD_` or `DBUS_`, which configure systemd-run
20
+ * itself, are refused.
21
+ */
22
+ environment: Readonly<Record<string, string>>;
23
+ /** Literal unit description: clean text of 1 to 1024 characters, trimmed, without `\`. */
24
+ description: string;
25
+ /** A `.scope` unit name; defaults to a unique `smartdaemon-<32 hex digits>.scope`. */
26
+ unitName?: string;
27
+ /** User-manager socket directory. Defaults to a non-empty XDG_RUNTIME_DIR. */
28
+ runtimeDirectory?: string;
29
+ /** Absolute executable path supplied by trusted owning code. Defaults to `/usr/bin/systemd-run`. */
30
+ systemdRunPath?: string;
31
+ /** Absolute executable path supplied by trusted owning code. Defaults to `/usr/bin/systemctl`. */
32
+ systemctlPath?: string;
33
+ /** Bounds the manager probe and the program's direct process, each. Defaults to 120 seconds. */
34
+ commandTimeoutMs?: number;
35
+ }
36
+
37
+ export interface ISystemdTransientScopeResult {
38
+ /** The scope unit. It stays active while any process the program started runs in it. */
39
+ readonly unitName: string;
40
+ /** Exit status of the program's direct process; null when a signal ended it. */
41
+ readonly exitCode: number | null;
42
+ readonly signal: NodeJS.Signals | null;
43
+ /** UTF-8 decoded output of the program and every process that shared its stdout. */
44
+ readonly stdout: string;
45
+ /** As `stdout`, without the line systemd-run prints on starting the scope. */
46
+ readonly stderr: string;
47
+ }
48
+
49
+ export type TSystemdTransientScopeErrorCode =
50
+ /** The options are malformed, or the runtime directory is not a private directory of the caller. */
51
+ | 'invalid_options'
52
+ /** The process is not running on Linux. */
53
+ | 'unsupported_platform'
54
+ /** No runtime directory is set or it does not exist, or the manager does not answer systemctl. */
55
+ | 'user_manager_unavailable'
56
+ /** systemd-run exited without reporting the scope running; the program has not run. */
57
+ | 'scope_start_failed'
58
+ /** The manager probe or the program did not finish, and close its output, in time. */
59
+ | 'timed_out'
60
+ /** The program's stdout or stderr exceeded 1 MiB. */
61
+ | 'output_limit';
62
+
63
+ export class SystemdTransientScopeError extends Error {
64
+ constructor(
65
+ public readonly code: TSystemdTransientScopeErrorCode,
66
+ /** The scope the call used; null when the options were refused. */
67
+ public readonly unitName: string | null,
68
+ /** The probe's or systemd-run's stderr as far as it was read; empty when none ran. */
69
+ public readonly stderr: string,
70
+ options?: ErrorOptions,
71
+ ) {
72
+ super(`Systemd transient scope failed: ${code}${unitName === null ? '' : ` (${unitName})`}.`, options);
73
+ this.name = 'SystemdTransientScopeError';
74
+ }
75
+ }
76
+
77
+ const outputLimit = 1048576;
78
+ const reservedName = (name: string) =>
79
+ name === 'XDG_RUNTIME_DIR' || name === 'INVOCATION_ID' || name.startsWith('SYSTEMD_') || name.startsWith('DBUS_');
80
+
81
+ interface IValidatedOptions {
82
+ readonly executable: string;
83
+ readonly args: readonly string[];
84
+ readonly workingDirectory: string;
85
+ readonly environment: ReadonlyMap<string, string>;
86
+ readonly description: string;
87
+ readonly unitName: string;
88
+ readonly runtimeDirectory?: string;
89
+ readonly systemdRunPath: string;
90
+ readonly systemctlPath: string;
91
+ readonly timeoutMs: number;
92
+ }
93
+
94
+ /** Reads every option once through its own data descriptor, so no accessor or proxy trap runs. */
95
+ const validate = (options: unknown): IValidatedOptions | undefined => {
96
+ const plain = (value: unknown, prototype: object): value is object => value !== null &&
97
+ typeof value === 'object' && !plugins.util.types.isProxy(value) && Object.getPrototypeOf(value) === prototype;
98
+ if (!plain(options, Object.prototype)) return undefined;
99
+ const required = ['scope', 'executable', 'args', 'workingDirectory', 'environment', 'description'];
100
+ const optional = ['unitName', 'runtimeDirectory', 'systemdRunPath', 'systemctlPath', 'commandTimeoutMs'];
101
+ const values = new Map<string, unknown>();
102
+ for (const key of Reflect.ownKeys(options)) {
103
+ const descriptor = Object.getOwnPropertyDescriptor(options, key);
104
+ if (typeof key !== 'string' || ![...required, ...optional].includes(key) || !descriptor?.enumerable ||
105
+ !('value' in descriptor)) return undefined;
106
+ if (descriptor.value !== undefined) values.set(key, descriptor.value);
107
+ }
108
+ if (!required.every((key) => values.has(key)) || values.get('scope') !== 'user') return undefined;
109
+ const path = (value: unknown): value is string => isSafeDirectory(value);
110
+ const bounded = (value: unknown): value is string =>
111
+ typeof value === 'string' && value.length <= 32768 && !value.includes('\0');
112
+
113
+ const executable = values.get('executable');
114
+ const workingDirectory = values.get('workingDirectory');
115
+ const description = values.get('description');
116
+ if (!path(executable) || !(workingDirectory === '/' || path(workingDirectory)) ||
117
+ typeof description !== 'string' || description.length < 1 || description.length > 1024 ||
118
+ description.trim() !== description || description.includes('\\') || !isCleanText(description)) return undefined;
119
+
120
+ const argsValue = values.get('args');
121
+ if (!plain(argsValue, Array.prototype) || !Array.isArray(argsValue) || argsValue.length > 128 ||
122
+ Reflect.ownKeys(argsValue).length !== argsValue.length + 1) return undefined;
123
+ const args: string[] = [];
124
+ for (let index = 0; index < argsValue.length; index++) {
125
+ const descriptor = Object.getOwnPropertyDescriptor(argsValue, String(index));
126
+ if (!descriptor || !('value' in descriptor) || !bounded(descriptor.value)) return undefined;
127
+ args.push(descriptor.value);
128
+ }
129
+
130
+ const variables = values.get('environment');
131
+ if (!plain(variables, Object.prototype)) return undefined;
132
+ const names = Reflect.ownKeys(variables);
133
+ if (names.length > 256) return undefined;
134
+ const environment = new Map<string, string>();
135
+ for (const name of names) {
136
+ const descriptor = Object.getOwnPropertyDescriptor(variables, name);
137
+ if (typeof name !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(name) || reservedName(name) ||
138
+ !descriptor?.enumerable || !('value' in descriptor) || !bounded(descriptor.value)) return undefined;
139
+ environment.set(name, descriptor.value);
140
+ }
141
+
142
+ const unitName = values.get('unitName') ?? `smartdaemon-${plugins.crypto.randomUUID().replaceAll('-', '')}.scope`;
143
+ const runtimeDirectory = values.get('runtimeDirectory');
144
+ const systemdRunPath = values.get('systemdRunPath') ?? '/usr/bin/systemd-run';
145
+ const systemctlPath = values.get('systemctlPath') ?? '/usr/bin/systemctl';
146
+ const timeoutMs = values.get('commandTimeoutMs') ?? 120000;
147
+ if (typeof unitName !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,230}\.scope$/.test(unitName) ||
148
+ (runtimeDirectory !== undefined && !path(runtimeDirectory)) || !path(systemdRunPath) || !path(systemctlPath) ||
149
+ !Number.isSafeInteger(timeoutMs) || (timeoutMs as number) < 1 || (timeoutMs as number) > 600000) return undefined;
150
+ return {
151
+ executable, args, workingDirectory, environment, description, unitName, runtimeDirectory, systemdRunPath,
152
+ systemctlPath, timeoutMs: timeoutMs as number,
153
+ };
154
+ };
155
+
156
+ type TProcessOutcome =
157
+ | { readonly kind: 'exited'; readonly code: number | null; readonly signal: NodeJS.Signals | null;
158
+ readonly stdout: string; readonly stderr: string }
159
+ | { readonly kind: 'spawn_failed'; readonly error: Error }
160
+ | { readonly kind: 'timed_out' | 'output_limit'; readonly stderr: string };
161
+
162
+ /**
163
+ * Runs a program with stdin on /dev/null and bounded output. It settles once the process has exited
164
+ * and its stdout and stderr have closed; on the deadline or an output overflow it kills the process
165
+ * if it still runs and releases both pipes, so a descendant holding them keeps no handle open here.
166
+ */
167
+ const runProcess = (file: string, args: readonly string[], env: Record<string, string>, timeoutMs: number) =>
168
+ new Promise<TProcessOutcome>((resolve) => {
169
+ const child = plugins.childProcess.spawn(file, args, {
170
+ cwd: '/', env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true,
171
+ });
172
+ const output = { stdout: [] as Buffer[], stderr: [] as Buffer[] };
173
+ const sizes = { stdout: 0, stderr: 0 };
174
+ let settled = false;
175
+ const text = (chunks: Buffer[]) => Buffer.concat(chunks).toString('utf8');
176
+ const abandon = (kind: 'timed_out' | 'output_limit') => {
177
+ if (settled) return;
178
+ settled = true;
179
+ clearTimeout(timer);
180
+ if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
181
+ child.stdout.destroy();
182
+ child.stderr.destroy();
183
+ resolve({ kind, stderr: text(output.stderr) });
184
+ };
185
+ const timer = setTimeout(() => abandon('timed_out'), timeoutMs);
186
+ for (const stream of ['stdout', 'stderr'] as const) {
187
+ child[stream].on('data', (chunk: Buffer) => {
188
+ sizes[stream] += chunk.length;
189
+ if (sizes[stream] > outputLimit) abandon('output_limit');
190
+ else output[stream].push(chunk);
191
+ });
192
+ }
193
+ child.on('error', (error) => {
194
+ if (settled || child.pid !== undefined) return;
195
+ settled = true;
196
+ clearTimeout(timer);
197
+ resolve({ kind: 'spawn_failed', error });
198
+ });
199
+ child.on('close', (code, signal) => {
200
+ if (settled) return;
201
+ settled = true;
202
+ clearTimeout(timer);
203
+ resolve({ kind: 'exited', code, signal, stdout: text(output.stdout), stderr: text(output.stderr) });
204
+ });
205
+ });
206
+
207
+ /**
208
+ * The first stderr line systemd-run prints once the scope runs, before it executes the program:
209
+ * systemd 255 and later print `Running as unit: NAME` with an optional `; invocation ID: ID`,
210
+ * systemd 254 prints `Running scope as unit: NAME`. Returns the rest of stderr, or undefined.
211
+ */
212
+ const programStderr = (stderr: string, unitName: string): string | undefined => {
213
+ const newline = stderr.indexOf('\n');
214
+ if (newline < 0) return undefined;
215
+ const line = stderr.slice(0, newline);
216
+ const started = line === `Running as unit: ${unitName}` || line === `Running scope as unit: ${unitName}` ||
217
+ (line.startsWith(`Running as unit: ${unitName}; invocation ID: `) &&
218
+ /^[0-9a-f]{32}$/.test(line.slice(`Running as unit: ${unitName}; invocation ID: `.length)));
219
+ return started ? stderr.slice(newline + 1) : undefined;
220
+ };
221
+
222
+ /**
223
+ * Starts a program in a new transient scope unit of the calling user's systemd manager, so it and
224
+ * every process it starts run outside the caller's own unit and survive that unit's stop, and
225
+ * waits for the program's direct process. A program that daemonizes and exits returns its result
226
+ * while its daemon keeps the scope active; the scope ends when its last process exits. The daemon
227
+ * must not keep the program's stdout or stderr open, or the call runs into its timeout.
228
+ *
229
+ * On `timed_out` and `output_limit` the direct process is killed; processes it started stay in
230
+ * the scope. A program that exits with a failure status is a result, not an error.
231
+ */
232
+ export const runInTransientScope = async (
233
+ options: ISystemdTransientScopeOptions,
234
+ ): Promise<ISystemdTransientScopeResult> => {
235
+ const validated = validate(options);
236
+ if (validated === undefined) throw new SystemdTransientScopeError('invalid_options', null, '');
237
+ const { unitName } = validated;
238
+ const fail = (code: TSystemdTransientScopeErrorCode, stderr = '', cause?: unknown): never => {
239
+ throw new SystemdTransientScopeError(code, unitName, stderr, cause === undefined ? undefined : { cause });
240
+ };
241
+ if (process.platform !== 'linux') fail('unsupported_platform');
242
+ const runtimeDirectory = validated.runtimeDirectory ?? captureUserEnvironment(undefined).runtimeDirectory;
243
+ if (runtimeDirectory === undefined) fail('user_manager_unavailable');
244
+ if (!isSafeDirectory(runtimeDirectory)) fail('invalid_options');
245
+ const admission = await inspectRuntimeDirectory(runtimeDirectory!);
246
+ if (admission === 'absent') fail('user_manager_unavailable');
247
+ if (admission !== 'private') fail('invalid_options');
248
+
249
+ const probe = await runProcess(validated.systemctlPath,
250
+ ['--user', '--no-ask-password', '--no-pager', 'show', '--property=Version'],
251
+ { PATH: '/usr/bin:/bin', LANG: 'C', LC_ALL: 'C', XDG_RUNTIME_DIR: runtimeDirectory! }, validated.timeoutMs);
252
+ if (probe.kind === 'timed_out') fail('timed_out', probe.stderr);
253
+ if (probe.kind !== 'exited' || probe.code !== 0 || !probe.stdout.startsWith('Version=')) {
254
+ fail('user_manager_unavailable', probe.kind === 'spawn_failed' ? '' : probe.stderr,
255
+ probe.kind === 'spawn_failed' ? probe.error : undefined);
256
+ }
257
+
258
+ // systemd-run registers its own process as the scope, then executes the program in place with
259
+ // its environment, so that environment is the program's own plus INVOCATION_ID.
260
+ const outcome = await runProcess(validated.systemdRunPath, [
261
+ '--user', '--scope', '--collect', '--no-ask-password', '--expand-environment=no',
262
+ `--unit=${unitName}`, `--description=${validated.description}`,
263
+ `--working-directory=${validated.workingDirectory}`, '--', validated.executable, ...validated.args,
264
+ ], { ...Object.fromEntries(validated.environment), XDG_RUNTIME_DIR: runtimeDirectory! }, validated.timeoutMs);
265
+ if (outcome.kind === 'spawn_failed') return fail('scope_start_failed', '', outcome.error);
266
+ if (outcome.kind !== 'exited') return fail(outcome.kind, outcome.stderr);
267
+ const stderr = programStderr(outcome.stderr, unitName);
268
+ if (stderr === undefined) return fail('scope_start_failed', outcome.stderr);
269
+ return Object.freeze({
270
+ unitName, exitCode: outcome.code, signal: outcome.signal, stdout: outcome.stdout, stderr,
271
+ });
272
+ };
@@ -3,6 +3,7 @@ import {
3
3
  type ISystemdUserEnvironment,
4
4
  type TSystemdUserManagerEnvironment,
5
5
  captureUserEnvironment,
6
+ inspectRuntimeDirectory,
6
7
  isSafeDirectory,
7
8
  isUserManagerEnvironment,
8
9
  } from './systemd.userenvironment.js';
@@ -371,17 +372,7 @@ export class SystemdUnit {
371
372
  const environment = this.userEnvironment;
372
373
  if (environment === undefined) return undefined;
373
374
  if (!isUserManagerEnvironment(environment)) throw new SystemdUnitError('invalid_options');
374
- const runtimeDirectory = environment.runtimeDirectory;
375
- try {
376
- const uid = process.getuid?.();
377
- if (process.platform !== 'linux' || uid === undefined || process.geteuid?.() !== uid ||
378
- await plugins.fs.realpath(runtimeDirectory) !== runtimeDirectory) {
379
- throw new SystemdUnitError('invalid_options');
380
- }
381
- const info = await plugins.fs.lstat(runtimeDirectory);
382
- if (!info.isDirectory() || info.isSymbolicLink() || info.uid !== uid ||
383
- (info.mode & 0o077) !== 0) throw new SystemdUnitError('invalid_options');
384
- } catch {
375
+ if (await inspectRuntimeDirectory(environment.runtimeDirectory) !== 'private') {
385
376
  throw new SystemdUnitError('invalid_options');
386
377
  }
387
378
  return environment;
package/ts/index.ts CHANGED
@@ -3,3 +3,4 @@ export * from './smartdaemon.classes.service.js';
3
3
  export * from './classes.systemdunit.js';
4
4
  export * from './classes.systemdservicedefinition.js';
5
5
  export * from './classes.systemdunitfile.js';
6
+ export * from './classes.systemdtransientscope.js';
@@ -34,6 +34,41 @@ export interface ISystemdServiceInstallTargets {
34
34
  requiredBy: readonly string[];
35
35
  }
36
36
 
37
+ /**
38
+ * One credential a system service reads from `$CREDENTIALS_DIRECTORY/<name>` (`LoadCredential=` or
39
+ * `LoadCredentialEncrypted=`). The value never appears in the unit, its environment or
40
+ * `systemctl show`; it is readable by the service's user only.
41
+ */
42
+ export interface ISystemdServiceCredential {
43
+ /** The credential's name inside the service: printable ASCII from `[A-Za-z0-9_.@-]`, 1–255 characters, not starting with `.`. */
44
+ name: string;
45
+ /**
46
+ * Where systemd reads it: an absolute normalized path (a file, a directory of files or an
47
+ * `AF_UNIX` socket), or a credential name that systemd searches among its own credentials and
48
+ * in the credstore directories (`/etc/credstore/`, `/run/credstore/`, `/usr/lib/credstore/`, and
49
+ * for encrypted credentials also their `.encrypted` counterparts). Omitted, systemd searches
50
+ * under `name` itself.
51
+ */
52
+ source?: string;
53
+ }
54
+
55
+ /** Linux capabilities systemd 255 knows by name, for `capabilityBoundingSet` and `ambientCapabilities`. */
56
+ export const systemdCapabilities = [
57
+ 'CAP_AUDIT_CONTROL', 'CAP_AUDIT_READ', 'CAP_AUDIT_WRITE', 'CAP_BLOCK_SUSPEND', 'CAP_BPF',
58
+ 'CAP_CHECKPOINT_RESTORE', 'CAP_CHOWN', 'CAP_DAC_OVERRIDE', 'CAP_DAC_READ_SEARCH', 'CAP_FOWNER',
59
+ 'CAP_FSETID', 'CAP_IPC_LOCK', 'CAP_IPC_OWNER', 'CAP_KILL', 'CAP_LEASE', 'CAP_LINUX_IMMUTABLE',
60
+ 'CAP_MAC_ADMIN', 'CAP_MAC_OVERRIDE', 'CAP_MKNOD', 'CAP_NET_ADMIN', 'CAP_NET_BIND_SERVICE',
61
+ 'CAP_NET_BROADCAST', 'CAP_NET_RAW', 'CAP_PERFMON', 'CAP_SETFCAP', 'CAP_SETGID', 'CAP_SETPCAP',
62
+ 'CAP_SETUID', 'CAP_SYSLOG', 'CAP_SYS_ADMIN', 'CAP_SYS_BOOT', 'CAP_SYS_CHROOT', 'CAP_SYS_MODULE',
63
+ 'CAP_SYS_NICE', 'CAP_SYS_PACCT', 'CAP_SYS_PTRACE', 'CAP_SYS_RAWIO', 'CAP_SYS_RESOURCE',
64
+ 'CAP_SYS_TIME', 'CAP_SYS_TTY_CONFIG', 'CAP_WAKE_ALARM',
65
+ ] as const;
66
+ export type TSystemdCapability = typeof systemdCapabilities[number];
67
+
68
+ /** Socket address families a definition may allow with `restrictAddressFamilies`. */
69
+ export const systemdAddressFamilies = ['AF_INET', 'AF_INET6', 'AF_NETLINK', 'AF_PACKET', 'AF_UNIX', 'AF_VSOCK'] as const;
70
+ export type TSystemdAddressFamily = typeof systemdAddressFamilies[number];
71
+
37
72
  /**
38
73
  * Internal bounds of a definition's `environment`: variables, and UTF-8 bytes of `NAME=value` in
39
74
  * total. SystemdUnit sizes its systemctl output limit from them, so every accepted definition reads back.
@@ -59,10 +94,13 @@ export const renderServiceRelations = (options: {
59
94
  startup?: TSystemdServiceStartup;
60
95
  dependencies?: ISystemdServiceDependencies;
61
96
  install?: ISystemdServiceInstallTargets;
62
- }, unitName: string, restart: string, scope: 'system' | 'user', fail: () => never): {
97
+ }, unitName: string, restart: string, scope: 'system' | 'user',
98
+ fail: (option: 'startup' | 'dependencies' | 'install') => never): {
63
99
  unit: string[]; service: string[]; install: string[];
64
100
  } => {
65
- const requireValue = (value: unknown): void => { if (!value) fail(); };
101
+ // Every check names the option it belongs to; `option` is set before each option is read.
102
+ let option: 'startup' | 'dependencies' | 'install' = 'startup';
103
+ const requireValue = (value: unknown): void => { if (!value) fail(option); };
66
104
  const plain = (value: unknown): void => requireValue(value !== null && typeof value === 'object'
67
105
  && !plugins.util.types.isProxy(value) && Object.getPrototypeOf(value) === Object.prototype);
68
106
  const record = (value: unknown, keys: readonly string[]): void => {
@@ -110,6 +148,7 @@ export const renderServiceRelations = (options: {
110
148
  }
111
149
  }
112
150
  if (options.dependencies !== undefined) {
151
+ option = 'dependencies';
113
152
  const value = options.dependencies;
114
153
  record(value, ['defaultDependencies', 'after', 'before', 'requires', 'wants', 'conflicts']);
115
154
  requireValue(typeof value.defaultDependencies === 'boolean');
@@ -120,6 +159,7 @@ export const renderServiceRelations = (options: {
120
159
  ...line('Before', before), ...line('Requires', requires), ...line('Wants', wants), ...line('Conflicts', conflicts)];
121
160
  }
122
161
  if (options.install !== undefined) {
162
+ option = 'install';
123
163
  const value = options.install;
124
164
  record(value, ['wantedBy', 'requiredBy']);
125
165
  const wanted = units(value.wantedBy), required = units(value.requiredBy);
@@ -61,3 +61,23 @@ export const isUserManagerEnvironment = (
61
61
  isSafeDirectory(environment.runtimeDirectory) && isSafeDirectory(environment.homeDirectory) &&
62
62
  (environment.configDirectory === undefined || isSafeDirectory(environment.configDirectory)) &&
63
63
  (environment.dataDirectory === undefined || isSafeDirectory(environment.dataDirectory));
64
+
65
+ /**
66
+ * Admits a user-manager runtime directory: on Linux, for a process whose uid is its effective uid,
67
+ * the path must be its own real path and a directory owned by that uid without group or other
68
+ * permissions. `absent` is a directory that does not exist; every other refusal is `unsafe`.
69
+ */
70
+ export const inspectRuntimeDirectory = async (
71
+ runtimeDirectory: string,
72
+ ): Promise<'private' | 'absent' | 'unsafe'> => {
73
+ const uid = process.getuid?.();
74
+ if (process.platform !== 'linux' || uid === undefined || process.geteuid?.() !== uid) return 'unsafe';
75
+ try {
76
+ if (await plugins.fs.realpath(runtimeDirectory) !== runtimeDirectory) return 'unsafe';
77
+ const info = await plugins.fs.lstat(runtimeDirectory);
78
+ return info.isDirectory() && !info.isSymbolicLink() && info.uid === uid && (info.mode & 0o077) === 0
79
+ ? 'private' : 'unsafe';
80
+ } catch (error) {
81
+ return (error as NodeJS.ErrnoException).code === 'ENOENT' ? 'absent' : 'unsafe';
82
+ }
83
+ };