@push.rocks/smartdaemon 2.2.0 → 2.4.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/.smartconfig.json +27 -8
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.systemdservicedefinition.d.ts +22 -0
- package/dist_ts/classes.systemdservicedefinition.js +93 -0
- package/dist_ts/classes.systemdunit.d.ts +49 -0
- package/dist_ts/classes.systemdunit.js +125 -0
- package/dist_ts/classes.systemdunitfile.d.ts +45 -0
- package/dist_ts/classes.systemdunitfile.js +242 -0
- package/dist_ts/index.d.ts +3 -0
- package/dist_ts/index.js +4 -1
- package/dist_ts/smartdaemon.plugins.d.ts +6 -1
- package/dist_ts/smartdaemon.plugins.js +6 -2
- package/package.json +4 -4
- package/readme.md +103 -1
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.systemdservicedefinition.ts +134 -0
- package/ts/classes.systemdunit.ts +173 -0
- package/ts/classes.systemdunitfile.ts +311 -0
- package/ts/index.ts +3 -0
- package/ts/smartdaemon.plugins.ts +6 -1
- package/readme.hints.md +0 -1
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import * as plugins from './smartdaemon.plugins.js';
|
|
2
|
+
|
|
3
|
+
export interface ISystemdUnitOptions {
|
|
4
|
+
unitName: string;
|
|
5
|
+
/** Absolute executable path supplied by trusted owning code. */
|
|
6
|
+
systemctlPath?: string;
|
|
7
|
+
/** Bounds the systemctl client, not the server-side job. Defaults to 120 seconds. */
|
|
8
|
+
commandTimeoutMs?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ISystemdUnitState {
|
|
12
|
+
readonly unitName: string;
|
|
13
|
+
readonly loadState: string;
|
|
14
|
+
readonly activeState: string;
|
|
15
|
+
readonly subState: string;
|
|
16
|
+
readonly unitFileState: string;
|
|
17
|
+
readonly mainPid: number;
|
|
18
|
+
readonly controlPid: number;
|
|
19
|
+
readonly controlGroup: string;
|
|
20
|
+
readonly killMode: string;
|
|
21
|
+
/** Empty when no systemd job is pending; otherwise systemctl's job description. */
|
|
22
|
+
readonly job: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ISystemdUnitConfigurationState {
|
|
26
|
+
readonly unitName: string;
|
|
27
|
+
readonly fragmentPath: string;
|
|
28
|
+
/** Opaque systemctl path list. An empty value proves no effective drop-ins. */
|
|
29
|
+
readonly dropInPaths: string;
|
|
30
|
+
readonly needsReload: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class SystemdUnitError extends Error {
|
|
34
|
+
constructor(public readonly code: 'invalid_options' | 'command_failed' | 'invalid_state') {
|
|
35
|
+
super(`Systemd unit operation failed: ${code}.`);
|
|
36
|
+
this.name = 'SystemdUnitError';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const properties = [
|
|
41
|
+
'Id',
|
|
42
|
+
'LoadState',
|
|
43
|
+
'ActiveState',
|
|
44
|
+
'SubState',
|
|
45
|
+
'UnitFileState',
|
|
46
|
+
'MainPID',
|
|
47
|
+
'ControlPID',
|
|
48
|
+
'ControlGroup',
|
|
49
|
+
'KillMode',
|
|
50
|
+
'Job',
|
|
51
|
+
] as const;
|
|
52
|
+
|
|
53
|
+
/** Controls an existing system unit without creating files, changing enablement or escalating privileges. */
|
|
54
|
+
export class SystemdUnit {
|
|
55
|
+
private readonly unitName: string;
|
|
56
|
+
private readonly executable: string;
|
|
57
|
+
private readonly timeout: number;
|
|
58
|
+
|
|
59
|
+
constructor(optionsArg: ISystemdUnitOptions) {
|
|
60
|
+
this.unitName = optionsArg?.unitName;
|
|
61
|
+
this.executable = optionsArg?.systemctlPath ?? '/usr/bin/systemctl';
|
|
62
|
+
this.timeout = optionsArg?.commandTimeoutMs ?? 120000;
|
|
63
|
+
if (
|
|
64
|
+
typeof this.unitName !== 'string' ||
|
|
65
|
+
!/^[a-zA-Z0-9][a-zA-Z0-9_.:@-]{0,230}\.service$/.test(this.unitName) ||
|
|
66
|
+
typeof this.executable !== 'string' || !plugins.path.isAbsolute(this.executable) ||
|
|
67
|
+
this.executable.includes('\0') || !Number.isSafeInteger(this.timeout) ||
|
|
68
|
+
this.timeout < 1 || this.timeout > 600000
|
|
69
|
+
) throw new SystemdUnitError('invalid_options');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Snapshot only. Callers own application-specific admission and restart policy. */
|
|
73
|
+
public async inspect(): Promise<ISystemdUnitState> {
|
|
74
|
+
const stdout = await this.command([
|
|
75
|
+
'show',
|
|
76
|
+
this.unitName,
|
|
77
|
+
`--property=${properties.join(',')}`,
|
|
78
|
+
'--all',
|
|
79
|
+
]);
|
|
80
|
+
const fields = new Map<string, string>();
|
|
81
|
+
for (const line of stdout.trimEnd().split('\n')) {
|
|
82
|
+
const separator = line.indexOf('=');
|
|
83
|
+
const key = line.slice(0, separator);
|
|
84
|
+
if (separator < 1 || !(properties as readonly string[]).includes(key) || fields.has(key)) {
|
|
85
|
+
throw new SystemdUnitError('invalid_state');
|
|
86
|
+
}
|
|
87
|
+
fields.set(key, line.slice(separator + 1));
|
|
88
|
+
}
|
|
89
|
+
if (
|
|
90
|
+
fields.size !== properties.length || fields.get('Id') !== this.unitName ||
|
|
91
|
+
['LoadState', 'ActiveState', 'SubState'].some((key) => !fields.get(key)) ||
|
|
92
|
+
['MainPID', 'ControlPID'].some((key) =>
|
|
93
|
+
!/^(0|[1-9]\d*)$/.test(fields.get(key)!) || !Number.isSafeInteger(Number(fields.get(key)))
|
|
94
|
+
)
|
|
95
|
+
) throw new SystemdUnitError('invalid_state');
|
|
96
|
+
return Object.freeze({
|
|
97
|
+
unitName: this.unitName,
|
|
98
|
+
loadState: fields.get('LoadState')!,
|
|
99
|
+
activeState: fields.get('ActiveState')!,
|
|
100
|
+
subState: fields.get('SubState')!,
|
|
101
|
+
unitFileState: fields.get('UnitFileState')!,
|
|
102
|
+
mainPid: Number(fields.get('MainPID')),
|
|
103
|
+
controlPid: Number(fields.get('ControlPID')),
|
|
104
|
+
controlGroup: fields.get('ControlGroup')!,
|
|
105
|
+
killMode: fields.get('KillMode')!,
|
|
106
|
+
job: fields.get('Job')!,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Await the systemd job, then inspect. This does not enable or unmask the unit. */
|
|
111
|
+
public async start(): Promise<ISystemdUnitState> {
|
|
112
|
+
await this.command(['start', this.unitName]);
|
|
113
|
+
return this.inspect();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Await the systemd job, then inspect. The snapshot alone is not an application data-drain proof. */
|
|
117
|
+
public async stop(): Promise<ISystemdUnitState> {
|
|
118
|
+
await this.command(['stop', this.unitName]);
|
|
119
|
+
return this.inspect();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Reports the manager's selected definition without reading or changing its contents. */
|
|
123
|
+
public async inspectConfiguration(): Promise<ISystemdUnitConfigurationState> {
|
|
124
|
+
const keys = ['Id', 'FragmentPath', 'DropInPaths', 'NeedDaemonReload'];
|
|
125
|
+
const stdout = await this.command(['show', this.unitName, `--property=${keys.join(',')}`, '--all']);
|
|
126
|
+
const fields = new Map<string, string>();
|
|
127
|
+
for (const line of stdout.trimEnd().split('\n')) {
|
|
128
|
+
const separator = line.indexOf('='), key = line.slice(0, separator);
|
|
129
|
+
if (separator < 1 || !keys.includes(key) || fields.has(key)) {
|
|
130
|
+
throw new SystemdUnitError('invalid_state');
|
|
131
|
+
}
|
|
132
|
+
fields.set(key, line.slice(separator + 1));
|
|
133
|
+
}
|
|
134
|
+
if (fields.size !== keys.length || fields.get('Id') !== this.unitName ||
|
|
135
|
+
!['yes', 'no'].includes(fields.get('NeedDaemonReload')!)) {
|
|
136
|
+
throw new SystemdUnitError('invalid_state');
|
|
137
|
+
}
|
|
138
|
+
return Object.freeze({
|
|
139
|
+
unitName: this.unitName,
|
|
140
|
+
fragmentPath: fields.get('FragmentPath')!,
|
|
141
|
+
dropInPaths: fields.get('DropInPaths')!,
|
|
142
|
+
needsReload: fields.get('NeedDaemonReload') === 'yes',
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Reloads unit definitions globally; does not enable, unmask, start or stop a unit. */
|
|
147
|
+
public async reloadConfiguration(): Promise<ISystemdUnitConfigurationState> {
|
|
148
|
+
await this.command(['daemon-reload']);
|
|
149
|
+
return this.inspectConfiguration();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private command(argsArg: string[]): Promise<string> {
|
|
153
|
+
return new Promise((resolve, reject) => {
|
|
154
|
+
plugins.childProcess.execFile(
|
|
155
|
+
this.executable,
|
|
156
|
+
['--system', '--no-ask-password', '--no-pager', ...argsArg],
|
|
157
|
+
{
|
|
158
|
+
encoding: 'utf8',
|
|
159
|
+
timeout: this.timeout,
|
|
160
|
+
killSignal: 'SIGKILL',
|
|
161
|
+
maxBuffer: 16384,
|
|
162
|
+
windowsHide: true,
|
|
163
|
+
env: { PATH: '/usr/bin:/bin', LANG: 'C', LC_ALL: 'C' },
|
|
164
|
+
},
|
|
165
|
+
(error, stdout) => {
|
|
166
|
+
// execFile settles after the client closes. A timed-out client's remote job may continue.
|
|
167
|
+
if (error) reject(new SystemdUnitError('command_failed'));
|
|
168
|
+
else resolve(stdout);
|
|
169
|
+
},
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import * as plugins from './smartdaemon.plugins.js';
|
|
2
|
+
import { SystemdServiceDefinition } from './classes.systemdservicedefinition.js';
|
|
3
|
+
import {
|
|
4
|
+
type ISystemdUnitOptions,
|
|
5
|
+
type ISystemdUnitState,
|
|
6
|
+
SystemdUnit,
|
|
7
|
+
} from './classes.systemdunit.js';
|
|
8
|
+
|
|
9
|
+
export interface ISystemdUnitFileOptions extends ISystemdUnitOptions {
|
|
10
|
+
/** Local vendor definitions can be installed without removing an administrator's mask. */
|
|
11
|
+
unitDirectory?: string;
|
|
12
|
+
/** Trusted owning-code qualification only. Defaults to root. */
|
|
13
|
+
expectedUid?: number;
|
|
14
|
+
}
|
|
15
|
+
export interface ISystemdUnitFileRecord {
|
|
16
|
+
readonly path: string;
|
|
17
|
+
readonly sha256: string;
|
|
18
|
+
}
|
|
19
|
+
export class SystemdUnitFileError extends Error {
|
|
20
|
+
constructor(
|
|
21
|
+
public readonly code:
|
|
22
|
+
| 'invalid_options'
|
|
23
|
+
| 'unsafe_path'
|
|
24
|
+
| 'busy'
|
|
25
|
+
| 'changed'
|
|
26
|
+
| 'unsafe_unit'
|
|
27
|
+
| 'write_failed'
|
|
28
|
+
| 'reload_failed',
|
|
29
|
+
) {
|
|
30
|
+
super(`Systemd unit definition installation failed: ${code}.`);
|
|
31
|
+
this.name = 'SystemdUnitFileError';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const requireValue = (value: unknown, code: SystemdUnitFileError['code']): void => {
|
|
35
|
+
if (!value) throw new SystemdUnitFileError(code);
|
|
36
|
+
};
|
|
37
|
+
const digest = (bytes: Uint8Array) =>
|
|
38
|
+
plugins.crypto.createHash('sha256').update(bytes).digest('hex');
|
|
39
|
+
const quiet = (state: ISystemdUnitState) =>
|
|
40
|
+
state.mainPid === 0 && state.controlPid === 0 &&
|
|
41
|
+
state.controlGroup === '' && state.job === '' &&
|
|
42
|
+
(state.activeState === 'inactive' && state.subState === 'dead' ||
|
|
43
|
+
state.activeState === 'failed' && state.subState === 'failed');
|
|
44
|
+
const masked = (state: ISystemdUnitState) =>
|
|
45
|
+
state.loadState === 'masked' &&
|
|
46
|
+
['masked', 'masked-runtime'].includes(state.unitFileState);
|
|
47
|
+
const sameFile = (left: { dev: number; ino: number }, right: { dev: number; ino: number }) =>
|
|
48
|
+
left.dev === right.dev && left.ino === right.ino;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Protected code/config installation, inside the caller's cross-process installation ownership scope.
|
|
52
|
+
* Other privileged writers must be serialized by that owner. Never changes enablement or masks.
|
|
53
|
+
*/
|
|
54
|
+
export class SystemdUnitFile {
|
|
55
|
+
public readonly path: string;
|
|
56
|
+
private readonly directory: string;
|
|
57
|
+
private readonly uid: number;
|
|
58
|
+
private readonly unit: SystemdUnit;
|
|
59
|
+
private readonly name: string;
|
|
60
|
+
private busy = false;
|
|
61
|
+
|
|
62
|
+
constructor(options: ISystemdUnitFileOptions) {
|
|
63
|
+
this.unit = new SystemdUnit(options);
|
|
64
|
+
this.name = options.unitName;
|
|
65
|
+
this.directory = options.unitDirectory ?? '/usr/local/lib/systemd/system';
|
|
66
|
+
this.uid = options.expectedUid ?? 0;
|
|
67
|
+
requireValue(
|
|
68
|
+
typeof this.directory === 'string' && this.directory !== '/' &&
|
|
69
|
+
plugins.path.isAbsolute(this.directory) &&
|
|
70
|
+
plugins.path.normalize(this.directory) === this.directory &&
|
|
71
|
+
!this.directory.endsWith('/') && !/[\x00-\x1f\x7f]/.test(this.directory) &&
|
|
72
|
+
this.directory.length <= 4000 && Number.isSafeInteger(this.uid) && this.uid >= 0,
|
|
73
|
+
'invalid_options',
|
|
74
|
+
);
|
|
75
|
+
this.path = plugins.path.join(this.directory, this.name);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Reads only the exact protected definition; no systemd or filesystem mutation. */
|
|
79
|
+
public async inspect(): Promise<ISystemdUnitFileRecord | null> {
|
|
80
|
+
if (!await this.directories(false)) return null;
|
|
81
|
+
const file = await this.read(this.path);
|
|
82
|
+
return file ? Object.freeze({ path: this.path, sha256: digest(file.bytes) }) : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Expected previous digest is mandatory; null admits an absent file. Exact desired bytes are idempotent.
|
|
87
|
+
* Retains changed definitions beside the unit before replacement. A failed reload never rolls code back.
|
|
88
|
+
*/
|
|
89
|
+
public async install(
|
|
90
|
+
definition: SystemdServiceDefinition,
|
|
91
|
+
expectedSha256: string | null,
|
|
92
|
+
): Promise<Readonly<{ changed: boolean; record: ISystemdUnitFileRecord; masked: boolean }>> {
|
|
93
|
+
requireValue(
|
|
94
|
+
definition instanceof SystemdServiceDefinition && definition.unitName === this.name &&
|
|
95
|
+
(expectedSha256 === null ||
|
|
96
|
+
typeof expectedSha256 === 'string' && /^[a-f0-9]{64}$/.test(expectedSha256)),
|
|
97
|
+
'invalid_options',
|
|
98
|
+
);
|
|
99
|
+
requireValue(!this.busy, 'busy');
|
|
100
|
+
this.busy = true;
|
|
101
|
+
let temporary: string | undefined;
|
|
102
|
+
let temporaryInfo: plugins.Stats | undefined;
|
|
103
|
+
try {
|
|
104
|
+
const before = await this.unit.inspect();
|
|
105
|
+
await this.admit(before);
|
|
106
|
+
await this.directories(true);
|
|
107
|
+
const parent = await plugins.fs.lstat(this.directory);
|
|
108
|
+
const previous = await this.read(this.path);
|
|
109
|
+
const previousSha = previous ? digest(previous.bytes) : null;
|
|
110
|
+
const changed = previousSha !== definition.sha256;
|
|
111
|
+
requireValue(!changed || previousSha === expectedSha256, 'changed');
|
|
112
|
+
if (changed) {
|
|
113
|
+
if (previous) {
|
|
114
|
+
const backup = `${this.path}.previous-${previousSha}`;
|
|
115
|
+
try {
|
|
116
|
+
await this.create(backup, previous.bytes);
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
|
|
119
|
+
requireValue(digest((await this.read(backup))!.bytes) === previousSha, 'changed');
|
|
120
|
+
}
|
|
121
|
+
await this.flushDirectory(parent);
|
|
122
|
+
}
|
|
123
|
+
temporary = plugins.path.join(
|
|
124
|
+
this.directory,
|
|
125
|
+
`.${this.name}.${plugins.crypto.randomUUID()}.tmp`,
|
|
126
|
+
);
|
|
127
|
+
await this.create(temporary, Buffer.from(definition.content));
|
|
128
|
+
temporaryInfo = await plugins.fs.lstat(temporary);
|
|
129
|
+
const current = await this.read(this.path);
|
|
130
|
+
requireValue(
|
|
131
|
+
(current ? digest(current.bytes) : null) === previousSha &&
|
|
132
|
+
(!current || previous && sameFile(current.info, previous.info)),
|
|
133
|
+
'changed',
|
|
134
|
+
);
|
|
135
|
+
requireValue(sameFile(parent, await plugins.fs.lstat(this.directory)), 'unsafe_path');
|
|
136
|
+
const rechecked = await this.unit.inspect();
|
|
137
|
+
requireValue(JSON.stringify(rechecked) === JSON.stringify(before), 'changed');
|
|
138
|
+
await this.admit(rechecked);
|
|
139
|
+
await plugins.fs.rename(temporary, this.path);
|
|
140
|
+
temporary = undefined;
|
|
141
|
+
await this.flushDirectory(parent);
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
await this.unit.reloadConfiguration();
|
|
145
|
+
const after = await this.unit.inspect();
|
|
146
|
+
requireValue(
|
|
147
|
+
quiet(after) &&
|
|
148
|
+
(before.loadState === 'not-found'
|
|
149
|
+
? after.loadState === 'loaded' && after.unitFileState === 'disabled'
|
|
150
|
+
: after.loadState === before.loadState &&
|
|
151
|
+
after.unitFileState === before.unitFileState),
|
|
152
|
+
'changed',
|
|
153
|
+
);
|
|
154
|
+
const configuration = await this.unit.inspectConfiguration();
|
|
155
|
+
requireValue(
|
|
156
|
+
!configuration.needsReload && configuration.dropInPaths === '' &&
|
|
157
|
+
configuration.fragmentPath === (masked(after) ? '/dev/null' : this.path),
|
|
158
|
+
'unsafe_unit',
|
|
159
|
+
);
|
|
160
|
+
const installed = await this.inspect();
|
|
161
|
+
requireValue(installed?.sha256 === definition.sha256, 'changed');
|
|
162
|
+
return Object.freeze({ changed, record: installed!, masked: masked(after) });
|
|
163
|
+
} catch {
|
|
164
|
+
throw new SystemdUnitFileError('reload_failed');
|
|
165
|
+
}
|
|
166
|
+
} catch (error) {
|
|
167
|
+
if (error instanceof SystemdUnitFileError) throw error;
|
|
168
|
+
throw new SystemdUnitFileError('write_failed');
|
|
169
|
+
} finally {
|
|
170
|
+
try {
|
|
171
|
+
if (temporary && temporaryInfo) {
|
|
172
|
+
const named = await plugins.fs.lstat(temporary);
|
|
173
|
+
requireValue(sameFile(temporaryInfo, named), 'unsafe_path');
|
|
174
|
+
await plugins.fs.unlink(temporary);
|
|
175
|
+
}
|
|
176
|
+
} finally {
|
|
177
|
+
this.busy = false;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private async admit(state: ISystemdUnitState): Promise<void> {
|
|
183
|
+
requireValue(
|
|
184
|
+
quiet(state) && (masked(state) ||
|
|
185
|
+
state.loadState === 'loaded' &&
|
|
186
|
+
['enabled', 'enabled-runtime', 'disabled'].includes(state.unitFileState) ||
|
|
187
|
+
state.loadState === 'not-found' && state.unitFileState === ''),
|
|
188
|
+
'unsafe_unit',
|
|
189
|
+
);
|
|
190
|
+
const configuration = await this.unit.inspectConfiguration();
|
|
191
|
+
requireValue(
|
|
192
|
+
configuration.dropInPaths === '' && configuration.fragmentPath ===
|
|
193
|
+
(masked(state) ? '/dev/null' : state.loadState === 'not-found' ? '' : this.path),
|
|
194
|
+
'unsafe_unit',
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private async directories(create: boolean): Promise<boolean> {
|
|
199
|
+
requireValue(process.platform === 'linux' && process.getuid?.() === this.uid, 'unsafe_path');
|
|
200
|
+
let directory = '/';
|
|
201
|
+
for (const part of this.directory.split('/').filter(Boolean)) {
|
|
202
|
+
directory = plugins.path.join(directory, part);
|
|
203
|
+
let info: plugins.Stats;
|
|
204
|
+
try {
|
|
205
|
+
info = await plugins.fs.lstat(directory);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
208
|
+
if (!create) return false;
|
|
209
|
+
await plugins.fs.mkdir(directory, { mode: 0o755 });
|
|
210
|
+
const parent = await plugins.fs.open(
|
|
211
|
+
plugins.path.dirname(directory),
|
|
212
|
+
plugins.fsConstants.O_RDONLY | plugins.fsConstants.O_DIRECTORY |
|
|
213
|
+
plugins.fsConstants.O_NOFOLLOW,
|
|
214
|
+
);
|
|
215
|
+
try {
|
|
216
|
+
await parent.sync();
|
|
217
|
+
} finally {
|
|
218
|
+
await parent.close();
|
|
219
|
+
}
|
|
220
|
+
info = await plugins.fs.lstat(directory);
|
|
221
|
+
}
|
|
222
|
+
requireValue(
|
|
223
|
+
info.isDirectory() && !info.isSymbolicLink() && [0, this.uid].includes(info.uid) &&
|
|
224
|
+
((info.mode & 0o022) === 0 || directory === '/tmp' && info.uid === 0 &&
|
|
225
|
+
(info.mode & 0o7777) === 0o1777),
|
|
226
|
+
'unsafe_path',
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private async read(path: string): Promise<{ bytes: Buffer; info: plugins.Stats } | null> {
|
|
233
|
+
let file: Awaited<ReturnType<typeof plugins.fs.open>>;
|
|
234
|
+
try {
|
|
235
|
+
file = await plugins.fs.open(
|
|
236
|
+
path,
|
|
237
|
+
plugins.fsConstants.O_RDONLY | plugins.fsConstants.O_NOFOLLOW |
|
|
238
|
+
plugins.fsConstants.O_NONBLOCK,
|
|
239
|
+
);
|
|
240
|
+
} catch (error) {
|
|
241
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
|
242
|
+
throw new SystemdUnitFileError('unsafe_path');
|
|
243
|
+
}
|
|
244
|
+
try {
|
|
245
|
+
const info = await file.stat();
|
|
246
|
+
requireValue(
|
|
247
|
+
info.isFile() && info.uid === this.uid && info.nlink === 1 &&
|
|
248
|
+
(info.mode & 0o7777) === 0o644 && info.size <= 16384,
|
|
249
|
+
'unsafe_path',
|
|
250
|
+
);
|
|
251
|
+
const bytes = Buffer.alloc(16385);
|
|
252
|
+
let size = 0;
|
|
253
|
+
for (;;) {
|
|
254
|
+
const result = await file.read(bytes, size, bytes.length - size, size);
|
|
255
|
+
if (result.bytesRead === 0) break;
|
|
256
|
+
size += result.bytesRead;
|
|
257
|
+
requireValue(size <= 16384, 'unsafe_path');
|
|
258
|
+
}
|
|
259
|
+
const after = await file.stat(), named = await plugins.fs.lstat(path);
|
|
260
|
+
requireValue(
|
|
261
|
+
sameFile(info, named) && !named.isSymbolicLink() && after.size === size &&
|
|
262
|
+
info.size === after.size && info.mtimeMs === after.mtimeMs &&
|
|
263
|
+
info.ctimeMs === after.ctimeMs,
|
|
264
|
+
'changed',
|
|
265
|
+
);
|
|
266
|
+
return { bytes: bytes.subarray(0, size), info };
|
|
267
|
+
} finally {
|
|
268
|
+
await file.close();
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private async create(path: string, bytes: Uint8Array): Promise<void> {
|
|
273
|
+
const file = await plugins.fs.open(
|
|
274
|
+
path,
|
|
275
|
+
plugins.fsConstants.O_CREAT | plugins.fsConstants.O_EXCL |
|
|
276
|
+
plugins.fsConstants.O_WRONLY | plugins.fsConstants.O_NOFOLLOW,
|
|
277
|
+
0o644,
|
|
278
|
+
);
|
|
279
|
+
let created: plugins.Stats | undefined;
|
|
280
|
+
try {
|
|
281
|
+
try {
|
|
282
|
+
created = await file.stat();
|
|
283
|
+
await file.chmod(0o644);
|
|
284
|
+
await file.writeFile(bytes);
|
|
285
|
+
await file.sync();
|
|
286
|
+
} finally {
|
|
287
|
+
await file.close();
|
|
288
|
+
}
|
|
289
|
+
} catch (error) {
|
|
290
|
+
if (created) {
|
|
291
|
+
requireValue(sameFile(created, await plugins.fs.lstat(path)), 'unsafe_path');
|
|
292
|
+
await plugins.fs.unlink(path);
|
|
293
|
+
}
|
|
294
|
+
throw error;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private async flushDirectory(expected: plugins.Stats): Promise<void> {
|
|
299
|
+
const directory = await plugins.fs.open(
|
|
300
|
+
this.directory,
|
|
301
|
+
plugins.fsConstants.O_RDONLY |
|
|
302
|
+
plugins.fsConstants.O_DIRECTORY | plugins.fsConstants.O_NOFOLLOW,
|
|
303
|
+
);
|
|
304
|
+
try {
|
|
305
|
+
requireValue(sameFile(expected, await directory.stat()), 'unsafe_path');
|
|
306
|
+
await directory.sync();
|
|
307
|
+
} finally {
|
|
308
|
+
await directory.close();
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
package/ts/index.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
// node native scope
|
|
2
2
|
import * as path from 'path';
|
|
3
|
+
import * as childProcess from 'node:child_process';
|
|
4
|
+
import * as fs from 'node:fs/promises';
|
|
5
|
+
import { constants as fsConstants } from 'node:fs';
|
|
6
|
+
import * as crypto from 'node:crypto';
|
|
7
|
+
export type { Stats } from 'node:fs';
|
|
3
8
|
|
|
4
|
-
export { path };
|
|
9
|
+
export { path, childProcess, fs, fsConstants, crypto };
|
|
5
10
|
|
|
6
11
|
// @pushrocks scope
|
|
7
12
|
import * as lik from '@push.rocks/lik';
|
package/readme.hints.md
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
|