@littlebearapps/outlook-assistant 3.13.0 → 3.14.1
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/.env.example +30 -3
- package/README.md +67 -27
- package/advanced/index.js +44 -174
- package/auth/auth-errors.js +23 -1
- package/auth/oauth-server.js +7 -1
- package/auth/token-manager.js +7 -3
- package/auth/token-storage.js +28 -30
- package/auth/tools.js +61 -82
- package/calendar/attendees.js +36 -0
- package/calendar/cancel.js +9 -25
- package/calendar/create.js +42 -48
- package/calendar/decline.js +10 -25
- package/calendar/delete.js +10 -25
- package/calendar/index.js +20 -37
- package/calendar/list.js +4 -16
- package/calendar/preview.js +461 -0
- package/calendar/update.js +55 -83
- package/categories/index.js +68 -265
- package/config.js +29 -1
- package/contacts/index.js +72 -128
- package/email/attachments.js +43 -125
- package/email/conversations.js +44 -78
- package/email/delta.js +69 -46
- package/email/draft.js +170 -103
- package/email/export.js +145 -110
- package/email/folder-utils.js +3 -2
- package/email/headers.js +11 -49
- package/email/index.js +86 -110
- package/email/list.js +4 -17
- package/email/mail-tips.js +86 -57
- package/email/mark-as-read.js +13 -49
- package/email/mime.js +39 -51
- package/email/read.js +16 -50
- package/email/search.js +47 -87
- package/email/send.js +82 -48
- package/folder/create.js +6 -25
- package/folder/delete.js +117 -38
- package/folder/index.js +19 -17
- package/folder/list.js +5 -17
- package/folder/move.js +13 -42
- package/folder/resolve.js +11 -6
- package/folder/stats.js +18 -27
- package/index.js +39 -45
- package/llms-install.md +22 -4
- package/llms.txt +20 -11
- package/outlook-auth-server.js +10 -3
- package/package.json +4 -1
- package/request-handler.js +217 -116
- package/rules/create.js +28 -71
- package/rules/index.js +52 -93
- package/rules/list.js +7 -19
- package/rules/rule-builder.js +59 -22
- package/rules/update.js +27 -61
- package/server.js +41 -0
- package/settings/index.js +162 -145
- package/tools.js +30 -0
- package/utils/field-presets.js +4 -2
- package/utils/graph-api.js +65 -22
- package/utils/logger.js +251 -0
- package/utils/mock-data.js +91 -2
- package/utils/read-only.js +59 -0
- package/utils/response-formatter.js +54 -15
- package/utils/risk-classes.js +324 -0
- package/utils/safe-write.js +372 -6
- package/utils/safety.js +247 -42
- package/utils/server-instructions.js +73 -0
- package/utils/tool-error.js +33 -0
package/utils/safe-write.js
CHANGED
|
@@ -2,16 +2,299 @@
|
|
|
2
2
|
* Safe output writes, shared by attachment download, message export and
|
|
3
3
|
* conversation export.
|
|
4
4
|
*
|
|
5
|
+
* Every output path is first confined (confineOutputPath) to the system temp
|
|
6
|
+
* directory, ~/Downloads, ~/Documents or OUTLOOK_EXPORT_DIR, with no dotfile
|
|
7
|
+
* or dot-directory below them. Caller paths must be absolute or start with
|
|
8
|
+
* `~`; relative paths are refused rather than resolved against the server's
|
|
9
|
+
* working directory.
|
|
10
|
+
*
|
|
5
11
|
* Every file the server names itself is written with exclusive create (`wx`),
|
|
6
12
|
* so an existing file is never overwritten and a planted symlink — even a
|
|
7
13
|
* dangling one — is never followed. A clash gets a `-1`, `-2`, … suffix
|
|
8
14
|
* instead, and the result is always confined to `outputDir`.
|
|
15
|
+
*
|
|
16
|
+
* A file path the caller names (export savePath) is also created exclusively;
|
|
17
|
+
* it replaces an existing file only with `overwrite: true`, and never a
|
|
18
|
+
* symlink, a hard-linked file or anything in a dotted path (writeExplicitFile).
|
|
19
|
+
* A symlink as the last component of a caller path is not followed unless it
|
|
20
|
+
* leads to a directory, so a link to a file is refused, not written through.
|
|
21
|
+
*
|
|
22
|
+
* Files are created with mode 0600 and directories the server creates with
|
|
23
|
+
* 0700, set explicitly so the umask can't widen or narrow them; existing
|
|
24
|
+
* directories keep their mode, and a replaced file keeps the mode it had.
|
|
9
25
|
*/
|
|
26
|
+
const crypto = require('crypto');
|
|
10
27
|
const fs = require('fs');
|
|
28
|
+
const os = require('os');
|
|
11
29
|
const path = require('path');
|
|
12
30
|
|
|
31
|
+
/**
|
|
32
|
+
* A refused output path. The message says what was refused and why; nextStep
|
|
33
|
+
* says what to do instead. Handlers turn it into a tool error.
|
|
34
|
+
*/
|
|
35
|
+
class OutputPathError extends Error {
|
|
36
|
+
constructor(message, nextStep) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.name = 'OutputPathError';
|
|
39
|
+
this.nextStep = nextStep;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve a path to where it really is: `..` is resolved, and the longest
|
|
45
|
+
* existing prefix goes through realpath (so symlinked directories are
|
|
46
|
+
* followed); the not-yet-existing remainder is appended as is.
|
|
47
|
+
* @param {string} target
|
|
48
|
+
* @returns {string} Absolute path
|
|
49
|
+
*/
|
|
50
|
+
function resolveReal(target) {
|
|
51
|
+
const absolute = path.resolve(target);
|
|
52
|
+
const missing = [];
|
|
53
|
+
let current = absolute;
|
|
54
|
+
for (;;) {
|
|
55
|
+
try {
|
|
56
|
+
const real = fs.realpathSync.native(current);
|
|
57
|
+
return missing.length ? path.join(real, ...missing.reverse()) : real;
|
|
58
|
+
} catch (error) {
|
|
59
|
+
const parent = path.dirname(current);
|
|
60
|
+
if (error.code !== 'ENOENT' || parent === current) throw error;
|
|
61
|
+
missing.push(path.basename(current));
|
|
62
|
+
current = parent;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolve an output target without following a symlink in its last
|
|
69
|
+
* component: the parent goes through resolveReal and the name is appended.
|
|
70
|
+
* A last-component symlink to a directory is followed (it is used as that
|
|
71
|
+
* directory, and confined where it leads); any other symlink — to a file,
|
|
72
|
+
* or dangling — is kept as the link itself, which the writers refuse.
|
|
73
|
+
* @param {string} absolute - Absolute path
|
|
74
|
+
* @returns {string}
|
|
75
|
+
*/
|
|
76
|
+
function resolveTarget(absolute) {
|
|
77
|
+
const normalised = path.resolve(absolute);
|
|
78
|
+
const parent = path.dirname(normalised);
|
|
79
|
+
if (parent === normalised) return resolveReal(normalised); // filesystem root
|
|
80
|
+
const candidate = path.join(resolveReal(parent), path.basename(normalised));
|
|
81
|
+
let stat;
|
|
82
|
+
try {
|
|
83
|
+
stat = fs.lstatSync(candidate);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (error.code === 'ENOENT') return candidate;
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
if (!stat.isSymbolicLink()) return candidate;
|
|
89
|
+
try {
|
|
90
|
+
const real = fs.realpathSync.native(candidate);
|
|
91
|
+
if (fs.statSync(real).isDirectory()) return real;
|
|
92
|
+
} catch {
|
|
93
|
+
// Dangling or unreadable: keep the link itself.
|
|
94
|
+
}
|
|
95
|
+
return candidate;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Expand a leading `~` to the home directory.
|
|
100
|
+
* @param {string} p
|
|
101
|
+
* @returns {string}
|
|
102
|
+
*/
|
|
103
|
+
function expandHome(p) {
|
|
104
|
+
if (p === '~') return os.homedir();
|
|
105
|
+
if (p.startsWith('~/') || p.startsWith('~\\')) {
|
|
106
|
+
return path.join(os.homedir(), p.slice(2));
|
|
107
|
+
}
|
|
108
|
+
return p;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The directories exports and downloads may write into, each resolved to
|
|
113
|
+
* where it really is. Read on every call, so env changes apply at once.
|
|
114
|
+
* @returns {Array<{label: string, dir: string}>}
|
|
115
|
+
*/
|
|
116
|
+
function allowedOutputBases() {
|
|
117
|
+
const home = os.homedir();
|
|
118
|
+
const bases = [
|
|
119
|
+
{ label: 'the system temp directory', dir: os.tmpdir() },
|
|
120
|
+
{ label: '~/Downloads', dir: path.join(home, 'Downloads') },
|
|
121
|
+
{ label: '~/Documents', dir: path.join(home, 'Documents') },
|
|
122
|
+
];
|
|
123
|
+
const exportDir = (process.env.OUTLOOK_EXPORT_DIR || '').trim();
|
|
124
|
+
if (exportDir) {
|
|
125
|
+
bases.push({
|
|
126
|
+
label: 'OUTLOOK_EXPORT_DIR',
|
|
127
|
+
dir: path.resolve(expandHome(exportDir)),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return bases.flatMap((base) => {
|
|
131
|
+
try {
|
|
132
|
+
return [{ ...base, dir: resolveReal(base.dir) }];
|
|
133
|
+
} catch {
|
|
134
|
+
return []; // Unresolvable (e.g. unreadable): not usable as a base
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Path segments of `child` below `parent`, or null if it is not inside.
|
|
141
|
+
* @param {string} parent - Resolved directory
|
|
142
|
+
* @param {string} child - Resolved path
|
|
143
|
+
* @returns {string[]|null}
|
|
144
|
+
*/
|
|
145
|
+
function segmentsBelow(parent, child) {
|
|
146
|
+
const rel = path.relative(parent, child);
|
|
147
|
+
if (rel === '') return [];
|
|
148
|
+
if (rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
return rel.split(path.sep);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Resolve an output file or directory and check it may be written: it must
|
|
156
|
+
* be inside an allowed base, with no dotfile or dot-directory below that
|
|
157
|
+
* base. Callers must write to the returned `path`, not the one passed in;
|
|
158
|
+
* `requested` is the caller's path (absolute, `~` expanded) for messages;
|
|
159
|
+
* `base` is the allowed directory it is in.
|
|
160
|
+
* @param {string} target - Path from the caller: absolute, or starting
|
|
161
|
+
* with `~`/`~/` for the home directory. Anything else is refused.
|
|
162
|
+
* @returns {{path: string, requested: string, base: string}}
|
|
163
|
+
* @throws {OutputPathError}
|
|
164
|
+
*/
|
|
165
|
+
function confineOutputTarget(target) {
|
|
166
|
+
const absolute = typeof target === 'string' ? expandHome(target) : target;
|
|
167
|
+
if (typeof absolute !== 'string' || !path.isAbsolute(absolute)) {
|
|
168
|
+
throw new OutputPathError(
|
|
169
|
+
`Refusing to write to ${JSON.stringify(target)}: output paths must be absolute (or start with ~/ for the home directory). A relative path would land in the server's working directory.`,
|
|
170
|
+
'Pass an absolute path, or omit the path to use the system temp directory.'
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
const requested = path.resolve(absolute);
|
|
174
|
+
let resolved;
|
|
175
|
+
try {
|
|
176
|
+
resolved = resolveTarget(absolute);
|
|
177
|
+
} catch (error) {
|
|
178
|
+
throw new OutputPathError(
|
|
179
|
+
`Cannot use output path ${JSON.stringify(target)}: ${error.message}`,
|
|
180
|
+
'Pass a plain absolute path, or omit the path to use the system temp directory.'
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
const bases = allowedOutputBases();
|
|
184
|
+
let dotted = false;
|
|
185
|
+
for (const base of bases) {
|
|
186
|
+
const below = segmentsBelow(base.dir, resolved);
|
|
187
|
+
if (!below) continue;
|
|
188
|
+
if (!below.some((segment) => segment.startsWith('.'))) {
|
|
189
|
+
return { path: resolved, requested, base: base.dir };
|
|
190
|
+
}
|
|
191
|
+
dotted = true;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const baseList = bases.map((b) => `${b.label} (${b.dir})`).join(', ');
|
|
195
|
+
if (dotted) {
|
|
196
|
+
throw new OutputPathError(
|
|
197
|
+
`Refusing to write to ${resolved}: exports and attachment downloads never write to a dotfile or into a dot-directory (a name starting with ".").`,
|
|
198
|
+
`Choose a path without a dot-prefixed name inside one of: ${baseList}.`
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
const exportDirNote = process.env.OUTLOOK_EXPORT_DIR
|
|
202
|
+
? ''
|
|
203
|
+
: ' OUTLOOK_EXPORT_DIR is not set.';
|
|
204
|
+
throw new OutputPathError(
|
|
205
|
+
`Refusing to write to ${resolved}: exports and attachment downloads can only write inside ${baseList}.${exportDirNote}`,
|
|
206
|
+
'Choose a path inside one of those directories, or ask the user to set OUTLOOK_EXPORT_DIR to an absolute directory in the MCP server env and restart the server.'
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* confineOutputTarget, returning only the resolved path.
|
|
212
|
+
* @param {string} target
|
|
213
|
+
* @returns {string}
|
|
214
|
+
* @throws {OutputPathError}
|
|
215
|
+
*/
|
|
216
|
+
function confineOutputPath(target) {
|
|
217
|
+
return confineOutputTarget(target).path;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Whether any segment of a resolved path starts with a dot. With `base`
|
|
222
|
+
* (the allowed directory the path was confined to), only the segments below
|
|
223
|
+
* it count, so a dotted OUTLOOK_EXPORT_DIR doesn't make every file in it
|
|
224
|
+
* look dotted; without one, or if the path isn't below it, every segment
|
|
225
|
+
* counts.
|
|
226
|
+
* @param {string} resolved
|
|
227
|
+
* @param {string} [base]
|
|
228
|
+
* @returns {boolean}
|
|
229
|
+
*/
|
|
230
|
+
function hasDotSegment(resolved, base) {
|
|
231
|
+
const segments =
|
|
232
|
+
(base && segmentsBelow(base, resolved)) || resolved.split(path.sep);
|
|
233
|
+
return segments.some((segment) => segment.startsWith('.'));
|
|
234
|
+
}
|
|
235
|
+
|
|
13
236
|
const MAX_ATTEMPTS = 1000;
|
|
14
237
|
|
|
238
|
+
/** Mode for every file exports and downloads create: owner read/write. */
|
|
239
|
+
const FILE_MODE = 0o600;
|
|
240
|
+
/** Mode for every directory they create: owner only. */
|
|
241
|
+
const DIR_MODE = 0o700;
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Set a mode regardless of the umask. Best effort: the mode given at create
|
|
245
|
+
* time (narrowed by the umask, never widened) stays if the filesystem
|
|
246
|
+
* refuses chmod.
|
|
247
|
+
* @param {() => void} chmod
|
|
248
|
+
*/
|
|
249
|
+
function forceMode(chmod) {
|
|
250
|
+
try {
|
|
251
|
+
chmod();
|
|
252
|
+
} catch {
|
|
253
|
+
// e.g. EPERM on a filesystem without POSIX modes
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Create `filePath` exclusively (`wx`: fails on any existing entry,
|
|
259
|
+
* including a symlink), set its mode, and write `data`. If the write fails
|
|
260
|
+
* part-way, the file this call created is removed before rethrowing.
|
|
261
|
+
* @param {string} filePath
|
|
262
|
+
* @param {string|Buffer} data
|
|
263
|
+
* @param {string} [encoding]
|
|
264
|
+
* @param {number} [mode]
|
|
265
|
+
*/
|
|
266
|
+
function writeNewFile(filePath, data, encoding, mode = FILE_MODE) {
|
|
267
|
+
const fd = fs.openSync(filePath, 'wx', mode);
|
|
268
|
+
try {
|
|
269
|
+
forceMode(() => fs.fchmodSync(fd, mode));
|
|
270
|
+
fs.writeFileSync(fd, data, { encoding });
|
|
271
|
+
} catch (error) {
|
|
272
|
+
fs.closeSync(fd);
|
|
273
|
+
removePartialFile(filePath);
|
|
274
|
+
throw error;
|
|
275
|
+
}
|
|
276
|
+
fs.closeSync(fd);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Create `dir` and any missing parents with mode 0700. Directories that
|
|
281
|
+
* already exist keep their mode.
|
|
282
|
+
* @param {string} dir
|
|
283
|
+
*/
|
|
284
|
+
function ensureOutputDir(dir) {
|
|
285
|
+
const target = path.resolve(dir);
|
|
286
|
+
const first = fs.mkdirSync(target, { recursive: true, mode: DIR_MODE });
|
|
287
|
+
if (!first) return; // Nothing was created
|
|
288
|
+
const below = segmentsBelow(first, target) || [];
|
|
289
|
+
let current = first;
|
|
290
|
+
forceMode(() => fs.chmodSync(current, DIR_MODE));
|
|
291
|
+
for (const segment of below) {
|
|
292
|
+
current = path.join(current, segment);
|
|
293
|
+
const created = current;
|
|
294
|
+
forceMode(() => fs.chmodSync(created, DIR_MODE));
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
15
298
|
/**
|
|
16
299
|
* Like fs.existsSync, but a dangling symlink counts as existing (existsSync
|
|
17
300
|
* follows the link and reports false).
|
|
@@ -110,13 +393,10 @@ function writeClaimedFile(outputDir, base, extension, claimed, data, encoding) {
|
|
|
110
393
|
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
111
394
|
const candidate = claimUniquePath(outputDir, base, extension, seen);
|
|
112
395
|
try {
|
|
113
|
-
|
|
396
|
+
writeNewFile(candidate, data, encoding);
|
|
114
397
|
return candidate;
|
|
115
398
|
} catch (error) {
|
|
116
|
-
if (error.code !== 'EEXIST')
|
|
117
|
-
removePartialFile(candidate);
|
|
118
|
-
throw error;
|
|
119
|
-
}
|
|
399
|
+
if (error.code !== 'EEXIST') throw error;
|
|
120
400
|
}
|
|
121
401
|
}
|
|
122
402
|
throw new Error(`Too many files named ${base} in ${outputDir}`);
|
|
@@ -136,7 +416,8 @@ function makeClaimedDir(outputDir, base) {
|
|
|
136
416
|
for (let suffix = 0; suffix < MAX_ATTEMPTS; suffix++) {
|
|
137
417
|
const candidate = candidatePath(root, base, '', suffix);
|
|
138
418
|
try {
|
|
139
|
-
fs.mkdirSync(candidate);
|
|
419
|
+
fs.mkdirSync(candidate, { mode: DIR_MODE });
|
|
420
|
+
forceMode(() => fs.chmodSync(candidate, DIR_MODE));
|
|
140
421
|
return candidate;
|
|
141
422
|
} catch (error) {
|
|
142
423
|
if (error.code !== 'EEXIST') throw error;
|
|
@@ -145,7 +426,92 @@ function makeClaimedDir(outputDir, base) {
|
|
|
145
426
|
throw new Error(`Too many directories named ${base} in ${root}`);
|
|
146
427
|
}
|
|
147
428
|
|
|
429
|
+
/**
|
|
430
|
+
* The refusal for an explicit file path that already exists.
|
|
431
|
+
* @param {string} filePath
|
|
432
|
+
* @returns {OutputPathError}
|
|
433
|
+
*/
|
|
434
|
+
function fileExistsError(filePath) {
|
|
435
|
+
return new OutputPathError(
|
|
436
|
+
`File already exists: ${filePath}. Nothing was written.`,
|
|
437
|
+
'Pass overwrite: true to replace it, choose a different savePath, or pass a directory as savePath so a new, unique file name is used.'
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Write `data` to a file path the caller chose (already confined). A new file
|
|
443
|
+
* is created exclusively. An existing one is replaced only with
|
|
444
|
+
* `overwrite: true`, and only if it is a regular file with a single link and
|
|
445
|
+
* no segment of its path (below `base`, when given) starts with a dot. The replacement is written to a
|
|
446
|
+
* temporary file beside it and renamed over it, so a link swapped in after
|
|
447
|
+
* the check is replaced, not followed.
|
|
448
|
+
* @param {string} filePath - Resolved target path
|
|
449
|
+
* @param {string|Buffer} data - File contents
|
|
450
|
+
* @param {{overwrite?: boolean, encoding?: string, displayPath?: string, base?: string}} [options]
|
|
451
|
+
* displayPath: the path as the caller gave it, used in refusals;
|
|
452
|
+
* base: the allowed directory from confineOutputTarget (dot check below it)
|
|
453
|
+
* @returns {{path: string, replaced: boolean}}
|
|
454
|
+
* @throws {OutputPathError} When the file exists and may not be replaced
|
|
455
|
+
*/
|
|
456
|
+
function writeExplicitFile(
|
|
457
|
+
filePath,
|
|
458
|
+
data,
|
|
459
|
+
{ overwrite = false, encoding, displayPath = filePath, base } = {}
|
|
460
|
+
) {
|
|
461
|
+
const dir = path.dirname(filePath);
|
|
462
|
+
ensureOutputDir(dir);
|
|
463
|
+
try {
|
|
464
|
+
writeNewFile(filePath, data, encoding);
|
|
465
|
+
return { path: filePath, replaced: false };
|
|
466
|
+
} catch (error) {
|
|
467
|
+
if (error.code !== 'EEXIST') throw error;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (!overwrite) throw fileExistsError(displayPath);
|
|
471
|
+
if (hasDotSegment(filePath, base)) {
|
|
472
|
+
throw new OutputPathError(
|
|
473
|
+
`Refusing to replace ${displayPath}: files that are dotfiles or inside a dot-directory are never replaced, even with overwrite: true. Nothing was written.`,
|
|
474
|
+
'Choose a different savePath, or pass a directory so a new, unique file name is used.'
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
const stat = fs.lstatSync(filePath);
|
|
478
|
+
let problem = null;
|
|
479
|
+
if (stat.isSymbolicLink()) problem = 'it is a symbolic link';
|
|
480
|
+
else if (!stat.isFile()) problem = 'it is not a regular file';
|
|
481
|
+
else if (stat.nlink > 1) problem = 'it has other hard links';
|
|
482
|
+
if (problem) {
|
|
483
|
+
throw new OutputPathError(
|
|
484
|
+
`Refusing to replace ${displayPath}: ${problem}. Nothing was written.`,
|
|
485
|
+
'Choose a different savePath, or pass a directory so a new, unique file name is used.'
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const temp = path.join(
|
|
490
|
+
dir,
|
|
491
|
+
`.${path.basename(filePath)}.${crypto.randomBytes(6).toString('hex')}.tmp`
|
|
492
|
+
);
|
|
493
|
+
try {
|
|
494
|
+
// The replacement keeps the mode the file had.
|
|
495
|
+
writeNewFile(temp, data, encoding, stat.mode & 0o777);
|
|
496
|
+
fs.renameSync(temp, filePath);
|
|
497
|
+
} catch (error) {
|
|
498
|
+
removePartialFile(temp);
|
|
499
|
+
throw error;
|
|
500
|
+
}
|
|
501
|
+
return { path: filePath, replaced: true };
|
|
502
|
+
}
|
|
503
|
+
|
|
148
504
|
module.exports = {
|
|
505
|
+
FILE_MODE,
|
|
506
|
+
DIR_MODE,
|
|
507
|
+
ensureOutputDir,
|
|
149
508
|
writeClaimedFile,
|
|
150
509
|
makeClaimedDir,
|
|
510
|
+
writeExplicitFile,
|
|
511
|
+
confineOutputPath,
|
|
512
|
+
confineOutputTarget,
|
|
513
|
+
allowedOutputBases,
|
|
514
|
+
fileExistsError,
|
|
515
|
+
pathEntryExists,
|
|
516
|
+
OutputPathError,
|
|
151
517
|
};
|