Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions packages/core/src/archive/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,18 @@ export async function extractArchive(archivePath: string, options: ExtractOption
}
}

/**
* Check whether a file exists using async FS operations.
*/
async function fileExists(filePath: string): Promise<boolean> {
try {
await fs.promises.access(filePath);
return true;
} catch {
return false;
}
}

/** Maximum recursion depth for findExtensionRoot to prevent stack overflow on crafted archives. */
const MAX_FIND_DEPTH = 5;

Expand All @@ -112,7 +124,7 @@ export async function findExtensionRoot(extractDir: string, depth = 0): Promise<

for (const name of MANIFEST_FILENAMES) {
const directPath = path.join(extractDir, name);
if (fs.existsSync(directPath)) {
if (await fileExists(directPath)) {
return extractDir;
}
}
Expand All @@ -125,7 +137,7 @@ export async function findExtensionRoot(extractDir: string, depth = 0): Promise<

for (const name of MANIFEST_FILENAMES) {
const manifestPath = path.join(dirPath, name);
if (fs.existsSync(manifestPath)) {
if (await fileExists(manifestPath)) {
return dirPath;
}
}
Expand Down Expand Up @@ -196,7 +208,7 @@ export async function findAllExtensionRoots(extractDir: string): Promise<Discove
// Check for manifest in current directory
for (const name of MANIFEST_FILENAMES) {
const manifestPath = path.join(dir, name);
if (fs.existsSync(manifestPath)) {
if (await fileExists(manifestPath)) {
// Found an extension, derive its ID from path
const id = deriveExtensionIdFromPath(dir, extractDir);
const relativePath = path.relative(extractDir, dir);
Expand Down
7 changes: 6 additions & 1 deletion packages/core/src/archive/security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,12 @@ export const MAX_FILE_COUNT = 10_000;
export function checkPathTraversal(filePath: string): void {
const normalised = path.normalize(filePath);

if (normalised.includes("..") || path.isAbsolute(normalised)) {
if (path.isAbsolute(normalised)) {
throw new SecurityError(`Path traversal detected in archive: "${filePath}"`);
}

const segments = normalised.split(path.sep);
if (segments.some((segment) => segment === "..")) {
throw new SecurityError(`Path traversal detected in archive: "${filePath}"`);
}
}
Expand Down
14 changes: 7 additions & 7 deletions src/commands/installQuartoExtension.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as vscode from "vscode";
import { STORAGE_KEY_RECENTLY_INSTALLED, STORAGE_KEY_RECENTLY_USED } from "../constants";
import { getShowLogsLink, logMessage } from "../utils/log";
import { getShowLogsLink, logMessage, showMessageWithLogs } from "../utils/log";
import { checkInternetConnection } from "../utils/network";
import { installQuartoExtension, useQuartoExtension } from "../utils/quarto";
import {
Expand Down Expand Up @@ -66,7 +66,7 @@ async function installQuartoExtensions(
}
const message = "Operation cancelled by the user.";
logMessage(message, "info");
vscode.window.showInformationMessage(`${message} ${getShowLogsLink()}.`);
showMessageWithLogs(message, "info");
});

const installedExtensions: string[] = [];
Expand Down Expand Up @@ -166,7 +166,7 @@ async function installQuartoExtensions(
failedExtensions.length > 1 ? "them" : "it",
` manually with \`quarto ${template ? "use" : "add"} <extension>\`:`,
].join("");
vscode.window.showErrorMessage(`${message} ${failedExtensions.join(", ")}. ${getShowLogsLink()}.`);
showMessageWithLogs(`${message} ${failedExtensions.join(", ")}.`, "error");
} else if (installedCount > 0) {
// Only show success message if at least one extension was processed
const message = [
Expand All @@ -176,7 +176,7 @@ async function installQuartoExtensions(
` ${actionPast} successfully.`,
].join("");
logMessage(message, "info");
vscode.window.showInformationMessage(`${message} ${getShowLogsLink()}.`);
showMessageWithLogs(message, "info");
}
// If installedCount === 0 and failedExtensions.length === 0, the operation was cancelled - no message needed
completed = true;
Expand Down Expand Up @@ -311,7 +311,7 @@ async function installFromSource(
token.onCancellationRequested(() => {
const message = "Operation cancelled by the user.";
logMessage(message, "info");
vscode.window.showInformationMessage(`${message} ${getShowLogsLink()}.`);
showMessageWithLogs(message, "info");
});

// Check if already cancelled before starting
Expand Down Expand Up @@ -350,13 +350,13 @@ async function installFromSource(
if (result === true) {
const message = template ? "Template used successfully." : "Extension installed successfully.";
logMessage(message, "info");
vscode.window.showInformationMessage(`${message} ${getShowLogsLink()}.`);
showMessageWithLogs(message, "info");
} else if (result === false) {
// Only show error message for actual failures, not cancellations
const message = template
? `Failed to use template from ${source}.`
: `Failed to install extension from ${source}.`;
vscode.window.showErrorMessage(`${message} ${getShowLogsLink()}.`);
showMessageWithLogs(message, "error");
}
// result === null means cancelled by user, no message needed
},
Expand Down
4 changes: 2 additions & 2 deletions src/commands/newQuartoReprex.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as vscode from "vscode";
import { getShowLogsLink, logMessage } from "../utils/log";
import { logMessage, showMessageWithLogs } from "../utils/log";
import { newQuartoReprex } from "../utils/reprex";

/**
Expand All @@ -20,6 +20,6 @@ export async function newQuartoReprexCommand(context: vscode.ExtensionContext) {
} else {
const message = `No computing language selected. Aborting.`;
logMessage(message, "error");
vscode.window.showErrorMessage(`${message} ${getShowLogsLink()}.`);
showMessageWithLogs(message, "error");
}
}
7 changes: 3 additions & 4 deletions src/commands/useBrand.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as vscode from "vscode";
import { getShowLogsLink, logMessage } from "../utils/log";
import { getShowLogsLink, logMessage, showMessageWithLogs } from "../utils/log";
import { checkInternetConnection } from "../utils/network";
import { useQuartoBrand } from "../utils/quarto";
import { confirmTrustAuthors, confirmInstall } from "../utils/ask";
Expand Down Expand Up @@ -61,13 +61,12 @@ async function useBrandFromSource(
message = `Brand applied successfully (${totalFiles} file(s) in _brand/).`;
}
logMessage(message, "info");
vscode.window.showInformationMessage(`${message} ${getShowLogsLink()}.`);
showMessageWithLogs(message, "info");
} else if (!token.isCancellationRequested) {
// result === null without cancellation can mean either an actual error or
// the user declining authentication. Show a neutral message and point to
// logs where the specific reason is recorded.
const message = `Brand operation did not complete. See ${getShowLogsLink()} for details.`;
vscode.window.showWarningMessage(message);
showMessageWithLogs("Brand operation did not complete. See logs for details.", "warning");
}
},
);
Expand Down
10 changes: 4 additions & 6 deletions src/extension.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as vscode from "vscode";
import { QW_LOG, STORAGE_KEY_RECENTLY_INSTALLED, STORAGE_KEY_RECENTLY_USED } from "./constants";
import { getShowLogsLink, logMessage } from "./utils/log";
import { logMessage, showMessageWithLogs } from "./utils/log";
import {
installQuartoExtensionCommand,
useQuartoTemplateCommand,
Expand Down Expand Up @@ -40,7 +40,7 @@ export function activate(context: vscode.ExtensionContext) {
context.globalState.update(STORAGE_KEY_RECENTLY_USED, []);
const message = "Recently installed Quarto extensions have been cleared.";
logMessage(message, "info");
vscode.window.showInformationMessage(`${message} ${getShowLogsLink()}.`);
showMessageWithLogs(message, "info");
}),
);

Expand Down Expand Up @@ -87,7 +87,7 @@ export function activate(context: vscode.ExtensionContext) {
});
if (token) {
await setManualToken(context, token);
vscode.window.showInformationMessage(`GitHub token stored securely. ${getShowLogsLink()}.`);
showMessageWithLogs("GitHub token stored securely.", "info");
}
}),
);
Expand All @@ -96,9 +96,7 @@ export function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(
vscode.commands.registerCommand("quartoWizard.clearGitHubToken", async () => {
await clearManualToken(context);
vscode.window.showInformationMessage(
`Manual token cleared. Will use VSCode GitHub session or environment variables. ${getShowLogsLink()}.`,
);
showMessageWithLogs("Manual token cleared. Will use VSCode GitHub session or environment variables.", "info");
}),
);

Expand Down
2 changes: 0 additions & 2 deletions src/test/suite/network.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,6 @@ suite("Network Utils Test Suite", () => {
assert.ok(logMessages[0].includes("No internet connection"));
assert.strictEqual(errorMessages.length, 1);
assert.ok(errorMessages[0].includes("No internet connection"));
assert.ok(errorMessages[0].includes("Show logs"));
});

test("should return false when fetch throws an error", async () => {
Expand All @@ -105,7 +104,6 @@ suite("Network Utils Test Suite", () => {
assert.ok(logMessages[0].includes("No internet connection"));
assert.strictEqual(errorMessages.length, 1);
assert.ok(errorMessages[0].includes("No internet connection"));
assert.ok(errorMessages[0].includes("Show logs"));
});

test("should use custom URL when provided", async () => {
Expand Down
4 changes: 2 additions & 2 deletions src/ui/extensionTreeItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,8 @@ export class ExtensionTreeItem extends vscode.TreeItem {
this.iconPath = new vscode.ThemeIcon(icon);
}

// Format version for installation commands
this.latestVersion = latestVersion !== "unknown" ? `@${latestVersion}` : "";
// Store the clean version string (without '@' prefix) for display and commands.
this.latestVersion = latestVersion !== "unknown" ? latestVersion : "";
this.workspaceFolder = workspacePath;

// Store repository for update commands
Expand Down
34 changes: 14 additions & 20 deletions src/ui/extensionsInstalled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as vscode from "vscode";
import * as path from "node:path";
import { normaliseVersion } from "@quarto-wizard/core";
import type { SchemaCache } from "@quarto-wizard/core";
import { logMessage, getShowLogsLink } from "../utils/log";
import { logMessage, showMessageWithLogs } from "../utils/log";
import { removeQuartoExtension, removeQuartoExtensions, installQuartoExtension } from "../utils/quarto";
import { withProgressNotification } from "../utils/withProgressNotification";
import { installQuartoExtensionFolderCommand } from "../commands/installQuartoExtension";
Expand Down Expand Up @@ -103,15 +103,16 @@ export class ExtensionsInstalled {
*/
context.subscriptions.push(
vscode.commands.registerCommand("quartoWizard.extensionsInstalled.update", async (item: ExtensionTreeItem) => {
const latestVersion = item.latestVersion?.replace(/^@/, "");
const latestVersion = item.latestVersion;
const latestSemver = latestVersion ? (normaliseVersion(latestVersion) ?? latestVersion) : undefined;
const auth = await getAuthConfig(context);
// result is true (success), false (failure), or null (cancelled)
const result = await withProgressNotification(
`Updating "${item.repository ?? item.label}" to ${latestSemver} ...`,
async (token) => {
const versionSuffix = latestVersion ? `@${latestVersion}` : "";
return installQuartoExtension(
`${item.repository ?? item.label}${item.latestVersion}`,
`${item.repository ?? item.label}${versionSuffix}`,
item.workspaceFolder,
auth,
undefined,
Expand All @@ -126,13 +127,12 @@ export class ExtensionsInstalled {
} else if (result === false) {
// Only show error for actual failures, not cancellations
if (!item.repository) {
vscode.window.showErrorMessage(
`Failed to update extension "${item.label}". ` +
`Source not found in extension manifest. ` +
`${getShowLogsLink()}.`,
showMessageWithLogs(
`Failed to update extension "${item.label}". Source not found in extension manifest.`,
"error",
);
} else {
vscode.window.showErrorMessage(`Failed to update extension "${item.label}". ${getShowLogsLink()}.`);
showMessageWithLogs(`Failed to update extension "${item.label}".`, "error");
}
}
// result === null means cancelled by user, no message needed
Expand All @@ -152,7 +152,7 @@ export class ExtensionsInstalled {
vscode.window.showInformationMessage(`Extension "${item.label}" removed successfully.`);
this.treeDataProvider.refreshAfterAction(context, view);
} else {
vscode.window.showErrorMessage(`Failed to remove extension "${item.label}". ${getShowLogsLink()}.`);
showMessageWithLogs(`Failed to remove extension "${item.label}".`, "error");
}
}),
);
Expand All @@ -167,9 +167,7 @@ export class ExtensionsInstalled {
// Early return if resourceUri is not available
if (!item.resourceUri) {
logMessage(`Cannot reveal "${item.label}": resource URI not available.`, "warn");
vscode.window.showWarningMessage(
`Cannot reveal extension "${item.label}" in Explorer. ${getShowLogsLink()}.`,
);
showMessageWithLogs(`Cannot reveal extension "${item.label}" in Explorer.`, "warning");
return;
}

Expand All @@ -178,9 +176,7 @@ export class ExtensionsInstalled {
await vscode.workspace.fs.stat(item.resourceUri);
} catch {
logMessage(`Extension directory not found: ${item.resourceUri.fsPath}.`, "warn");
vscode.window.showWarningMessage(
`Extension directory for "${item.label}" not found. ${getShowLogsLink()}.`,
);
showMessageWithLogs(`Extension directory for "${item.label}" not found.`, "warning");
return;
}

Expand Down Expand Up @@ -212,9 +208,7 @@ export class ExtensionsInstalled {
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logMessage(`Failed to reveal "${item.label}" in Explorer: ${errorMessage}`, "error");
vscode.window.showErrorMessage(
`Failed to reveal extension "${item.label}" in Explorer. ${getShowLogsLink()}.`,
);
showMessageWithLogs(`Failed to reveal extension "${item.label}" in Explorer.`, "error");
}
},
),
Expand Down Expand Up @@ -280,7 +274,7 @@ export class ExtensionsInstalled {
`Successfully updated ${successCount} extension(s)${failedCount > 0 ? `, ${failedCount} failed` : ""}.`,
);
} else {
vscode.window.showErrorMessage(`Failed to update extensions. ${getShowLogsLink()}.`);
showMessageWithLogs("Failed to update extensions.", "error");
}

this.treeDataProvider.refreshAfterAction(context, view);
Expand Down Expand Up @@ -336,7 +330,7 @@ export class ExtensionsInstalled {
`Successfully removed ${result.successCount} extension(s)${result.failedExtensions.length > 0 ? `, ${result.failedExtensions.length} failed` : ""}.`,
);
} else {
vscode.window.showErrorMessage(`Failed to remove extensions. ${getShowLogsLink()}.`);
showMessageWithLogs("Failed to remove extensions.", "error");
}

this.treeDataProvider.refreshAfterAction(context, view);
Expand Down
4 changes: 2 additions & 2 deletions src/utils/ask.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as vscode from "vscode";
import { minimatch } from "minimatch";
import { getShowLogsLink, logMessage } from "../utils/log";
import { logMessage, showMessageWithLogs } from "../utils/log";
import type { FileSelectionResult } from "@quarto-wizard/core";

/**
Expand Down Expand Up @@ -53,7 +53,7 @@ function createConfirmationDialog(config: ConfirmationDialogConfig): () => Promi
return true;
} else if (result?.label !== "Yes") {
logMessage(config.cancelMessage, "info");
vscode.window.showInformationMessage(`${config.cancelMessage} ${getShowLogsLink()}.`);
showMessageWithLogs(config.cancelMessage, "info");
return false;
}
}
Expand Down
4 changes: 2 additions & 2 deletions src/utils/extensionDetails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
STORAGE_KEY_RECENTLY_INSTALLED,
STORAGE_KEY_RECENTLY_USED,
} from "../constants";
import { logMessage, logMessageDebounced, getShowLogsLink } from "./log";
import { logMessage, logMessageDebounced, showMessageWithLogs } from "./log";
import { generateHashKey } from "./hash";

/**
Expand Down Expand Up @@ -145,5 +145,5 @@ export async function clearExtensionsCache(context: vscode.ExtensionContext): Pr

const message = "Extension cache and recent lists cleared successfully.";
logMessage(message, "info");
vscode.window.showInformationMessage(`${message} ${getShowLogsLink()}.`);
showMessageWithLogs(message, "info");
}
4 changes: 2 additions & 2 deletions src/utils/handleUri.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as vscode from "vscode";
import type { AuthConfig } from "@quarto-wizard/core";
import { installQuartoExtension, useQuartoExtension } from "./quarto";
import { getShowLogsLink, logMessage } from "../utils/log";
import { logMessage, showMessageWithLogs } from "../utils/log";
import { selectWorkspaceFolder } from "../utils/workspace";
import { withProgressNotification } from "../utils/withProgressNotification";
import { createFileSelectionCallback, createTargetSubdirCallback } from "../utils/ask";
Expand Down Expand Up @@ -60,7 +60,7 @@ async function handleUriAction(
if (confirmed === "No") {
const message = "Operation cancelled by the user.";
logMessage(message, "info");
vscode.window.showInformationMessage(`${message} ${getShowLogsLink()}.`);
showMessageWithLogs(message, "info");
return;
}

Expand Down
2 changes: 1 addition & 1 deletion src/utils/hash.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import * as crypto from "crypto";
import * as crypto from "node:crypto";

/**
* Generates a hash key for a given string.
Expand Down
Loading