brightspace-mcp-server 5.1.44 → 5.2.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.
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Purdue Brightspace MCP Server
3
+ * Copyright (c) 2026 Rohan Muppa. All rights reserved.
4
+ * Licensed under MIT — see LICENSE file for details.
5
+ */
6
+ import { DownloadCourseFilesSchema } from "./schemas.js";
7
+ import { errorResponse, sanitizeError, withUpdateNotice } from "./tool-helpers.js";
8
+ import { log } from "../utils/logger.js";
9
+ import { validateContentId } from "../utils/file-validator.js";
10
+ import { SyncInputError, syncCourse } from "../sync/course-sync.js";
11
+ import { summarizeSync } from "../sync/summary.js";
12
+ import path from "node:path";
13
+ import fs from "node:fs/promises";
14
+ /**
15
+ * Register download_course_files tool
16
+ */
17
+ export function registerDownloadCourseFiles(server, apiClient) {
18
+ server.registerTool("download_course_files", {
19
+ title: "Download Course Files",
20
+ description: "Download every file in a course's Content area to a folder on the host computer, in sub-folders named after the course's modules. Use this when the user wants a whole course (or one module) saved, instead of calling download_file once per file. Ask the user where to save it before calling; never guess a directory. It is safe to run again: files already on disk are skipped by default, so a rerun fetches only what is new and never overwrites the user's annotated copies. Use dryRun first to preview a large course. The answer counts what was downloaded, skipped, failed and not attempted, and what was not a file (web links, embedded videos, broken items, locked items), so do not report those as missing. If the Brightspace session expires mid-run it stops with sign-in instructions and the files already saved are kept; sign in and call it again.",
21
+ inputSchema: DownloadCourseFilesSchema,
22
+ }, async (args, extra) => {
23
+ try {
24
+ log("DEBUG", "download_course_files tool called", { args });
25
+ const { courseId, downloadPath, existing, moduleId, dryRun, maxFiles, maxMinutes, maxTotalMB } = DownloadCourseFilesSchema.parse(args);
26
+ validateContentId(courseId);
27
+ // Same rules as download_file's disk mode: an absolute path to a directory that exists.
28
+ if (!path.isAbsolute(downloadPath)) {
29
+ return errorResponse("Download path must be an absolute path (e.g., /Users/username/Downloads on Mac or C:\\Users\\username\\Downloads on Windows)", "invalidInput");
30
+ }
31
+ try {
32
+ if (!(await fs.stat(downloadPath)).isDirectory()) {
33
+ return errorResponse(`Download path is not a directory: ${downloadPath}`, "destination");
34
+ }
35
+ }
36
+ catch (error) {
37
+ if (error?.code === "ENOENT") {
38
+ return errorResponse(`Download directory does not exist: ${downloadPath}. Create it, then retry, or choose an existing directory.`, "destination");
39
+ }
40
+ throw error;
41
+ }
42
+ let result;
43
+ try {
44
+ // A client that sent a progress token sees each file as it is handled, and a
45
+ // client that cancels the call (or a deadline) ends the run cleanly.
46
+ const progressToken = extra?._meta?.progressToken;
47
+ result = await syncCourse(apiClient, {
48
+ courseId,
49
+ destination: downloadPath,
50
+ existing,
51
+ moduleId,
52
+ dryRun,
53
+ maxFiles,
54
+ maxTotalBytes: maxTotalMB * 1024 * 1024,
55
+ deadlineMs: maxMinutes * 60_000,
56
+ signal: extra?.signal,
57
+ onProgress: progressToken === undefined || typeof extra?.sendNotification !== "function"
58
+ ? undefined
59
+ : (event) => {
60
+ void extra.sendNotification({
61
+ method: "notifications/progress",
62
+ params: {
63
+ progressToken,
64
+ progress: event.done,
65
+ total: event.total,
66
+ message: `${event.type} ${JSON.stringify(event.path)}`,
67
+ },
68
+ }).catch(() => { });
69
+ },
70
+ });
71
+ }
72
+ catch (error) {
73
+ if (error instanceof SyncInputError)
74
+ return errorResponse(error.message, "invalidInput");
75
+ throw error;
76
+ }
77
+ const response = withUpdateNotice([{ type: "text", text: summarizeSync(result) }]);
78
+ if (result.authError)
79
+ response.isError = true;
80
+ return response;
81
+ }
82
+ catch (error) {
83
+ return sanitizeError(error);
84
+ }
85
+ });
86
+ }
@@ -6,9 +6,10 @@
6
6
  import { ApiError } from "../api/index.js";
