Skip to content
Open
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
11 changes: 8 additions & 3 deletions src/spec-configuration/containerFeaturesConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -843,13 +843,18 @@ export async function processFeatureIdentifier(params: CommonParams, configPath:
}
const featureFolderPath = path.join(path.dirname(configPath), userFeature.userFeatureId);

// Ensure we aren't escaping .devcontainer folder
const parent = path.join(_workspaceRoot, '.devcontainer');
// Ensure we aren't escaping the directory containing the devcontainer config.
// The local-features spec resolves paths relative to the config file's directory
// (see `featureFolderPath` above), so the escape check must be anchored there
// rather than at `${workspaceRoot}/.devcontainer`. Otherwise, configs supplied
// via `--config` that live outside the workspace's `.devcontainer/` folder would
// reject all of their own sibling features.
const parent = path.dirname(configPath);
const child = featureFolderPath;
const relative = path.relative(parent, child);
output.write(`${parent} -> ${child}: Relative Distance = '${relative}'`, LogLevel.Trace);
if (relative.indexOf('..') !== -1) {
output.write(`Local file path parse error. Resolved path must be a child of the .devcontainer/ folder. Parsed: ${featureFolderPath}`, LogLevel.Error);
output.write(`Local file path parse error. Resolved path must be a child of the config file's folder. Parsed: ${featureFolderPath}`, LogLevel.Error);
return undefined;
}

Expand Down
24 changes: 24 additions & 0 deletions src/test/container-features/featureHelpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,30 @@ describe('validate processFeatureIdentifier', async function () {
assert.deepEqual(featureSet?.sourceInformation, { type: 'file-path', resolvedFilePath: path.join(workspaceRoot, '.devcontainer', 'featureB'), userFeatureId: './.devcontainer/featureB' });
});

it('local-path should parse when config file is outside the workspace .devcontainer folder', async function () {
// Regression: when `--config` points to a devcontainer.json that lives
// outside `${workspaceRoot}/.devcontainer/`, local-path features
// resolved relative to that config must still be accepted. Previously
// the parent-escape check was anchored at `${workspaceRoot}/.devcontainer`,
// which rejected every local feature in this layout.
const userFeature: DevContainerFeature = {
userFeatureId: './featureA',
options: {},
};

const externalConfigDir = '/some/other/place';
const customConfigPath = path.join(externalConfigDir, 'devcontainer.json');

const featureSet = await processFeatureIdentifier(params, customConfigPath, workspaceRoot, userFeature);
assert.exists(featureSet);
assert.strictEqual(featureSet?.features[0].id, 'featureA');
assert.deepEqual(featureSet?.sourceInformation, {
type: 'file-path',
resolvedFilePath: path.join(externalConfigDir, 'featureA'),
userFeatureId: './featureA',
});
});

it('should process oci registry (without tag)', async function () {
const userFeature: DevContainerFeature = {
userFeatureId: 'ghcr.io/codspace/features/ruby',
Expand Down