Skip to content

Commit c1a3497

Browse files
committed
Resolve local feature paths relative to the config file's directory
Local-path features are spec'd as relative to the containing devcontainer.json (see `featureFolderPath` computed above), but the parent-escape sanity check was anchored at `${workspaceRoot}/.devcontainer`. When `--config` points to a devcontainer.json that lives outside that folder (e.g. an out-of-tree config supplied by an editor integration), every local feature is rejected even though its resolved path is a sibling of the config. Anchor the check at `path.dirname(configPath)` so it mirrors how the path is actually resolved. Existing escape coverage still holds (e.g. './../featureC' relative to /workspace/.devcontainer.json resolves to /workspace/featureC -> relative '../featureC' -> rejected). Add a regression test exercising an external config directory.
1 parent 65f98a5 commit c1a3497

2 files changed

Lines changed: 32 additions & 3 deletions

File tree

src/spec-configuration/containerFeaturesConfiguration.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -843,13 +843,18 @@ export async function processFeatureIdentifier(params: CommonParams, configPath:
843843
}
844844
const featureFolderPath = path.join(path.dirname(configPath), userFeature.userFeatureId);
845845

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

src/test/container-features/featureHelpers.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,30 @@ describe('validate processFeatureIdentifier', async function () {
188188
assert.deepEqual(featureSet?.sourceInformation, { type: 'file-path', resolvedFilePath: path.join(workspaceRoot, '.devcontainer', 'featureB'), userFeatureId: './.devcontainer/featureB' });
189189
});
190190

191+
it('local-path should parse when config file is outside the workspace .devcontainer folder', async function () {
192+
// Regression: when `--config` points to a devcontainer.json that lives
193+
// outside `${workspaceRoot}/.devcontainer/`, local-path features
194+
// resolved relative to that config must still be accepted. Previously
195+
// the parent-escape check was anchored at `${workspaceRoot}/.devcontainer`,
196+
// which rejected every local feature in this layout.
197+
const userFeature: DevContainerFeature = {
198+
userFeatureId: './featureA',
199+
options: {},
200+
};
201+
202+
const externalConfigDir = '/some/other/place';
203+
const customConfigPath = path.join(externalConfigDir, 'devcontainer.json');
204+
205+
const featureSet = await processFeatureIdentifier(params, customConfigPath, workspaceRoot, userFeature);
206+
assert.exists(featureSet);
207+
assert.strictEqual(featureSet?.features[0].id, 'featureA');
208+
assert.deepEqual(featureSet?.sourceInformation, {
209+
type: 'file-path',
210+
resolvedFilePath: path.join(externalConfigDir, 'featureA'),
211+
userFeatureId: './featureA',
212+
});
213+
});
214+
191215
it('should process oci registry (without tag)', async function () {
192216
const userFeature: DevContainerFeature = {
193217
userFeatureId: 'ghcr.io/codspace/features/ruby',

0 commit comments

Comments
 (0)