Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
f1466e5
fix(security): reject symlinks in TAR and add depth limit to findAllE…
mcanouil Feb 22, 2026
2e5034a
refactor: consolidate error message extraction into getErrorMessage
mcanouil Feb 22, 2026
989f7c6
fix: cancel debounced validation on close and warn for array extensio…
mcanouil Feb 22, 2026
85995a8
refactor: extract timeout constants, improve error messages, expand t…
mcanouil Feb 22, 2026
01c975e
fix: set registry source-type correctly and update context menu actions
mcanouil Feb 22, 2026
60a94c9
fix: preserve sourceType during updates and remove pinned concept
mcanouil Feb 22, 2026
a39c849
fix: add registry cache integrity validation
mcanouil Feb 22, 2026
2198d34
fix: improve file selection handling in quick pick
mcanouil Feb 22, 2026
23329c4
chore: add changelog entry
mcanouil Feb 22, 2026
47427a9
fix: validate GitHub reference format in source prompt input
mcanouil Feb 22, 2026
a99cf9d
fix: add concurrency guard to update check
mcanouil Feb 22, 2026
e20ed61
fix: include error details in revealInExplorer log message
mcanouil Feb 22, 2026
78cc23a
fix: prevent spurious cancellation message after completed install
mcanouil Feb 22, 2026
2c3785e
chore: add changelog entries for code review fixes
mcanouil Feb 22, 2026
6895ac6
chore: drop internal changes
mcanouil Feb 22, 2026
8657d25
fix: use deferred error pattern in tar extraction
mcanouil Feb 22, 2026
ae48348
fix: track additional extension install failures separately
mcanouil Feb 22, 2026
17bb6e2
fix: make retry backoff cancellable via AbortSignal
mcanouil Feb 22, 2026
c0f0415
fix: cross-platform path detection and internet check consolidation
mcanouil Feb 22, 2026
b37e5ca
refactor: centralise workspace schema index
mcanouil Feb 22, 2026
d2713b1
fix: exclude fenced code blocks from schema providers
mcanouil Feb 22, 2026
8e4b556
chore: update changelog for security and provider improvements
mcanouil Feb 22, 2026
761763c
chore: drop entries
mcanouil Feb 22, 2026
6ebdfbd
fix: remove undeclared @quarto-wizard/core dependency from schema pac…
mcanouil Feb 22, 2026
ff5e3fe
fix: remove remaining @quarto-wizard/core imports from schema and sni…
mcanouil Feb 22, 2026
d92ff55
refactor: consolidate caches, options objects, and diagnostics guards
mcanouil Feb 22, 2026
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
14 changes: 12 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,21 @@
- fix: harden network and security handling for stream backpressure failures and path traversal checks.
- fix: improve schema provider robustness with better cache sharing, merge consistency, type handling, and completion ranking.
- fix: store file system watcher event disposables to prevent resource leaks.
- fix: prevent directory deselection in the template file picker by updating selection state directly instead of rebuilding items.
- fix: validate GitHub reference format in source prompt input to reject malformed values such as `///` or `a/b/c/d`.
- fix: add concurrency guard to update check to prevent concurrent calls from corrupting version data.
- fix: include error details in the "reveal in Explorer" log message for better diagnostics.
- fix: prevent spurious cancellation message after a completed single-source install.
- fix: use deferred error pattern in tar extraction to prevent unhandled rejections, and reject hard links alongside symbolic links.
- fix: track additional extension install failures separately so partial installs report success for the primary extension.
- fix: make retry backoff cancellable via AbortSignal so users can cancel during retry delays.
- fix: detect cross-platform absolute paths (Windows drive letters, UNC paths) in source prompts.
- fix: skip internet connectivity check for local extension installs.
- fix: prevent closing a document tab from cancelling pending diagnostics for other open documents.
- fix: prevent stale YAML diagnostics from overwriting fresh validation results during rapid edits.

### Refactoring

- refactor: simplify tree view context values from 8 to 4 and replace regex `when` clauses with exact matches.
- refactor: consolidate four per-source-type "Open Source" commands into a single command that branches on source type.
- refactor: extract schema types, parsing, validation, and caching into a dedicated `@quarto-wizard/schema` package for better separation of concerns.
- refactor: consolidate shared utilities, deduplicate error handling, and harden internal validation across extension and core packages.

Expand Down
30 changes: 0 additions & 30 deletions docs/api/core/archive.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ ZIP and TAR extractors.
### Functions

- [checkPathTraversal](archive.qmd#symbol-function-checkpathtraversal)
- [checkSymlinkTarget](archive.qmd#symbol-function-checksymlinktarget)
- [formatSize](archive.qmd#symbol-function-formatsize)
- [validateUrlProtocol](archive.qmd#symbol-function-validateurlprotocol)

Expand Down Expand Up @@ -94,35 +93,6 @@ Check for path traversal attempts in archive entry paths.

SecurityError if path traversal is detected

## checkSymlinkTarget {#symbol-function-checksymlinktarget}

```{.ts filename="TypeScript"}
function checkSymlinkTarget(
linkTarget,
entryDir,
destDir): void;
```

Defined in: [packages/core/src/archive/security.ts:50](https://github.com/mcanouil/quarto-wizard/blob/46faccb6120271e673ba936b480c00bbea73bcdc/packages/core/src/archive/security.ts#L50)

Validate that a symlink target stays within the extraction directory.

### Parameters

| Parameter | Type | Description |
| ------------ | -------- | -------------------------------------- |
| `linkTarget` | `string` | Symlink target path |
| `entryDir` | `string` | Directory containing the symlink entry |
| `destDir` | `string` | Root extraction directory |

### Returns

`void`

### Throws

SecurityError if the symlink escapes destDir

## cleanupExtraction {#symbol-function-cleanupextraction}

```{.ts filename="TypeScript"}
Expand Down
14 changes: 7 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -420,17 +420,17 @@
},
{
"command": "quartoWizard.extensionsInstalled.reinstall",
"when": "view == quartoWizard.extensionsInstalled && viewItem == quartoExtensionItemPinned",
"when": "view == quartoWizard.extensionsInstalled && viewItem == quartoExtensionItemUpToDate",
"group": "inline@3"
},
{
"command": "quartoWizard.extensionsInstalled.remove",
"when": "view == quartoWizard.extensionsInstalled && (viewItem == quartoExtensionItem || viewItem == quartoExtensionItemUpToDate || viewItem == quartoExtensionItemPinned || viewItem == quartoExtensionItemNoSource)",
"when": "view == quartoWizard.extensionsInstalled && (viewItem == quartoExtensionItem || viewItem == quartoExtensionItemUpToDate || viewItem == quartoExtensionItemNoSource)",
"group": "inline@4"
},
{
"command": "quartoWizard.extensionsInstalled.openSource",
"when": "view == quartoWizard.extensionsInstalled && (viewItem == quartoExtensionItem || viewItem == quartoExtensionItemUpToDate || viewItem == quartoExtensionItemPinned)",
"when": "view == quartoWizard.extensionsInstalled && (viewItem == quartoExtensionItem || viewItem == quartoExtensionItemUpToDate)",
"group": "inline@5"
},
{
Expand All @@ -440,22 +440,22 @@
},
{
"command": "quartoWizard.extensionsInstalled.reinstall",
"when": "view == quartoWizard.extensionsInstalled && viewItem == quartoExtensionItemPinned",
"when": "view == quartoWizard.extensionsInstalled && (viewItem == quartoExtensionItem || viewItem == quartoExtensionItemUpToDate)",
"group": "navigation@1"
},
{
"command": "quartoWizard.extensionsInstalled.remove",
"when": "view == quartoWizard.extensionsInstalled && (viewItem == quartoExtensionItem || viewItem == quartoExtensionItemUpToDate || viewItem == quartoExtensionItemPinned || viewItem == quartoExtensionItemNoSource)",
"when": "view == quartoWizard.extensionsInstalled && (viewItem == quartoExtensionItem || viewItem == quartoExtensionItemUpToDate || viewItem == quartoExtensionItemNoSource)",
"group": "navigation@2"
},
{
"command": "quartoWizard.extensionsInstalled.openSource",
"when": "view == quartoWizard.extensionsInstalled && (viewItem == quartoExtensionItem || viewItem == quartoExtensionItemUpToDate || viewItem == quartoExtensionItemPinned)",
"when": "view == quartoWizard.extensionsInstalled && (viewItem == quartoExtensionItem || viewItem == quartoExtensionItemUpToDate)",
"group": "navigation@3"
},
{
"command": "quartoWizard.extensionsInstalled.revealInExplorer",
"when": "view == quartoWizard.extensionsInstalled && (viewItem == quartoExtensionItem || viewItem == quartoExtensionItemUpToDate || viewItem == quartoExtensionItemPinned || viewItem == quartoExtensionItemNoSource)",
"when": "view == quartoWizard.extensionsInstalled && (viewItem == quartoExtensionItem || viewItem == quartoExtensionItemUpToDate || viewItem == quartoExtensionItemNoSource)",
"group": "navigation@4"
},
{
Expand Down
8 changes: 6 additions & 2 deletions packages/core/src/archive/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,11 @@ function deriveExtensionIdFromPath(extensionPath: string, extractDir: string): {
export async function findAllExtensionRoots(extractDir: string): Promise<DiscoveredExtension[]> {
const results: DiscoveredExtension[] = [];

async function searchDirectory(dir: string): Promise<void> {
async function searchDirectory(dir: string, depth = 0): Promise<void> {
if (depth > MAX_FIND_DEPTH) {
return;
}

// Check for manifest in current directory
for (const name of MANIFEST_FILENAMES) {
const manifestPath = path.join(dir, name);
Expand All @@ -222,7 +226,7 @@ export async function findAllExtensionRoots(extractDir: string): Promise<Discove
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
await searchDirectory(path.join(dir, entry.name));
await searchDirectory(path.join(dir, entry.name), depth + 1);
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/archive/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Archive module exports.
*/

export { checkPathTraversal, checkSymlinkTarget, formatSize, validateUrlProtocol } from "./security.js";
export { checkPathTraversal, formatSize, validateUrlProtocol } from "./security.js";

export { type ZipExtractOptions, extractZip } from "./zip.js";

Expand Down
17 changes: 0 additions & 17 deletions packages/core/src/archive/security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,23 +39,6 @@ export function checkPathTraversal(filePath: string): void {
}
}

/**
* Validate that a symlink target stays within the extraction directory.
*
* @param linkTarget - Symlink target path
* @param entryDir - Directory containing the symlink entry
* @param destDir - Root extraction directory
* @throws SecurityError if the symlink escapes destDir
*/
export function checkSymlinkTarget(linkTarget: string, entryDir: string, destDir: string): void {
const resolvedTarget = path.resolve(entryDir, linkTarget);
const resolvedDest = path.resolve(destDir);

if (!resolvedTarget.startsWith(resolvedDest + path.sep) && resolvedTarget !== resolvedDest) {
throw new SecurityError(`Symlink target escapes extraction directory: "${linkTarget}"`);
}
}

/**
* Validate that a URL uses an allowed protocol.
*
Expand Down
57 changes: 37 additions & 20 deletions packages/core/src/archive/tar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,7 @@ import * as fs from "node:fs";
import * as path from "node:path";
import * as tar from "tar";
import { SecurityError } from "../errors.js";
import {
checkPathTraversal,
checkSymlinkTarget,
formatSize,
DEFAULT_MAX_SIZE,
MAX_COMPRESSION_RATIO,
MAX_FILE_COUNT,
} from "./security.js";
import { checkPathTraversal, formatSize, DEFAULT_MAX_SIZE, MAX_COMPRESSION_RATIO, MAX_FILE_COUNT } from "./security.js";

/**
* Options for TAR extraction.
Expand Down Expand Up @@ -53,52 +46,76 @@ export async function extractTar(
const extractedFiles: string[] = [];
let totalSize = 0;
let entryCount = 0;
let securityError: SecurityError | undefined;

await tar.extract({
file: archivePath,
cwd: destDir,
filter: (entryPath) => {
checkPathTraversal(entryPath);
filter: (entryPath, entry) => {
if (securityError) {
return false;
}
try {
checkPathTraversal(entryPath);
} catch (error) {
securityError = error instanceof SecurityError ? error : new SecurityError(String(error));
return false;
}
if ("type" in entry && (entry.type === "SymbolicLink" || entry.type === "Link")) {
const linkType = entry.type === "SymbolicLink" ? "symbolic link" : "hard link";
securityError = new SecurityError(`Archive contains a ${linkType} ("${entryPath}"), which is not permitted.`);
return false;
}
return true;
},
onReadEntry: (entry) => {
if (securityError) {
entry.resume();
return;
}

entryCount++;
if (entryCount > MAX_FILE_COUNT) {
throw new SecurityError(
securityError = new SecurityError(
`Archive contains too many entries: ${entryCount} > ${MAX_FILE_COUNT}. This may indicate a file bomb.`,
);
entry.resume();
return;
}

totalSize += entry.size ?? 0;

if (totalSize > maxSize) {
throw new SecurityError(`Archive exceeds maximum size: ${formatSize(totalSize)} > ${formatSize(maxSize)}`);
securityError = new SecurityError(
`Archive exceeds maximum size: ${formatSize(totalSize)} > ${formatSize(maxSize)}`,
);
entry.resume();
return;
}

// Check compression ratio incrementally to detect tar bombs early.
if (compressedSize > 0) {
const ratio = totalSize / compressedSize;
if (ratio > MAX_COMPRESSION_RATIO) {
throw new SecurityError(
securityError = new SecurityError(
`Suspicious compression ratio detected: ${ratio.toFixed(1)}:1. ` + "This may indicate a tar bomb.",
);
entry.resume();
return;
}
}

const entryPath = entry.path;

// Validate symlink targets stay within the extraction directory.
if (entry.type === "SymbolicLink" && entry.linkpath) {
const entryDir = path.resolve(destDir, path.dirname(entryPath));
checkSymlinkTarget(entry.linkpath, entryDir, destDir);
}

if (entry.type === "File" || entry.type === "ContiguousFile") {
extractedFiles.push(path.join(destDir, entryPath));
onProgress?.(entryPath);
}
},
});

if (securityError) {
throw securityError;
}

return extractedFiles;
}
9 changes: 8 additions & 1 deletion packages/core/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,13 @@ export function isQuartoWizardError(error: unknown): error is QuartoWizardError
return error instanceof QuartoWizardError;
}

/**
* Extract a human-readable message from an unknown error value.
*/
export function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

/**
* Wrap an unknown error as a QuartoWizardError.
*/
Expand All @@ -176,7 +183,7 @@ export function wrapError(error: unknown, context?: string): QuartoWizardError {
return error;
}

const message = error instanceof Error ? error.message : String(error);
const message = getErrorMessage(error);
const contextPrefix = context ? `${context}: ` : "";

return new QuartoWizardError(`${contextPrefix}${message}`, "UNKNOWN_ERROR", {
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/filesystem/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import * as path from "node:path";
import * as yaml from "js-yaml";
import type { ExtensionManifest, RawManifest, SourceType } from "../types/manifest.js";
import { normaliseManifest } from "../types/manifest.js";
import { ManifestError } from "../errors.js";
import { ManifestError, getErrorMessage } from "../errors.js";

/** Supported manifest file names. */
export const MANIFEST_FILENAMES = ["_extension.yml", "_extension.yaml"] as const;
Expand Down Expand Up @@ -60,7 +60,7 @@ export function parseManifestFile(manifestPath: string): ExtensionManifest {
if (error instanceof ManifestError) {
throw error;
}
throw new ManifestError(`Failed to read manifest file: ${error instanceof Error ? error.message : String(error)}`, {
throw new ManifestError(`Failed to read manifest file: ${getErrorMessage(error)}`, {
manifestPath,
cause: error,
});
Expand Down Expand Up @@ -88,7 +88,7 @@ export function parseManifestContent(content: string, sourcePath?: string): Exte
if (error instanceof ManifestError) {
throw error;
}
throw new ManifestError(`Failed to parse manifest: ${error instanceof Error ? error.message : String(error)}`, {
throw new ManifestError(`Failed to parse manifest: ${getErrorMessage(error)}`, {
manifestPath: sourcePath,
cause: error,
});
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export {
CancellationError,
isQuartoWizardError,
isCancellationError,
getErrorMessage,
wrapError,
} from "./errors.js";

Expand Down
5 changes: 2 additions & 3 deletions packages/core/src/operations/brand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import * as fs from "node:fs";
import * as path from "node:path";
import * as yaml from "js-yaml";
import type { AuthConfig } from "../types/auth.js";
import { ExtensionError } from "../errors.js";
import { ExtensionError, getErrorMessage } from "../errors.js";
import { parseInstallSource, formatInstallSource, type InstallSource } from "./install.js";
import { downloadGitHubArchive, downloadFromUrl } from "../github/download.js";
import { extractArchive, cleanupExtraction } from "../archive/extract.js";
Expand Down Expand Up @@ -307,8 +307,7 @@ export function extractBrandFilePaths(brandYamlPath: string, onWarning?: (messag
return paths;
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
onWarning?.(`Failed to read brand file "${brandYamlPath}": ${message}`);
onWarning?.(`Failed to read brand file "${brandYamlPath}": ${getErrorMessage(error)}`);
return paths;
}

Expand Down
Loading