Skip to content
Draft
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
85 changes: 85 additions & 0 deletions pkgs/npm/src/clilib.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import AdmZip from "adm-zip";
import axios from "axios";
import * as child_process from "child_process";
import * as crypto from "crypto";
import * as fs from "fs";
import * as path from "path";
import * as tar from "tar";
Expand Down Expand Up @@ -73,6 +74,68 @@ async function downloadFile(
}
}

async function downloadChecksumFile(
version: string,
outputPath: string
): Promise<string | null> {
const checksumFilename = `defang_v${version}_checksums.txt`;
const downloadUrl = `https://s.defang.io/${checksumFilename}?x-defang-source=npm`;
const downloadTargetFile = path.join(outputPath, checksumFilename);

return await downloadFile(downloadUrl, downloadTargetFile);
}

async function verifyChecksum(
archiveFilePath: string,
checksumFilePath: string
): Promise<boolean> {
try {
// Read the checksum file
const checksumContent = await fs.promises.readFile(checksumFilePath, "utf8");

// Get the archive filename (without path)
const archiveFilename = path.basename(archiveFilePath);

// Find the line with the checksum for our archive
const lines = checksumContent.split('\n');
let expectedChecksum: string | null = null;

for (const line of lines) {
// Format: <checksum> <filename>
const parts = line.trim().split(/\s+/);
if (parts.length >= 2 && parts[parts.length - 1] === archiveFilename) {
expectedChecksum = parts[0];
break;
}
}

if (!expectedChecksum) {
console.warn(`Checksum for ${archiveFilename} not found in checksum file.`);
return false;
}

// Calculate the actual checksum
const fileBuffer = await fs.promises.readFile(archiveFilePath);
const hash = crypto.createHash('sha256');
hash.update(fileBuffer);
const actualChecksum = hash.digest('hex');

// Compare checksums
if (actualChecksum !== expectedChecksum) {
console.error('Checksum verification failed!');
console.error(`Expected: ${expectedChecksum}`);
console.error(`Got: ${actualChecksum}`);
return false;
}

console.log('Checksum verification passed.');
return true;
} catch (error) {
console.error(`Error verifying checksum: ${error}`);
return false;
}
}

