diff --git a/src/spec-configuration/containerFeaturesConfiguration.ts b/src/spec-configuration/containerFeaturesConfiguration.ts index 5957d0896..141e03d32 100644 --- a/src/spec-configuration/containerFeaturesConfiguration.ts +++ b/src/spec-configuration/containerFeaturesConfiguration.ts @@ -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; } diff --git a/src/test/container-features/featureHelpers.test.ts b/src/test/container-features/featureHelpers.test.ts index 3e08c0648..67e42ff29 100644 --- a/src/test/container-features/featureHelpers.test.ts +++ b/src/test/container-features/featureHelpers.test.ts @@ -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',