7
7
  import { DownloadDropboxSubmissionFileSchema } from "./schemas.js";
8
8
  import { toolResponse, sanitizeError, errorResponse } from "./tool-helpers.js";
9
+ import { sensitiveDestination, sensitiveDestinationMessage } from "../utils/sensitive-paths.js";
9
10
  import { log } from "../utils/logger.js";
10
11
  import { validateContentId, DISK_MAX_FILE_SIZE } from "../utils/file-validator.js";
11
- import { secureStreamDownload } from "../utils/download-helpers.js";
12
+ import { secureStreamDownload, expectedLength } from "../utils/download-helpers.js";
12
13
  import { DownloadError } from "../utils/download-errors.js";
13
14
  import path from "node:path";
14
15
  import fs from "node:fs/promises";
@@ -53,6 +54,10 @@ export function registerDownloadDropboxSubmissionFile(server, apiClient) {
53
54
  }
54
55
  throw error;
55
56
  }
57
+ const protectedReason = await sensitiveDestination(downloadPath);
58
+ if (protectedReason) {
59
+ return errorResponse(sensitiveDestinationMessage(downloadPath, protectedReason), "destination");
60
+ }
56
61
  // Fetch submission metadata to get the file list and validate fileId.
57
62
  const submissionsPath = apiClient.le(courseId, `/dropbox/folders/${folderId}/submissions/`);
58
63
  let targetFile = null;
@@ -119,6 +124,7 @@ export function registerDownloadDropboxSubmissionFile(server, apiClient) {
119
124
  originalFilename,
120
125
  body: response.body,
121
126
  maxBytes: DISK_MAX_FILE_SIZE,
127
+ expectedBytes: expectedLength(response),
122
128
  });
123
129
  log("INFO", `download_dropbox_submission_file: saved ${result.path} (${result.size} bytes, ${result.mime})`);
124
130
  return toolResponse({
@@ -13,8 +13,9 @@ import { checkTopicAvailability } from "./topic-availability.js";
13
13
  // validated anything itself. Inline mode never writes to disk, so it calls
14
14
  // validateFileType directly to enforce the same magic-byte allowlist.
15
15
  import { validateContentId, validateFileType, nameForType, MAX_FILE_SIZE, DISK_MAX_FILE_SIZE, } from "../utils/file-validator.js";
16
- import { secureStreamDownload, readBodyCapped } from "../utils/download-helpers.js";
16
+ import { secureStreamDownload, readBodyCapped, expectedLength } from "../utils/download-helpers.js";
17
17
  import { DownloadError } from "../utils/download-errors.js";
18
+ import { sensitiveDestination, sensitiveDestinationMessage } from "../utils/sensitive-paths.js";
18
19
  import { AUTH_COMMAND } from "../utils/commands.js";
19
20
  import { parseCoursePath, QUICKLINK_PATH } from "../utils/course-files.js";
20
21
  import { extractPdfText } from "../utils/pdf-extractor.js";
@@ -232,6 +233,7 @@ async function finishDownload(response, originalFilename, downloadPath, customFi
232
233
  originalFilename,
233
234
  body: response.body,
234
235
  maxBytes: DISK_MAX_FILE_SIZE,
236
+ expectedBytes: expectedLength(response),
235
237
  });
236
238
  log("INFO", `${sourceLabel} downloaded successfully: ${result.path} (${result.size} bytes, ${result.mime})`);
237
239
  return toolResponse({
@@ -314,6 +316,10 @@ cookieRejectedMessage) {
314
316
  }
315
317
  throw error;
316
318
  }
319
+ const protectedReason = await sensitiveDestination(downloadPath);
320
+ if (protectedReason) {
321
+ return errorResponse(sensitiveDestinationMessage(downloadPath, protectedReason), "destination");
322
+ }
317
323
  }
