fix(ci): canonicalize 8.3 short paths in cutter matrix ownership checks
Windows CI / verify (push) Failing after 1m26s
Windows CI / verify (push) Failing after 1m26s
assertPathInside compared managed-tool paths with path.resolve only, so on runners whose temporary directory surfaces as a Windows 8.3 short path (RUNNER~1) the canonical long executable paths appeared to live outside the owned root and the cutter matrix provisioning contract failed. Resolve both sides through fs.realpathSync.native, walking up through not-yet-existing segments so planned directories keep working, and cover the contract with a real ShortPath regression test plus mixed-existence canonicalization cases.
This commit is contained in:
@@ -41,9 +41,25 @@ const markerTimes = Object.freeze({
|
|||||||
});
|
});
|
||||||
const markerAudioSampleRate = 48000;
|
const markerAudioSampleRate = 48000;
|
||||||
|
|
||||||
|
function resolveCanonicalPath(candidatePath) {
|
||||||
|
let existingPath = path.resolve(candidatePath);
|
||||||
|
const missingSegments = [];
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
return path.join(fs.realpathSync.native(existingPath), ...missingSegments.reverse());
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code !== 'ENOENT') throw error;
|
||||||
|
const parentPath = path.dirname(existingPath);
|
||||||
|
if (parentPath === existingPath) return path.resolve(candidatePath);
|
||||||
|
missingSegments.push(path.basename(existingPath));
|
||||||
|
existingPath = parentPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function assertPathInside(targetPath, parentPath, label) {
|
function assertPathInside(targetPath, parentPath, label) {
|
||||||
const resolvedTarget = path.resolve(targetPath);
|
const resolvedTarget = resolveCanonicalPath(targetPath);
|
||||||
const resolvedParent = path.resolve(parentPath);
|
const resolvedParent = resolveCanonicalPath(parentPath);
|
||||||
const relative = path.relative(resolvedParent, resolvedTarget);
|
const relative = path.relative(resolvedParent, resolvedTarget);
|
||||||
if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
||||||
throw new Error(`${label} is outside the owned managed-tool directory: ${resolvedTarget}`);
|
throw new Error(`${label} is outside the owned managed-tool directory: ${resolvedTarget}`);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
|
const { spawnSync } = require('node:child_process');
|
||||||
const { EventEmitter } = require('node:events');
|
const { EventEmitter } = require('node:events');
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const os = require('node:os');
|
const os = require('node:os');
|
||||||
@@ -28,6 +29,18 @@ function createEnvironment() {
|
|||||||
return { rootDir, appDataDir };
|
return { rootDir, appDataDir };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getWindowsShortPath(targetPath) {
|
||||||
|
const command = `for %I in ("${targetPath}") do @echo %~sI`;
|
||||||
|
const result = spawnSync(process.env.ComSpec || 'cmd.exe', ['/d', '/c', command], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
windowsHide: true,
|
||||||
|
windowsVerbatimArguments: true
|
||||||
|
});
|
||||||
|
if (result.error) throw result.error;
|
||||||
|
if (result.status !== 0) throw new Error(result.stderr.trim() || `ShortPath lookup exited with ${result.status}`);
|
||||||
|
return result.stdout.trim();
|
||||||
|
}
|
||||||
|
|
||||||
function createProvisioningFixture() {
|
function createProvisioningFixture() {
|
||||||
const manifest = {
|
const manifest = {
|
||||||
streamlink: { id: 'streamlink', version: '8.4.0' },
|
streamlink: { id: 'streamlink', version: '8.4.0' },
|
||||||
@@ -105,6 +118,77 @@ test('provisions and verifies the pinned product toolchain inside the isolated A
|
|||||||
assert.equal(result.paths.streamlink, fs.realpathSync.native(path.join(initialized.streamlinkDirectory, 'bin', 'streamlink.exe')));
|
assert.equal(result.paths.streamlink, fs.realpathSync.native(path.join(initialized.streamlinkDirectory, 'bin', 'streamlink.exe')));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('accepts a Windows ShortPath root when managed executables resolve to the same long path', { skip: process.platform !== 'win32' }, async (t) => {
|
||||||
|
const environment = createEnvironment();
|
||||||
|
t.after(() => fs.rmSync(environment.rootDir, { recursive: true, force: true }));
|
||||||
|
const shortRoot = getWindowsShortPath(environment.rootDir);
|
||||||
|
const longRoot = fs.realpathSync.native(environment.rootDir);
|
||||||
|
if (!shortRoot || shortRoot.toLowerCase() === longRoot.toLowerCase()) {
|
||||||
|
t.skip('8.3 short names are unavailable');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const shortEnvironment = {
|
||||||
|
rootDir: shortRoot,
|
||||||
|
appDataDir: path.join(shortRoot, 'programdata', 'Twitch_VOD_Manager')
|
||||||
|
};
|
||||||
|
const fixture = createProvisioningFixture();
|
||||||
|
|
||||||
|
const result = await provisionManagedCutterTools(shortEnvironment, {
|
||||||
|
loadBuiltArtifacts: () => ({ tools: fixture.tools, manifest: fixture.manifest }),
|
||||||
|
runVersionCheck: (executablePath, _args, label) => {
|
||||||
|
if (label.includes('Streamlink')) return 'Streamlink 8.4.0';
|
||||||
|
if (path.basename(executablePath).toLowerCase() === 'ffprobe.exe') return 'ffprobe version 8.1.2';
|
||||||
|
return 'ffmpeg version 8.1.2';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.paths.streamlink.startsWith(longRoot), true);
|
||||||
|
assert.equal(result.paths.ffmpeg.startsWith(longRoot), true);
|
||||||
|
assert.equal(result.paths.ffprobe.startsWith(longRoot), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a nonexistent managed directory below a junction before writing outside the owned tree', { skip: process.platform !== 'win32' }, async (t) => {
|
||||||
|
const environment = createEnvironment();
|
||||||
|
const outsideRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-cutter-junction-outside-'));
|
||||||
|
t.after(() => fs.rmSync(environment.rootDir, { recursive: true, force: true }));
|
||||||
|
t.after(() => fs.rmSync(outsideRoot, { recursive: true, force: true }));
|
||||||
|
const junctionPath = path.join(environment.rootDir, 'escaped');
|
||||||
|
fs.symlinkSync(outsideRoot, junctionPath, 'junction');
|
||||||
|
const fixture = createProvisioningFixture();
|
||||||
|
let loadedArtifacts = false;
|
||||||
|
|
||||||
|
await assert.rejects(() => provisionManagedCutterTools({
|
||||||
|
rootDir: environment.rootDir,
|
||||||
|
appDataDir: path.join(junctionPath, 'programdata', 'Twitch_VOD_Manager')
|
||||||
|
}, {
|
||||||
|
loadBuiltArtifacts: () => {
|
||||||
|
loadedArtifacts = true;
|
||||||
|
return { tools: fixture.tools, manifest: fixture.manifest };
|
||||||
|
},
|
||||||
|
runVersionCheck: () => '8.1.2'
|
||||||
|
}), /outside the owned managed-tool directory/);
|
||||||
|
|
||||||
|
assert.equal(loadedArtifacts, false);
|
||||||
|
assert.equal(fs.existsSync(path.join(outsideRoot, 'programdata')), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts casing differences for the same existing Windows tree', { skip: process.platform !== 'win32' }, async (t) => {
|
||||||
|
const environment = createEnvironment();
|
||||||
|
t.after(() => fs.rmSync(environment.rootDir, { recursive: true, force: true }));
|
||||||
|
const fixture = createProvisioningFixture();
|
||||||
|
const upperEnvironment = {
|
||||||
|
rootDir: environment.rootDir.toUpperCase(),
|
||||||
|
appDataDir: environment.appDataDir.toUpperCase()
|
||||||
|
};
|
||||||
|
|
||||||
|
await assert.doesNotReject(() => provisionManagedCutterTools(upperEnvironment, {
|
||||||
|
loadBuiltArtifacts: () => ({ tools: fixture.tools, manifest: fixture.manifest }),
|
||||||
|
runVersionCheck: (_executablePath, _args, label) => label === 'Streamlink'
|
||||||
|
? 'Streamlink 8.4.0'
|
||||||
|
: `${label.toLowerCase()} version 8.1.2`
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
test('rejects a product tool path that escapes the owned installation directory', async (t) => {
|
test('rejects a product tool path that escapes the owned installation directory', async (t) => {
|
||||||
const environment = createEnvironment();
|
const environment = createEnvironment();
|
||||||
t.after(() => fs.rmSync(environment.rootDir, { recursive: true, force: true }));
|
t.after(() => fs.rmSync(environment.rootDir, { recursive: true, force: true }));
|
||||||
|
|||||||
Reference in New Issue
Block a user