feat: introduced nix packaging and path-based binary resolution
- Add comprehensive Nix flake definitions, derivations, modules, and CI workflows. - Update tests and executables to resolve binaries from PATH rather than absolute paths. - Ensure byte reproducibility and zeroed timestamps in embedded dashboard archives. - Add handling for Nix-managed installations in CLI update checks.
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Made the embedded dashboard archive byte-reproducible by sorting entries and zeroing tar and gzip timestamps before it is compiled into OMP.
|
||||
|
||||
## [17.2.10] - 2026-08-06
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { $ } from "bun";
|
||||
|
||||
@@ -11,6 +10,14 @@ const DIST_CLIENT_DIR = path.join("dist", "client");
|
||||
const GENERATE_FLAG = "--generate";
|
||||
const RESET_FLAG = "--reset";
|
||||
|
||||
const TAR_BLOCK_SIZE = 512;
|
||||
const TAR_SIZE_OFFSET = 124;
|
||||
const TAR_SIZE_LENGTH = 12;
|
||||
const TAR_MTIME_OFFSET = 136;
|
||||
const TAR_MTIME_LENGTH = 12;
|
||||
const TAR_CHECKSUM_OFFSET = 148;
|
||||
const TAR_CHECKSUM_LENGTH = 8;
|
||||
|
||||
// `--reset` restores the checked-in state: an empty file. The runtime treats
|
||||
// blank (or any non-base64) content as "no archive embedded" and builds the
|
||||
// dashboard from source instead; see src/embedded-client.ts.
|
||||
@@ -26,29 +33,60 @@ async function collectFiles(dir: string): Promise<string[]> {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
files.sort((a, b) => a.localeCompare(b));
|
||||
files.sort();
|
||||
return files;
|
||||
}
|
||||
|
||||
async function buildArchiveBase64(dir: string): Promise<string> {
|
||||
function readTarOctal(bytes: Uint8Array, offset: number, length: number): number {
|
||||
const value = Buffer.from(bytes.subarray(offset, offset + length))
|
||||
.toString("ascii")
|
||||
.replace(/\0.*$/, "")
|
||||
.trim();
|
||||
return value ? Number.parseInt(value, 8) : 0;
|
||||
}
|
||||
|
||||
function writeTarOctal(bytes: Uint8Array, offset: number, length: number, value: number): void {
|
||||
const octal = value.toString(8);
|
||||
if (octal.length >= length) throw new Error(`Tar value ${value} does not fit in ${length} bytes`);
|
||||
bytes.fill(0x30, offset, offset + length - 1);
|
||||
bytes.set(Buffer.from(octal), offset + length - 1 - octal.length);
|
||||
bytes[offset + length - 1] = 0;
|
||||
}
|
||||
|
||||
function normalizeTarMetadata(bytes: Uint8Array): void {
|
||||
for (let offset = 0; offset + TAR_BLOCK_SIZE <= bytes.length; ) {
|
||||
const header = bytes.subarray(offset, offset + TAR_BLOCK_SIZE);
|
||||
if (header.every(byte => byte === 0)) return;
|
||||
|
||||
const size = readTarOctal(header, TAR_SIZE_OFFSET, TAR_SIZE_LENGTH);
|
||||
writeTarOctal(header, TAR_MTIME_OFFSET, TAR_MTIME_LENGTH, 0);
|
||||
header.fill(0x20, TAR_CHECKSUM_OFFSET, TAR_CHECKSUM_OFFSET + TAR_CHECKSUM_LENGTH);
|
||||
|
||||
let checksum = 0;
|
||||
for (const byte of header) checksum += byte;
|
||||
const checksumOctal = checksum.toString(8).padStart(6, "0");
|
||||
if (checksumOctal.length > 6) throw new Error(`Tar checksum ${checksum} exceeds the header field`);
|
||||
header.set(Buffer.from(checksumOctal), TAR_CHECKSUM_OFFSET);
|
||||
header[TAR_CHECKSUM_OFFSET + 6] = 0;
|
||||
header[TAR_CHECKSUM_OFFSET + 7] = 0x20;
|
||||
|
||||
offset += TAR_BLOCK_SIZE * (1 + Math.ceil(size / TAR_BLOCK_SIZE));
|
||||
if (offset > bytes.length) throw new Error("Tar entry extends beyond the archive");
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a byte-stable gzip archive of a directory for embedding in the OMP binary. */
|
||||
export async function buildArchiveBase64(dir: string): Promise<string> {
|
||||
const files = await collectFiles(dir);
|
||||
const entries: Record<string, Uint8Array> = {};
|
||||
for (const filePath of files) {
|
||||
const relativePath = path.relative(dir, filePath).split(path.sep).join("/");
|
||||
entries[relativePath] = await fs.readFile(filePath);
|
||||
entries[relativePath] = await Bun.file(filePath).bytes();
|
||||
}
|
||||
|
||||
const tempArchivePath = path.join(
|
||||
os.tmpdir(),
|
||||
`omp-stats-client-${Bun.hash(Date.now().toString() + Math.random().toString(16)).toString(16)}.tar.gz`,
|
||||
);
|
||||
try {
|
||||
await Bun.Archive.write(tempArchivePath, entries, { compress: "gzip" });
|
||||
const archiveBytes = await Bun.file(tempArchivePath).bytes();
|
||||
return Buffer.from(archiveBytes).toString("base64");
|
||||
} finally {
|
||||
await fs.rm(tempArchivePath, { force: true });
|
||||
}
|
||||
const archiveBytes = await new Bun.Archive(entries).bytes();
|
||||
normalizeTarMetadata(archiveBytes);
|
||||
return Buffer.from(Bun.gzipSync(archiveBytes, { level: 9 })).toString("base64");
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
@@ -69,4 +107,4 @@ async function main(): Promise<void> {
|
||||
console.log(`Generated ${GENERATED_FILE}`);
|
||||
}
|
||||
|
||||
await main();
|
||||
if (import.meta.main) await main();
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { buildArchiveBase64 } from "../scripts/generate-client-bundle";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function createFixture(order: readonly string[]): Promise<string> {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "omp-stats-archive-"));
|
||||
tempDirs.push(root);
|
||||
for (const relativePath of order) {
|
||||
const filePath = path.join(root, relativePath);
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await Bun.write(filePath, relativePath === "index.html" ? "<main>OMP</main>" : "body { color: blue; }");
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
function tarHeaderMtimes(bytes: Uint8Array): number[] {
|
||||
const mtimes: number[] = [];
|
||||
for (let offset = 0; offset + 512 <= bytes.length; ) {
|
||||
const header = bytes.subarray(offset, offset + 512);
|
||||
if (header.every(byte => byte === 0)) break;
|
||||
const sizeField = Buffer.from(header.subarray(124, 136)).toString("ascii").replace(/\0.*$/, "").trim();
|
||||
const mtimeField = Buffer.from(header.subarray(136, 148)).toString("ascii").replace(/\0.*$/, "").trim();
|
||||
const size = sizeField ? Number.parseInt(sizeField, 8) : 0;
|
||||
mtimes.push(mtimeField ? Number.parseInt(mtimeField, 8) : 0);
|
||||
offset += 512 * (1 + Math.ceil(size / 512));
|
||||
}
|
||||
return mtimes;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map(dir => fs.rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("embedded stats client archive", () => {
|
||||
test("is byte-stable across filesystem order and carries zero timestamps", async () => {
|
||||
const firstDir = await createFixture(["index.html", "assets/app.css"]);
|
||||
const secondDir = await createFixture(["assets/app.css", "index.html"]);
|
||||
|
||||
const first = await buildArchiveBase64(firstDir);
|
||||
const second = await buildArchiveBase64(secondDir);
|
||||
expect(second).toBe(first);
|
||||
|
||||
const gzipBytes = Buffer.from(first, "base64");
|
||||
expect(gzipBytes.readUInt32LE(4)).toBe(0);
|
||||
const tarBytes = Bun.gunzipSync(gzipBytes);
|
||||
expect(tarHeaderMtimes(tarBytes)).toEqual([0, 0]);
|
||||
|
||||
const files = await new Bun.Archive(gzipBytes).files();
|
||||
expect(await files.get("index.html")?.text()).toBe("<main>OMP</main>");
|
||||
expect(await files.get("assets/app.css")?.text()).toBe("body { color: blue; }");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user