Skip to content

Commit

Permalink
Check for disallowed features
Browse files Browse the repository at this point in the history
  • Loading branch information
chrmarti committed May 16, 2023
1 parent 914d873 commit fa20d4b
Show file tree
Hide file tree
Showing 8 changed files with 253 additions and 4 deletions.
92 changes: 92 additions & 0 deletions src/spec-configuration/controlManifest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------------------------------------------*/

import { promises as fs } from 'fs';
import * as path from 'path';
import * as os from 'os';
import * as jsonc from 'jsonc-parser';

import { request } from '../spec-utils/httpRequest';
import * as crypto from 'crypto';
import { Log } from '../spec-utils/log';

export interface DisallowedFeature {
featureIdPrefix: string;
documentationURL?: string;
}

export interface DevContainerControlManifest {
disallowedFeatures: DisallowedFeature[];
}

const controlManifestPath = path.join(os.homedir(), '.devcontainer', 'cache', 'control-manifest.json');

const emptyControlManifest: DevContainerControlManifest = {
disallowedFeatures: [],
};

const cacheTimeoutMillis = 5 * 60 * 1000; // 5 minutes

export async function getControlManifest(output: Log): Promise<DevContainerControlManifest> {
const cacheStat = await fs.stat(controlManifestPath)
.catch(err => {
if (err?.code !== 'ENOENT') {
throw err;
}
});
const cacheBuffer = cacheStat?.isFile() ? await fs.readFile(controlManifestPath)
.catch(err => {
if (err?.code !== 'ENOENT') {
throw err;
}
}) : undefined;
const cachedManifest = cacheBuffer ? sanitizeControlManifest(jsonc.parse(cacheBuffer.toString())) : undefined;
if (cacheStat && cachedManifest && cacheStat.mtimeMs + cacheTimeoutMillis > Date.now()) {
return cachedManifest;
}
return updateControlManifest(cachedManifest, output);
}

async function updateControlManifest(oldManifest: DevContainerControlManifest | undefined, output: Log): Promise<DevContainerControlManifest> {
let manifestBuffer: Buffer;
try {
manifestBuffer = await fetchControlManifest();
} catch (error) {
output.write(`Failed to fetch control manifest: ${error.message}`);
if (oldManifest) {
// Keep old manifest to not loose existing information and update timestamp to avoid flooding the server.
const now = new Date();
await fs.utimes(controlManifestPath, now, now);
return oldManifest;
}
manifestBuffer = Buffer.from(JSON.stringify(emptyControlManifest, undefined, 2));
}

const controlManifestTmpPath = `${controlManifestPath}-${crypto.randomUUID()}`;
await fs.mkdir(path.dirname(controlManifestPath), { recursive: true });
await fs.writeFile(controlManifestTmpPath, manifestBuffer);
await fs.rename(controlManifestTmpPath, controlManifestPath);
return sanitizeControlManifest(jsonc.parse(manifestBuffer.toString()));
}

async function fetchControlManifest() {
return request({
type: 'GET',
url: 'https://containers.dev/static/devcontainer-control-manifest.json',
headers: {
'user-agent': 'devcontainers-vscode',
'accept': 'application/json',
},
});
}

function sanitizeControlManifest(manifest: any): DevContainerControlManifest {
if (!manifest || typeof manifest !== 'object') {
return emptyControlManifest;
}
const disallowedFeatures = manifest.disallowedFeatures as DisallowedFeature[] | undefined;
return {
disallowedFeatures: Array.isArray(disallowedFeatures) ? disallowedFeatures.filter(f => typeof f.featureIdPrefix === 'string') : [],
};
}
7 changes: 7 additions & 0 deletions src/spec-node/configContainer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import { CLIHost } from '../spec-common/commonUtils';
import { Log } from '../spec-utils/log';
import { getDefaultDevContainerConfigPath, getDevContainerConfigPathIn } from '../spec-configuration/configurationCommonUtils';
import { DevContainerConfig, DevContainerFromDockerComposeConfig, DevContainerFromDockerfileConfig, DevContainerFromImageConfig, updateFromOldProperties } from '../spec-configuration/configuration';
import { ensureNoDisallowedFeatures } from './disallowedFeatures';
import { DockerCLIParameters } from '../spec-shutdown/dockerUtils';

export { getWellKnownDevContainerPaths as getPossibleDevContainerPaths } from '../spec-configuration/configurationCommonUtils';