318
324
  // Determine download source
319
325
  if (coursePath !== undefined) {
@@ -416,26 +422,35 @@ export function parseContentDispositionFilename(disposition) {
416
422
  return null;
417
423
  }
418
424
  /**
419
- * Download a content file using topicId
425
+ * Request a content topic's file. Returns the response, or why the topic is
426
+ * not available: unreleased files can answer 403 or 404, and a release
427
+ * restriction is only explained when the metadata supports it, so every other
428
+ * failure is thrown intact. Shared with the course sync.
420
429
  */
421
- async function downloadContentFile(apiClient, courseId, topicId, downloadPath, customFilename) {
422
- log("INFO", `Downloading content file: courseId=${courseId}, topicId=${topicId}`);
430
+ export async function openContentTopic(apiClient, courseId, topicId, signal) {
423
431
  // Build download URL using D2L API path helper
424
432
  const apiPath = apiClient.le(courseId, `/content/topics/${topicId}/file`);
425
- // Unreleased files can return 404 as well as 403. Only explain a release
426
- // restriction when the metadata supports it; keep other failures intact.
427
- let response;
428
433
  try {
429
- response = await apiClient.getRaw(apiPath);
434
+ return { response: await apiClient.getRaw(apiPath, signal ? { signal } : undefined) };
430
435
  }
431
436
  catch (error) {
432
437
  if (error instanceof ApiError && (error.status === 403 || error.status === 404)) {
433
438
  const unavailable = await checkTopicAvailability(apiClient, courseId, topicId, error.status);
434
439
  if (unavailable)
435
- return toolResponse(unavailable);
440
+ return { unavailable };
436
441
  }
437
442
  throw error;
438
443
  }
444
+ }
445
+ /**
446
+ * Download a content file using topicId
447
+ */
448
+ async function downloadContentFile(apiClient, courseId, topicId, downloadPath, customFilename) {
449
+ log("INFO", `Downloading content file: courseId=${courseId}, topicId=${topicId}`);
450
+ const opened = await openContentTopic(apiClient, courseId, topicId);
451
+ if ("unavailable" in opened)
452
+ return toolResponse(opened.unavailable);
453
+ const { response } = opened;
439
454
  // Get filename from Content-Disposition header
440
455
  const disposition = response.headers.get("Content-Disposition") ?? "";
441
456
  const filename = parseContentDispositionFilename(disposition) ?? "download";
@@ -170,7 +170,9 @@ export async function buildContentTree(apiClient, courseId, modules, progressMap
170
170
  if (typeFilter !== 'all' && !matchesTypeFilter(item, typeFilter)) {
171
171
  continue;
172
172
  }
173
- const topicProgress = progressMap.get(item.Id);
173
+ // A null map means progress couldn't be read: completion is unknown,
174
+ // not false, so a failed read can't pass for "nothing completed".
175
+ const topicProgress = progressMap?.get(item.Id);
174
176
  const topic = {
175
177
  type: 'topic',
176
178
  topicType,
@@ -181,7 +183,7 @@ export async function buildContentTree(apiClient, courseId, modules, progressMap
181
183
  ...availabilityFields(availability),
182
184
  ...(item.DueDate ? { dueDate: item.DueDate } : {}),
183
185
  lastModified: item.LastModifiedDate ?? null,
184
- isCompleted: topicProgress?.IsRead ?? false,
186
+ isCompleted: progressMap === null ? null : topicProgress?.IsRead ?? false,
185
187
  ...(topicProgress?.DateCompleted ? { completedDate: topicProgress.DateCompleted } : {}),
186
188
  };
187
189
  // Add type-specific content
@@ -194,6 +196,12 @@ export async function buildContentTree(apiClient, courseId, modules, progressMap
194
196
  // Link topic — include URL
195
197
  topic.url = item.Url ?? null;
196
198
  }
199
+ // An embedded video (D2L's ContentService type) has no file behind it.
200
+ // Said outright so it is not taken for a file that is missing.
201
+ if (typeof item.TypeIdentifier === 'string' && item.TypeIdentifier.toLowerCase() === 'contentservice') {
202
+ topic.embeddedMedia = true;
203
+ topic.note = 'Embedded media: download_file cannot fetch it. Open it in Brightspace, or use get_video_transcript for its transcript.';
204
+ }
197
205
  // HTML content — include body converted to markdown
198
206
  if (item.Description?.Html) {
199
207
  topic.content = convertHtmlToMarkdown(item.Description.Html).markdown;
@@ -301,22 +309,25 @@ export function registerGetCourseContent(server, apiClient) {
301
309
  const searchTerm = moduleTitle.toLowerCase();
302
310
  rootModules = rootModules.filter(m => m.Title.toLowerCase().includes(searchTerm));
303
311
  }
304
- // Fetch user progress for the course (graceful degradation)
305
- let progressArray = [];
312
+ // Fetch user progress for the course (graceful degradation). 403/404
313
+ // means this course has no progress data for the user. Any other
314
+ // failure leaves completion unknown, so the map stays null and every
315
+ // topic reports isCompleted: null rather than false.
316
+ let progressMap = new Map();
306
317
  try {
307
- progressArray = await apiClient.get(apiClient.le(courseId, '/content/userprogress/'), { ttl: DEFAULT_CACHE_TTLS.courseContent });
318
+ const progressArray = await apiClient.get(apiClient.le(courseId, '/content/userprogress/'), { ttl: DEFAULT_CACHE_TTLS.courseContent });
319
+ for (const p of Array.isArray(progressArray) ? progressArray : []) {
320
+ progressMap.set(p.ContentObjectId, p);
321
+ }
308
322
  }
309
323
  catch (error) {
310
- // 404/403 means no progress data available - not an error
324
+ if (isAuthUnavailable(error))
325
+ throw error;
311
326
  if (error?.status !== 404 && error?.status !== 403) {
312
327
  log('DEBUG', `Failed to fetch progress for course ${courseId}`, error);
328
+ progressMap = null;
313
329
  }
314
330
  }
315
- // Build progress map for O(1) lookups
316
- const progressMap = new Map();
317
- for (const p of progressArray) {
318
- progressMap.set(p.ContentObjectId, p);
319
- }
320
331
  // Recursively build content tree
321
332
  let contentTree = await buildContentTree(apiClient, courseId, rootModules, progressMap, typeFilter, maxDepth);
322
333
  // Apply modifiedSince filter, if requested, after the tree is built:
@@ -340,6 +351,12 @@ export function registerGetCourseContent(server, apiClient) {
340
351
  topicCount,
341
352
  moduleCount,
342
353
  ...(modifiedSince ? { modifiedSince, returned: topicCount, filteredOut } : {}),
354
+ ...(progressMap === null
355
+ ? {
356
+ progressUnavailable: "Couldn't read this user's completion progress, so every topic has isCompleted null. " +
357
+ "Do not report topics as not completed; try again later.",
358
+ }
359
+ : {}),
343
360
  ...(unreadable > 0
344
361
  ? {
345
362
  notice: `The contents of ${unreadable} module(s) couldn't be read; they have children null and a ` +
@@ -13,6 +13,7 @@ import { DownloadError } from "../utils/download-errors.js";
13
13
  import { MAX_FILE_SIZE } from "../utils/file-validator.js";
14
14
  import { extractPdfText } from "../utils/pdf-extractor.js";
15
15
  import { parseContentDispositionFilename } from "./download-file.js";
16
+ import { sensitiveDestination, sensitiveDestinationMessage } from "../utils/sensitive-paths.js";
16
17
  import { log } from "../utils/logger.js";
17
18
  import path from "node:path";
18
19
  import fs from "node:fs/promises";
@@ -137,6 +138,10 @@ export function registerGetSyllabus(server, apiClient) {
137
138
  }
138
139
  throw error;
139
140
  }
141
+ const protectedReason = await sensitiveDestination(downloadPath);
142
+ if (protectedReason) {
143
+ return errorResponse(sensitiveDestinationMessage(downloadPath, protectedReason), "destination");
144
+ }
140
145
  }
141
146
  // Fetch overview text
142
147
  let overview = null;
@@ -15,6 +15,7 @@ export { registerGetAssignmentFiles } from "./get-assignment-files.js";
15
15
  export { registerGetAnnouncementFiles } from "./get-announcement-files.js";
16
16
  export { registerGetCourseContent } from "./get-course-content.js";
17
17
  export { registerDownloadFile, COURSE_FILE_NEEDS_COOKIE } from "./download-file.js";
18
+ export { registerDownloadCourseFiles } from "./download-course-files.js";
18
19
  export { registerGetClasslistEmails } from "./get-classlist-emails.js";
19
20
  export { registerGetRoster } from "./get-roster.js";
20
21
  export { registerGetMyGroups } from "./get-my-groups.js";
@@ -82,6 +82,24 @@ export const DownloadFileSchema = z.object({
82
82
  customFilename: z.string().max(255).optional()
83
83
  .describe("Custom filename for the downloaded file (include extension). If not provided, uses the original filename from Brightspace."),
84
84
  });
85
+ export const DownloadCourseFilesSchema = z.object({
86
+ courseId: z.coerce.number().int().positive()
87
+ .describe("Course ID to download every file from."),
88
+ downloadPath: z.string().min(1)
89
+ .describe("Absolute path to an existing directory on the HOST filesystem where the course is saved. The directory must already exist; it is not created for you, so create it first if needed. Folders named after the course's modules are created inside it. Ask the user where to save the course before calling this; never guess a directory."),
90
+ existing: z.enum(["skip", "replace", "keep_both"]).default("skip")
91
+ .describe("What to do with a file that is already on disk. skip (default): leave it alone, so running this again only fetches what is new and never overwrites the user's annotated copies. replace: download it again over the old one. keep_both: save the new copy as 'name (1).ext' beside it, unless a copy of the same size is already there."),
92
+ moduleId: z.coerce.number().int().positive().optional()
93
+ .describe("Only download this module and the modules inside it (a module id from get_course_content). Omit to download the whole course."),
94
+ dryRun: z.boolean().default(false)
95
+ .describe("List the files that would be downloaded without downloading anything or creating any folder. Use it first on a large course."),
96
+ maxFiles: z.coerce.number().int().min(1).max(5000).default(300)
97
+ .describe("Most files to download in one call (default 300). Files already on disk do not count. The rest are reported as not attempted: call again to continue, since existing files are skipped."),
98
+ maxMinutes: z.coerce.number().int().min(1).max(120).default(8)
99
+ .describe("Wall-clock limit for this call in minutes (default 8). When it passes, the call stops cleanly and reports the rest as not attempted; call it again to continue, since files already on disk are skipped. Raise it only if the client allows long tool calls."),
100
+ maxTotalMB: z.coerce.number().int().min(1).max(1048576).default(5120)
101
+ .describe("Most megabytes to download in one call (default 5120, i.e. 5 GB). The rest is reported as not attempted; call again to continue."),
102
+ });
85
103
  export const GetSyllabusSchema = z.object({
86
104
  courseId: z.coerce.number().int().positive()
87
105
  .describe("Course ID to get syllabus for."),
@@ -240,6 +240,11 @@ const DOWNLOAD_FAILURE_GUIDANCE = {
240
240
  "directory and was refused. Pass customFilename to choose one yourself.",
241
241
  tooLarge: "The file is larger than the download size limit. " +
242
242
  "Open it from Brightspace in a browser instead.",
243
+ incomplete: "Download incomplete: the connection closed before the whole file arrived, so nothing was saved. " +
244
+ "Try again.",
245
+ empty: "The file is empty (0 bytes) on Brightspace, so nothing was saved.",
246
+ forbiddenDestination: "That folder is not an allowed place to save files: it is the home folder itself, a drive root, a hidden folder, a system folder or an application-data folder. " +
247
+ "Choose a folder such as ~/Documents/Brightspace, or have the person who runs the server add the folder to D2L_DOWNLOAD_ROOTS.",
243
248
  };
244
249
  /**
245
250
  * True when an error means the session itself is unusable, not that one
@@ -12,6 +12,16 @@ import { pipeline } from "node:stream/promises";
12
12
  import { validateDownloadPath, validateFileType, validateFileTypeOfFile, nameForType, MAX_FILE_SIZE, } from "./file-validator.js";
13
13
  import { DownloadError } from "./download-errors.js";
14
14
  import { log } from "./logger.js";
15
+ import { sensitiveDestination } from "./sensitive-paths.js";
16
+ /**
17
+ * Every write to disk starts here, so the protected-destination rule is
18
+ * enforced once for all tools (see sensitiveDestination).
19
+ */
20
+ async function assertAllowedDestination(targetDir) {
21
+ if (await sensitiveDestination(targetDir)) {
22
+ throw new DownloadError("forbiddenDestination", "Destination folder is not allowed");
23
+ }
24
+ }
15
25
  /**
16
26
  * Resolve filename conflicts by appending (1), (2), etc.
17
27
  *
@@ -57,6 +67,7 @@ export async function resolveFilenameConflict(dir, filename) {
57
67
  export async function secureDownload(options) {
58
68
  const { targetDir, filename, data, allowedTypes } = options;
59
69
  log("DEBUG", `secureDownload: starting download of ${filename} to ${targetDir}`);
70
+ await assertAllowedDestination(targetDir);
60
71
  // Validate target directory exists and is a directory
61
72
  try {
62
73
  const stats = await fs.stat(targetDir);
@@ -102,6 +113,14 @@ export async function secureDownload(options) {
102
113
  mime,
103
114
  };
104
115
  }
116
+ /** A planned name, checked to be exactly one safe path component and nothing else. */
117
+ function verbatimComponent(targetDir, name) {
118
+ const unsafe = name === "" || name === "." || name === ".." || /[/\\\u0000]/.test(name) || path.basename(name) !== name || Buffer.byteLength(name) > 255;
119
+ const rel = path.relative(path.resolve(targetDir), path.resolve(targetDir, name));
120
+ if (unsafe || rel !== name)
121
+ throw new DownloadError("badFilename", "Invalid filename");
122
+ return name;
123
+ }
105
124
  /**
106
125
  * secureDownload for a response body that is streamed to disk rather than
107
126
  * held in memory, so a file's size is bounded by `maxBytes` instead of by
@@ -109,11 +128,18 @@ export async function secureDownload(options) {
109
128
  * type-checked there, and only then published under its final name; a refused or
110
129
  * failed download leaves nothing behind.
111
130
  *
131
+ * With `expectedBytes` (the response's Content-Length, see expectedLength), a
132
+ * body that ends short of it fails and leaves nothing behind: a dropped
133
+ * connection must not publish a truncated file that a later sync would take
134
+ * for the whole one.
135
+ *
112
136
  * @throws DownloadError("tooLarge") once the body passes maxBytes
137
+ * @throws DownloadError("incomplete") when fewer than expectedBytes arrive
113
138
  */
114
139
  export async function secureStreamDownload(options) {
115
- const { targetDir, filename, originalFilename, body, maxBytes } = options;
116
- const safeFilename = path.basename(validateDownloadPath(targetDir, filename));
140
+ const { targetDir, filename, originalFilename, body, maxBytes, expectedBytes, replaceExisting, replaceGuard, signal, verbatimName } = options;
141
+ await assertAllowedDestination(targetDir);
142
+ const safeFilename = verbatimName ? verbatimComponent(targetDir, filename) : path.basename(validateDownloadPath(targetDir, filename));
117
143
  const partialPath = path.join(targetDir, `.download-${randomUUID()}.part`);
118
144
  let size = 0;
119
145
  const limit = new Transform({
@@ -127,11 +153,34 @@ export async function secureStreamDownload(options) {
127
153
  }
128
154
  },
129
155
  });
156
+ const incomplete = () => new DownloadError("incomplete", `Download incomplete: ${size} of ${expectedBytes} bytes arrived`);
130
157
  try {
131
- await pipeline(Readable.fromWeb(body), limit, createWriteStream(partialPath, { flags: "wx" }));
158
+ try {
159
+ await pipeline(Readable.fromWeb(body), limit, createWriteStream(partialPath, { flags: "wx" }), ...(signal ? [{ signal }] : []));
160
+ }
161
+ catch (error) {
162
+ // fetch reports a connection cut mid-body as a bare "terminated".
163
+ if (expectedBytes !== undefined && size < expectedBytes && !(error instanceof DownloadError)) {
164
+ throw incomplete();
165
+ }
166
+ throw error;
167
+ }
168
+ if (expectedBytes !== undefined && size < expectedBytes)
169
+ throw incomplete();
170
+ if (size === 0)
171
+ throw new DownloadError("empty", "File is empty (0 bytes)");
132
172
  const type = await validateFileTypeOfFile(partialPath, undefined, safeFilename, originalFilename);
133
173
  const { mime } = type;
134
- const finalPath = await publishExclusively(partialPath, targetDir, nameForType(safeFilename, type));
174
+ const finalName = nameForType(safeFilename, type);
175
+ let finalPath;
176
+ const replaceTarget = path.join(targetDir, finalName);
177
+ if (replaceExisting && finalName === safeFilename && (!replaceGuard || (await replaceGuard(replaceTarget)))) {
178
+ finalPath = replaceTarget;
179
+ await fs.rename(partialPath, finalPath);
180
+ }
181
+ else {
182
+ finalPath = await publishExclusively(partialPath, targetDir, finalName);
183
+ }
135
184
  log("INFO", `Downloaded file to ${finalPath} (${size} bytes, ${mime})`);
136
185
  return { path: finalPath, size, mime };
137
186
  }
@@ -139,6 +188,20 @@ export async function secureStreamDownload(options) {
139
188
  await fs.rm(partialPath, { force: true });
140
189
  }
141
190
  }
191
+ /**
192
+ * The byte count a response promises, or undefined when it promises none we
193
+ * can hold it to: no Content-Length, or a Content-Encoding, where fetch hands
194
+ * back the decoded body and the header counts the encoded one.
195
+ */
196
+ export function expectedLength(response) {
197
+ const encoding = (response.headers.get("Content-Encoding") ?? "").trim().toLowerCase();
198
+ if (encoding && encoding !== "identity")
199
+ return undefined;
200
+ const header = response.headers.get("Content-Length");
201
+ if (header === null || !/^\d+$/.test(header.trim()))
202
+ return undefined;
203
+ return Number(header);
204
+ }
142
205
  /**
143
206
  * Give a finished temporary file its final name without ever replacing an
144
207
  * existing file. resolveFilenameConflict only reports a name that was free a