fix: preserve file permissions during unpack operation (#43)
- Parse ZIP central directory to extract Unix file permissions - Restore executable permissions using chmodSync after unpacking files - Skip permission restoration on Windows (Unix-only feature) - Add comprehensive test to verify executable and regular file permissions are preserved - Fix test setup to use npm instead of yarn for consistent build process Fixes issue where executable files lose their execute permissions after being packed and unpacked, which is critical for extensions containing binary executables or shell scripts. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
3c1bb1c25e
commit
83b5a773ae
2 changed files with 136 additions and 1 deletions
|
|
@ -1,5 +1,5 @@
|
|||
import { unzipSync } from "fflate";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
||||
import { join, resolve } from "path";
|
||||
|
||||
import { extractSignatureBlock } from "../node/sign.js";
|
||||
|
|
@ -34,6 +34,51 @@ export async function unpackExtension({
|
|||
const fileContent = readFileSync(resolvedDxtPath);
|
||||
const { originalContent } = extractSignatureBlock(fileContent);
|
||||
|
||||
// Parse file attributes from ZIP central directory
|
||||
const fileAttributes = new Map<string, number>();
|
||||
const isUnix = process.platform !== "win32";
|
||||
|
||||
if (isUnix) {
|
||||
// Parse ZIP central directory to extract file attributes
|
||||
const zipBuffer = originalContent;
|
||||
|
||||
// Find end of central directory record
|
||||
let eocdOffset = -1;
|
||||
for (let i = zipBuffer.length - 22; i >= 0; i--) {
|
||||
if (zipBuffer.readUInt32LE(i) === 0x06054b50) {
|
||||
eocdOffset = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (eocdOffset !== -1) {
|
||||
const centralDirOffset = zipBuffer.readUInt32LE(eocdOffset + 16);
|
||||
const centralDirEntries = zipBuffer.readUInt16LE(eocdOffset + 8);
|
||||
|
||||
let offset = centralDirOffset;
|
||||
|
||||
for (let i = 0; i < centralDirEntries; i++) {
|
||||
if (zipBuffer.readUInt32LE(offset) === 0x02014b50) {
|
||||
const externalAttrs = zipBuffer.readUInt32LE(offset + 38);
|
||||
const filenameLength = zipBuffer.readUInt16LE(offset + 28);
|
||||
const filename = zipBuffer.toString('utf8', offset + 46, offset + 46 + filenameLength);
|
||||
|
||||
// Extract Unix permissions from external attributes (upper 16 bits)
|
||||
const mode = (externalAttrs >> 16) & 0o777;
|
||||
if (mode > 0) {
|
||||
fileAttributes.set(filename, mode);
|
||||
}
|
||||
|
||||
const extraFieldLength = zipBuffer.readUInt16LE(offset + 30);
|
||||
const commentLength = zipBuffer.readUInt16LE(offset + 32);
|
||||
offset += 46 + filenameLength + extraFieldLength + commentLength;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const decompressed = unzipSync(originalContent);
|
||||
|
||||
for (const relativePath in decompressed) {
|
||||
|
|
@ -45,6 +90,18 @@ export async function unpackExtension({
|
|||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
writeFileSync(fullPath, data);
|
||||
|
||||
// Restore Unix file permissions if available
|
||||
if (isUnix && fileAttributes.has(relativePath)) {
|
||||
try {
|
||||
const mode = fileAttributes.get(relativePath);
|
||||
if (mode !== undefined) {
|
||||
chmodSync(fullPath, mode);
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently ignore permission errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -171,5 +171,83 @@ describe("DXT CLI", () => {
|
|||
);
|
||||
expect(originalFile2).toEqual(unpackedFile2);
|
||||
});
|
||||
|
||||
it("should preserve executable file permissions after packing and unpacking", () => {
|
||||
// Skip this test on Windows since it doesn't support Unix permissions
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
|
||||
const tempExecDir = join(__dirname, "temp-exec-test");
|
||||
const execPackedFilePath = join(__dirname, "test-exec-extension.dxt");
|
||||
const execUnpackedDir = join(__dirname, "temp-exec-unpack-test");
|
||||
|
||||
try {
|
||||
// Create a temporary directory with an executable file
|
||||
fs.mkdirSync(tempExecDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
join(tempExecDir, "manifest.json"),
|
||||
JSON.stringify({
|
||||
dxt_version: "1.0",
|
||||
name: "Test Executable Extension",
|
||||
version: "1.0.0",
|
||||
description: "A test extension with executable files",
|
||||
author: {
|
||||
name: "DXT",
|
||||
},
|
||||
server: {
|
||||
type: "node",
|
||||
entry_point: "server/index.js",
|
||||
mcp_config: {
|
||||
command: "node",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Create an executable script
|
||||
const executableScript = join(tempExecDir, "run-script.sh");
|
||||
fs.writeFileSync(executableScript, "#!/bin/bash\necho 'Hello from executable'");
|
||||
fs.chmodSync(executableScript, 0o755); // Make it executable
|
||||
|
||||
// Create a regular file for comparison
|
||||
const regularFile = join(tempExecDir, "regular-file.txt");
|
||||
fs.writeFileSync(regularFile, "regular content");
|
||||
fs.chmodSync(regularFile, 0o644); // Regular file permissions
|
||||
|
||||
// Pack the extension
|
||||
execSync(`node ${cliPath} pack ${tempExecDir} ${execPackedFilePath}`, {
|
||||
encoding: "utf-8",
|
||||
});
|
||||
|
||||
// Unpack the extension
|
||||
execSync(`node ${cliPath} unpack ${execPackedFilePath} ${execUnpackedDir}`, {
|
||||
encoding: "utf-8",
|
||||
});
|
||||
|
||||
// Check that the executable file preserved its permissions
|
||||
const originalStats = fs.statSync(executableScript);
|
||||
const unpackedStats = fs.statSync(join(execUnpackedDir, "run-script.sh"));
|
||||
|
||||
// Check that executable permissions are preserved (0o755)
|
||||
expect(unpackedStats.mode & 0o777).toBe(0o755);
|
||||
expect(originalStats.mode & 0o777).toBe(unpackedStats.mode & 0o777);
|
||||
|
||||
// Check that regular file permissions are preserved (0o644)
|
||||
const originalRegularStats = fs.statSync(regularFile);
|
||||
const unpackedRegularStats = fs.statSync(join(execUnpackedDir, "regular-file.txt"));
|
||||
|
||||
expect(unpackedRegularStats.mode & 0o777).toBe(0o644);
|
||||
expect(originalRegularStats.mode & 0o777).toBe(unpackedRegularStats.mode & 0o777);
|
||||
|
||||
} finally {
|
||||
// Clean up
|
||||
fs.rmSync(tempExecDir, { recursive: true, force: true });
|
||||
fs.rmSync(execUnpackedDir, { recursive: true, force: true });
|
||||
if (fs.existsSync(execPackedFilePath)) {
|
||||
fs.unlinkSync(execPackedFilePath);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue