Files
vscode-codeql/extensions/ql-vscode/src/common/vscode/archive-filesystem-provider.ts
Koen Vlaswinkel 918661e5ce Enable ESLint curly rule
This enables [the ESLint `curly` rule](https://eslint.org/docs/latest/rules/curly)
with its options set to `all`. This enforces curly braces around all
blocks, even single-line ones.

I've used `npm run lint -- --fix` to fix all occurences.
2023-11-24 14:38:32 +01:00

351 lines
10 KiB
TypeScript

import { pathExists } from "fs-extra";
import * as unzipper from "unzipper";
import * as vscode from "vscode";
import { extLogger } from "../logging/vscode";
// All path operations in this file must be on paths *within* the zip
// archive.
import { posix } from "path";
const path = posix;
class File implements vscode.FileStat {
type: vscode.FileType;
ctime: number;
mtime: number;
size: number;
constructor(
public name: string,
public data: Uint8Array,
) {
this.type = vscode.FileType.File;
this.ctime = Date.now();
this.mtime = Date.now();
this.size = data.length;
this.name = name;
}
}
class Directory implements vscode.FileStat {
type: vscode.FileType;
ctime: number;
mtime: number;
size: number;
entries: Map<string, Entry> = new Map();
constructor(public name: string) {
this.type = vscode.FileType.Directory;
this.ctime = Date.now();
this.mtime = Date.now();
this.size = 0;
}
}
type Entry = File | Directory;
/**
* A map containing directory hierarchy information in a convenient form.
*
* For example, if dirMap : DirectoryHierarchyMap, and /foo/bar/baz.c is a file in the
* directory structure being represented, then
*
* dirMap['/foo'] = {'bar': vscode.FileType.Directory}
* dirMap['/foo/bar'] = {'baz': vscode.FileType.File}
*/
type DirectoryHierarchyMap = Map<string, Map<string, vscode.FileType>>;
export type ZipFileReference = {
sourceArchiveZipPath: string;
pathWithinSourceArchive: string;
};
/** Encodes a reference to a source file within a zipped source archive into a single URI. */
export function encodeSourceArchiveUri(ref: ZipFileReference): vscode.Uri {
const { sourceArchiveZipPath, pathWithinSourceArchive } = ref;
// These two paths are put into a single URI with a custom scheme.
// The path and authority components of the URI encode the two paths.
// The path component of the URI contains both paths, joined by a slash.
let encodedPath = path.join(sourceArchiveZipPath, pathWithinSourceArchive);
// If a URI contains an authority component, then the path component
// must either be empty or begin with a slash ("/") character.
// (Source: https://tools.ietf.org/html/rfc3986#section-3.3)
// Since we will use an authority component, we add a leading slash if necessary
// (paths on Windows usually start with the drive letter).
let sourceArchiveZipPathStartIndex: number;
if (encodedPath.startsWith("/")) {
sourceArchiveZipPathStartIndex = 0;
} else {
encodedPath = `/${encodedPath}`;
sourceArchiveZipPathStartIndex = 1;
}
// The authority component of the URI records the 0-based inclusive start and exclusive end index
// of the source archive zip path within the path component of the resulting URI.
// This lets us separate the paths, ignoring the leading slash if we added one.
const sourceArchiveZipPathEndIndex =
sourceArchiveZipPathStartIndex + sourceArchiveZipPath.length;
const authority = `${sourceArchiveZipPathStartIndex}-${sourceArchiveZipPathEndIndex}`;
return vscode.Uri.parse(`${zipArchiveScheme}:/`, true).with({
path: encodedPath,
authority,
});
}
/**
* Convenience method to create a codeql-zip-archive with a path to the root
* archive
*
* @param pathToArchive the filesystem path to the root of the archive
*/
export function encodeArchiveBasePath(sourceArchiveZipPath: string) {
return encodeSourceArchiveUri({
sourceArchiveZipPath,
pathWithinSourceArchive: "",
});
}
const sourceArchiveUriAuthorityPattern = /^(\d+)-(\d+)$/;
class InvalidSourceArchiveUriError extends Error {
constructor(uri: vscode.Uri) {
super(
`Can't decode uri ${uri}: authority should be of the form startIndex-endIndex (where both indices are integers).`,
);
}
}
/** Decodes an encoded source archive URI into its corresponding paths. Inverse of `encodeSourceArchiveUri`. */
export function decodeSourceArchiveUri(uri: vscode.Uri): ZipFileReference {
if (!uri.authority) {
// Uri is malformed, but this is recoverable
void extLogger.log(
`Warning: ${new InvalidSourceArchiveUriError(uri).message}`,
);
return {
pathWithinSourceArchive: "/",
sourceArchiveZipPath: uri.path,
};
}
const match = sourceArchiveUriAuthorityPattern.exec(uri.authority);
if (match === null) {
throw new InvalidSourceArchiveUriError(uri);
}
const zipPathStartIndex = parseInt(match[1]);
const zipPathEndIndex = parseInt(match[2]);
if (isNaN(zipPathStartIndex) || isNaN(zipPathEndIndex)) {
throw new InvalidSourceArchiveUriError(uri);
}
return {
pathWithinSourceArchive: uri.path.substring(zipPathEndIndex) || "/",
sourceArchiveZipPath: uri.path.substring(
zipPathStartIndex,
zipPathEndIndex,
),
};
}
/**
* Make sure `file` and all of its parent directories are represented in `map`.
*/
function ensureFile(map: DirectoryHierarchyMap, file: string) {
const dirname = path.dirname(file);
if (dirname === ".") {
const error = `Ill-formed path ${file} in zip archive (expected absolute path)`;
void extLogger.log(error);
throw new Error(error);
}
ensureDir(map, dirname);
map.get(dirname)!.set(path.basename(file), vscode.FileType.File);
}
/**
* Make sure `dir` and all of its parent directories are represented in `map`.
*/
function ensureDir(map: DirectoryHierarchyMap, dir: string) {
const parent = path.dirname(dir);
if (!map.has(dir)) {
map.set(dir, new Map());
if (dir !== parent) {
// not the root directory
ensureDir(map, parent);
map.get(parent)!.set(path.basename(dir), vscode.FileType.Directory);
}
}
}
type Archive = {
unzipped: unzipper.CentralDirectory;
dirMap: DirectoryHierarchyMap;
};
async function parse_zip(zipPath: string): Promise<Archive> {
if (!(await pathExists(zipPath))) {
throw vscode.FileSystemError.FileNotFound(zipPath);
}
const archive: Archive = {
unzipped: await unzipper.Open.file(zipPath),
dirMap: new Map(),
};
archive.unzipped.files.forEach((f) => {
ensureFile(archive.dirMap, path.resolve("/", f.path));
});
return archive;
}
export class ArchiveFileSystemProvider implements vscode.FileSystemProvider {
private readOnlyError = vscode.FileSystemError.NoPermissions(
"write operation attempted, but source archive filesystem is readonly",
);
private archives: Map<string, Promise<Archive>> = new Map();
private async getArchive(zipPath: string): Promise<Archive> {
if (!this.archives.has(zipPath)) {
this.archives.set(zipPath, parse_zip(zipPath));
}
return await this.archives.get(zipPath)!;
}
root = new Directory("");
// metadata
async stat(uri: vscode.Uri): Promise<vscode.FileStat> {
return await this._lookup(uri);
}
async readDirectory(
uri: vscode.Uri,
): Promise<Array<[string, vscode.FileType]>> {
const ref = decodeSourceArchiveUri(uri);
const archive = await this.getArchive(ref.sourceArchiveZipPath);
const contents = archive.dirMap.get(ref.pathWithinSourceArchive);
const result =
contents === undefined ? undefined : Array.from(contents.entries());
if (result === undefined) {
throw vscode.FileSystemError.FileNotFound(uri);
}
return result;
}
// file contents
async readFile(uri: vscode.Uri): Promise<Uint8Array> {
const data = (await this._lookupAsFile(uri)).data;
if (data) {
return data;
}
throw vscode.FileSystemError.FileNotFound();
}
// write operations, all disabled
writeFile(
_uri: vscode.Uri,
_content: Uint8Array,
_options: { create: boolean; overwrite: boolean },
): void {
throw this.readOnlyError;
}
rename(
_oldUri: vscode.Uri,
_newUri: vscode.Uri,
_options: { overwrite: boolean },
): void {
throw this.readOnlyError;
}
delete(_uri: vscode.Uri): void {
throw this.readOnlyError;
}
createDirectory(_uri: vscode.Uri): void {
throw this.readOnlyError;
}
// content lookup
private async _lookup(uri: vscode.Uri): Promise<Entry> {
const ref = decodeSourceArchiveUri(uri);
const archive = await this.getArchive(ref.sourceArchiveZipPath);
// this is a path inside the archive, so don't use `.fsPath`, and
// use '/' as path separator throughout
const reqPath = ref.pathWithinSourceArchive;
const file = archive.unzipped.files.find((f) => {
const absolutePath = path.resolve("/", f.path);
return (
absolutePath === reqPath ||
absolutePath === path.join("/src_archive", reqPath)
);
});
if (file !== undefined) {
if (file.type === "File") {
return new File(reqPath, await file.buffer());
} else {
// file.type === 'Directory'
// I haven't observed this case in practice. Could it happen
// with a zip file that contains empty directories?
return new Directory(reqPath);
}
}
if (archive.dirMap.has(reqPath)) {
return new Directory(reqPath);
}
throw vscode.FileSystemError.FileNotFound(
`uri '${uri.toString()}', interpreted as '${reqPath}' in archive '${
ref.sourceArchiveZipPath
}'`,
);
}
private async _lookupAsFile(uri: vscode.Uri): Promise<File> {
const entry = await this._lookup(uri);
if (entry instanceof File) {
return entry;
}
throw vscode.FileSystemError.FileIsADirectory(uri);
}
// file events
private _emitter = new vscode.EventEmitter<vscode.FileChangeEvent[]>();
readonly onDidChangeFile: vscode.Event<vscode.FileChangeEvent[]> =
this._emitter.event;
watch(_resource: vscode.Uri): vscode.Disposable {
// ignore, fires for all changes...
return new vscode.Disposable(() => {
/**/
});
}
}
/**
* Custom uri scheme for referring to files inside zip archives stored
* in the filesystem. See `encodeSourceArchiveUri`/`decodeSourceArchiveUri` for
* how these uris are constructed.
*
* (cf. https://www.ietf.org/rfc/rfc2396.txt (Appendix A, page 26) for
* the fact that hyphens are allowed in uri schemes)
*/
export const zipArchiveScheme = "codeql-zip-archive";
export function activate(ctx: vscode.ExtensionContext) {
ctx.subscriptions.push(
vscode.workspace.registerFileSystemProvider(
zipArchiveScheme,
new ArchiveFileSystemProvider(),
{
isCaseSensitive: true,
isReadonly: true,
},
),
);
}