Expand Down Expand Up @@ -56,6 +58,11 @@ async function resolveWithLocalFolder(params: DockerResolverParameters, parsedAu
const configWithRaw = addSubstitution(configs.config, config => beforeContainerSubstitute(envListToObj(idLabels), config));
const { config } = configWithRaw;

const { dockerCLI, dockerComposeCLI } = params;
const { env } = common;
const cliParams: DockerCLIParameters = { cliHost, dockerCLI, dockerComposeCLI, env, output };
await ensureNoDisallowedFeatures(cliParams, config, additionalFeatures, idLabels);

await runInitializeCommand({ ...params, common: { ...common, output: common.lifecycleHook.output } }, config.initializeCommand, common.lifecycleHook.onDidInput);

let result: ResolverResult;
Expand Down
6 changes: 4 additions & 2 deletions src/spec-node/devContainersSpecCLI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { templateApplyHandler, templateApplyOptions } from './templatesCLI/apply
import { featuresInfoHandler as featuresInfoHandler, featuresInfoOptions } from './featuresCLI/info';
import { bailOut, buildNamedImageAndExtend } from './singleContainer';
import { Event, NodeEventEmitter } from '../spec-utils/event';
import { ensureNoDisallowedFeatures } from './disallowedFeatures';

const defaultDefaultUserEnvProbe: UserEnvProbe = 'loginInteractiveShell';

Expand Down Expand Up @@ -576,6 +577,9 @@ async function doBuild({
throw new ContainerError({ description: '--push true cannot be used with --output.' });
}

const buildParams: DockerCLIParameters = { cliHost, dockerCLI, dockerComposeCLI, env, output };
await ensureNoDisallowedFeatures(buildParams, config, additionalFeatures, undefined);

// Support multiple use of `--image-name`
const imageNames = (argImageName && (Array.isArray(argImageName) ? argImageName : [argImageName]) as string[]) || undefined;

Expand Down Expand Up @@ -613,8 +617,6 @@ async function doBuild({
}
const projectName = await getProjectName(params, workspace, composeFiles);

const buildParams: DockerCLIParameters = { cliHost, dockerCLI, dockerComposeCLI, env, output };

const composeConfig = await readDockerComposeConfig(buildParams, composeFiles, envFile);
const services = Object.keys(composeConfig.services || {});
if (services.indexOf(config.service) === -1) {
Expand Down
51 changes: 51 additions & 0 deletions src/spec-node/disallowedFeatures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------------------------------------------*/

import { DevContainerConfig } from '../spec-configuration/configuration';
import { ContainerError } from '../spec-common/errors';
import { DockerCLIParameters, dockerCLI } from '../spec-shutdown/dockerUtils';
import { findDevContainer } from './singleContainer';
import { DevContainerControlManifest, DisallowedFeature, getControlManifest } from '../spec-configuration/controlManifest';


export async function ensureNoDisallowedFeatures(params: DockerCLIParameters, config: DevContainerConfig, additionalFeatures: Record<string, string | boolean | Record<string, string | boolean>>, idLabels: string[] | undefined) {
const controlManifest = await getControlManifest(params.output);
const disallowed = Object.keys({
...config.features,
...additionalFeatures,
}).map(configFeatureId => {
const disallowedFeatureEntry = findDisallowedFeatureEntry(controlManifest, configFeatureId);
return disallowedFeatureEntry ? { configFeatureId, disallowedFeatureEntry } : undefined;
}).filter(Boolean) as {
configFeatureId: string;
disallowedFeatureEntry: DisallowedFeature;
}[];

if (!disallowed.length) {
return;
}

let stopped = false;
if (idLabels) {
const container = await findDevContainer(params, idLabels);
if (container?.State?.Status === 'running') {
await dockerCLI(params, 'stop', '-t', '0', container.Id);
stopped = true;
}
}

const d = disallowed[0]!;
const documentationURL = d.disallowedFeatureEntry.documentationURL;
throw new ContainerError({
description: `Cannot use the '${d.configFeatureId}' feature since it was reported to be problematic. Please remove this feature from your configuration and rebuild any dev container using it before continuing.${stopped ? ' The existing dev container was stopped.' : ''}${documentationURL ? ` See ${documentationURL} to learn more.` : ''}`,
});
}

export function findDisallowedFeatureEntry(controlManifest: DevContainerControlManifest, featureId: string): DisallowedFeature | undefined {
return controlManifest.disallowedFeatures.find(
disallowedFeature =>
featureId.startsWith(disallowedFeature.featureIdPrefix) &&
(featureId.length === disallowedFeature.featureIdPrefix.length || '/:@'.indexOf(featureId[disallowedFeature.featureIdPrefix.length]) !== -1)
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"image": "mcr.microsoft.com/devcontainers/base:latest",
"features": {
"ghcr.io/devcontainers/features/github-cli:latest": "latest"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"image": "mcr.microsoft.com/devcontainers/base:latest",
"features": {
"ghcr.io/devcontainers/features/github-cli:latest": "latest",
"ghcr.io/devcontainers/features/disallowed-feature:latest": "latest"
}
}
59 changes: 59 additions & 0 deletions src/test/disallowedFeatures.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------------------------------------------*/

import * as assert from 'assert';
import * as path from 'path';
import * as jsonc from 'jsonc-parser';

import { ensureNoDisallowedFeatures, findDisallowedFeatureEntry } from '../spec-node/disallowedFeatures';
import { readLocalFile } from '../spec-utils/pfs';
import { ContainerError } from '../spec-common/errors';
import { createCLIParams } from './testUtils';


describe(`Disallowed features check`, function () {

it(`passes with allowed features`, async () => {
const hostPath = path.join(__dirname, 'configs', 'disallowed-features');
const configFile = path.join(hostPath, '.devcontainer', 'allowed', 'devcontainer.json');
const config = jsonc.parse((await readLocalFile(configFile)).toString());
const cliParams = await createCLIParams(hostPath);

await ensureNoDisallowedFeatures(cliParams, config, {}, []);
});

it(`fails with disallowed features`, async () => {
const hostPath = path.join(__dirname, 'configs', 'disallowed-features');
const configFile = path.join(hostPath, '.devcontainer', 'disallowed', 'devcontainer.json');
const config = jsonc.parse((await readLocalFile(configFile)).toString());
const cliParams = await createCLIParams(hostPath);

let error: Error | undefined;
try {
await ensureNoDisallowedFeatures(cliParams, config, {}, []);
} catch (err) {
error = err;
}
assert.ok(error, 'Expected error');
assert.ok(error instanceof ContainerError, `Expected ContainerError got: ${error.message}`);
});

it(`matches equal feature id and prefix`, () => {
const controlManifest = {
disallowedFeatures: [
{
featureIdPrefix: 'example.io/test/node',
},
],
};
assert.ok(findDisallowedFeatureEntry(controlManifest, 'example.io/test/node'));
assert.strictEqual(findDisallowedFeatureEntry(controlManifest, 'example.io/test/nodej'), undefined);
assert.strictEqual(findDisallowedFeatureEntry(controlManifest, 'example.io/test/nod'), undefined);

assert.ok(findDisallowedFeatureEntry(controlManifest, 'example.io/test/node:1'));
assert.ok(findDisallowedFeatureEntry(controlManifest, 'example.io/test/node/js'));
assert.ok(findDisallowedFeatureEntry(controlManifest, 'example.io/test/node@abc'));
assert.strictEqual(findDisallowedFeatureEntry(controlManifest, 'example.io/test/node.js'), undefined);
});
});
29 changes: 27 additions & 2 deletions src/test/testUtils.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------------------------------------------*/

import * as assert from 'assert';
import * as cp from 'child_process';
import { loadNativeModule, plainExec, plainPtyExec, runCommand, runCommandNoPty } from '../spec-common/commonUtils';
import { getCLIHost, loadNativeModule, plainExec, plainPtyExec, runCommand, runCommandNoPty } from '../spec-common/commonUtils';
import { SubstituteConfig } from '../spec-node/utils';
import { nullLog } from '../spec-utils/log';
import { LogLevel, createPlainLog, makeLog, nullLog } from '../spec-utils/log';
import { dockerComposeCLIConfig } from '../spec-node/dockerCompose';
import { DockerCLIParameters } from '../spec-shutdown/dockerUtils';

export interface BuildKitOption {
text: string;
Expand Down Expand Up @@ -137,3 +143,22 @@ export const testSubstitute: SubstituteConfig = value => {
}
return value;
};

export const output = makeLog(createPlainLog(text => process.stdout.write(text), () => LogLevel.Trace));

export async function createCLIParams(hostPath: string) {
const cliHost = await getCLIHost(hostPath, loadNativeModule);
const dockerComposeCLI = dockerComposeCLIConfig({
exec: cliHost.exec,
env: cliHost.env,
output,
}, 'docker', 'docker-compose');
const cliParams: DockerCLIParameters = {
cliHost,
dockerCLI: 'docker',
dockerComposeCLI,
env: {},
output,
};
return cliParams;
}

0 comments on commit fa20d4b

Please sign in to comment.