async function extractArchive(
archiveFilePath: string,
outputPath: string
Expand Down Expand Up @@ -312,6 +375,26 @@ export async function install(
throw new Error(`Failed to download ${filename}`);
}

// Download and verify checksum
console.log('Downloading checksum file...');
const checksumFile = await downloadChecksumFile(version, saveDirectory);

if (checksumFile) {
console.log('Verifying checksum...');
const isValid = await verifyChecksum(archiveFile, checksumFile);

// Clean up checksum file
await deleteArchive(checksumFile);

if (!isValid) {
// Clean up archive file if checksum verification failed
await deleteArchive(archiveFile);
throw new Error('Checksum verification failed! The downloaded file may be corrupted or tampered with.');
}
} else {
console.warn('Warning: Could not download checksum file. Skipping checksum verification.');
}

// Because the releases are compressed tar.gz or .zip we need to
// uncompress them to the ./bin directory in the package in node_modules.
const result = await extractArchive(archiveFile, saveDirectory);
Expand Down Expand Up @@ -381,6 +464,7 @@ export async function run(): Promise<void> {
const clilib = {
deleteArchive,
downloadAppArchive,
downloadChecksumFile,
downloadFile,
extractArchive,
extractCLIVersions,
Expand All @@ -390,6 +474,7 @@ const clilib = {
getLatestVersion,
getVersionInfo,
getPathToExecutable,
verifyChecksum,
};

export default clilib;
113 changes: 113 additions & 0 deletions pkgs/npm/test/clilib.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import axios, {type AxiosResponse } from "axios";
import * as chai from "chai";
import chaiAsPromised from "chai-as-promised";
import * as crypto from "crypto";
import fs from "fs";
import "mocha";
import * as sinon from "sinon";
Expand Down Expand Up @@ -214,3 +215,115 @@ describe("Testing extractCLIVersions()", () => {
});

});

describe("Testing verifyChecksum()", () => {
let readFileStub: sinon.SinonStub;

beforeEach(() => {
sandbox = sinon.createSandbox();
});

afterEach(() => {
sandbox.restore();
});

it("verifies valid checksum", async () => {
const archivePath = "/path/to/archive.tar.gz";
const checksumPath = "/path/to/checksums.txt";

// Mock checksum file content
const checksumContent = "abc123def456 archive.tar.gz\n";

// Mock file reading for checksum file
readFileStub = sandbox.stub(fs.promises, "readFile");
readFileStub.withArgs(checksumPath, "utf8").resolves(checksumContent);

// Mock file reading for archive (return buffer that will hash to abc123def456)
const archiveBuffer = Buffer.from("test data");
readFileStub.withArgs(archivePath).resolves(archiveBuffer);

// Calculate the actual hash using the statically imported crypto
const hash = crypto.createHash('sha256');
hash.update(archiveBuffer);
const actualHash = hash.digest('hex');

// Update the checksum content to match the actual hash
readFileStub.withArgs(checksumPath, "utf8").resolves(`${actualHash} archive.tar.gz\n`);

const result = await clilib.verifyChecksum(archivePath, checksumPath);
expect(result).to.be.true;
});

it("fails verification with wrong checksum", async () => {
const archivePath = "/path/to/archive.tar.gz";
const checksumPath = "/path/to/checksums.txt";

// Mock checksum file content with wrong hash
const checksumContent = "wronghash123 archive.tar.gz\n";

readFileStub = sandbox.stub(fs.promises, "readFile");
readFileStub.withArgs(checksumPath, "utf8").resolves(checksumContent);
readFileStub.withArgs(archivePath).resolves(Buffer.from("test data"));

const result = await clilib.verifyChecksum(archivePath, checksumPath);
expect(result).to.be.false;
});

it("returns false when checksum not found", async () => {
const archivePath = "/path/to/archive.tar.gz";
const checksumPath = "/path/to/checksums.txt";

// Mock checksum file content without matching archive
const checksumContent = "abc123def456 other-file.tar.gz\n";

readFileStub = sandbox.stub(fs.promises, "readFile");
readFileStub.withArgs(checksumPath, "utf8").resolves(checksumContent);

const result = await clilib.verifyChecksum(archivePath, checksumPath);
expect(result).to.be.false;
});

it("handles errors gracefully", async () => {
const archivePath = "/path/to/archive.tar.gz";
const checksumPath = "/path/to/checksums.txt";

readFileStub = sandbox.stub(fs.promises, "readFile");
readFileStub.rejects(new Error("File read error"));

const result = await clilib.verifyChecksum(archivePath, checksumPath);
expect(result).to.be.false;
});
});

describe("Testing downloadChecksumFile()", () => {
let downloadFileStub: sinon.SinonStub;

beforeEach(() => {
sandbox = sinon.createSandbox();
});

afterEach(() => {
sandbox.restore();
});

it("downloads checksum file with correct URL", async () => {
const version = "0.5.32";
const outputPath = "/tmp";
const expectedFilename = "defang_v0.5.32_checksums.txt";
const expectedUrl = `https://s.defang.io/${expectedFilename}?x-defang-source=npm`;
const expectedPath = `/tmp/${expectedFilename}`;

const axiosStub = sandbox.stub(axios, "get");
axiosStub.resolves({
status: 200,
data: Buffer.from("test checksum data"),
});

const writeStub = sandbox.stub(fs.promises, "writeFile").resolves();

const result = await clilib.downloadChecksumFile(version, outputPath);

sinon.assert.calledWith(axiosStub, expectedUrl, sinon.match.any);
expect(result).to.equal(expectedPath);
});
});
48 changes: 48 additions & 0 deletions src/bin/install
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,54 @@ if ! curl -fsSL "$DOWNLOAD_URL" -o "$FILENAME"; then
return 4
fi

# Extract version from the release JSON
VERSION=$(echo "$RELEASE_JSON" | grep -o '"tag_name":[[:space:]]*"[^"]*"' | head -n 1 | sed 's/"tag_name":[[:space:]]*"\(.*\)"/\1/')

# Download and verify checksum
CHECKSUM_FILENAME="defang_${VERSION}_checksums.txt"
CHECKSUM_URL="https://github.com/DefangLabs/defang/releases/download/${VERSION}/${CHECKSUM_FILENAME}"

echo "Downloading checksums from $CHECKSUM_URL..."
if ! curl -fsSL "$CHECKSUM_URL" -o "$CHECKSUM_FILENAME"; then
echo "Warning: Failed to download checksum file. Skipping checksum verification."
else
# Extract the actual archive filename from the download URL
ARCHIVE_NAME=$(basename "$DOWNLOAD_URL")

# Find the checksum for our file in the checksum file
EXPECTED_CHECKSUM=$(grep "$ARCHIVE_NAME" "$CHECKSUM_FILENAME" | awk '{print $1}')

if [ -z "$EXPECTED_CHECKSUM" ]; then
echo "Warning: Checksum for $ARCHIVE_NAME not found in checksum file. Skipping verification."
else
echo "Verifying checksum..."
# Calculate the actual checksum of the downloaded file
if command -v sha256sum > /dev/null 2>&1; then
ACTUAL_CHECKSUM=$(sha256sum "$FILENAME" | awk '{print $1}')
elif command -v shasum > /dev/null 2>&1; then
ACTUAL_CHECKSUM=$(shasum -a 256 "$FILENAME" | awk '{print $1}')
else
echo "Warning: Neither sha256sum nor shasum found. Skipping checksum verification."
ACTUAL_CHECKSUM=""
fi

if [ -n "$ACTUAL_CHECKSUM" ]; then
if [ "$ACTUAL_CHECKSUM" = "$EXPECTED_CHECKSUM" ]; then
echo "Checksum verification passed."
else
echo "Error: Checksum verification failed!"
echo "Expected: $EXPECTED_CHECKSUM"
echo "Got: $ACTUAL_CHECKSUM"
rm -f "$FILENAME" "$CHECKSUM_FILENAME"
return 11
fi
fi
fi

# Clean up checksum file
rm -f "$CHECKSUM_FILENAME"
fi

# Create a temporary directory for extraction
EXTRACT_DIR=$(mktemp -d)

Expand Down