import { describe, expect, it } from 'vitest' import { createHash } from 'node:crypto' import { gzipSync } from 'node:zlib' import { detectFrpcPlatform, FRPC_PLATFORMS, FRPC_RELEASES, provisionFrpc, type FrpcPlatform, type FrpcReleaseRef, type ProvisionFrpcDeps, } from '../src/provision/frpcBinary.js' import { extractTarFileByBasename, extractFrpcBinary, TarExtractError, } from '../src/provision/untar.js' function sha256Hex(data: Uint8Array): string { return createHash('sha256').update(data).digest('hex') } // --------------------------------------------------------------------------- // In-test tar/gzip fixture builder — hand-builds real USTAR blocks so the // extractor is exercised against the SAME on-disk layout frp ships (dir entry // + regular files), with zero network access. // --------------------------------------------------------------------------- const TAR_BLOCK = 512 const TYPE_FILE = '0' const TYPE_DIR = '5' interface TarEntrySpec { readonly name: string readonly content: Uint8Array readonly typeflag?: string } function writeOctalField(block: Uint8Array, offset: number, len: number, value: number): void { const s = value.toString(8).padStart(len - 1, '0') block.set(new TextEncoder().encode(s), offset) block[offset + len - 1] = 0 // NUL terminator } function tarHeader(name: string, size: number, typeflag: string): Uint8Array { const block = new Uint8Array(TAR_BLOCK) const enc = new TextEncoder() block.set(enc.encode(name).subarray(0, 100), 0) writeOctalField(block, 100, 8, 0o755) // mode writeOctalField(block, 108, 8, 0) // uid writeOctalField(block, 116, 8, 0) // gid writeOctalField(block, 124, 12, size) // size writeOctalField(block, 136, 12, 0) // mtime block[156] = typeflag.charCodeAt(0) block.set(enc.encode('ustar'), 257) // magic "ustar\0" block[263] = 0x30 // version "00" block[264] = 0x30 // checksum: fields spaces during compute, then written as 6 octal + NUL + space for (let i = 148; i < 156; i++) block[i] = 0x20 let sum = 0 for (let i = 0; i < TAR_BLOCK; i++) sum += block[i] ?? 0 block.set(enc.encode(sum.toString(8).padStart(6, '0')), 148) block[154] = 0 block[155] = 0x20 return block } function concatBytes(parts: readonly Uint8Array[]): Uint8Array { const total = parts.reduce((n, p) => n + p.length, 0) const out = new Uint8Array(total) let off = 0 for (const p of parts) { out.set(p, off) off += p.length } return out } function makeTar(entries: readonly TarEntrySpec[]): Uint8Array { const parts: Uint8Array[] = [] for (const e of entries) { parts.push(tarHeader(e.name, e.content.length, e.typeflag ?? TYPE_FILE)) parts.push(e.content) const pad = (TAR_BLOCK - (e.content.length % TAR_BLOCK)) % TAR_BLOCK if (pad > 0) parts.push(new Uint8Array(pad)) } parts.push(new Uint8Array(TAR_BLOCK * 2)) // end-of-archive: two zero blocks return concatBytes(parts) } function makeTarGz(entries: readonly TarEntrySpec[]): Uint8Array { return new Uint8Array(gzipSync(makeTar(entries))) } const FRPC_BYTES = new Uint8Array([0x7f, 0x45, 0x4c, 0x46, 0xde, 0xad, 0xbe, 0xef]) // fake "ELF" frpc const FRPS_BYTES = new Uint8Array([0x7f, 0x45, 0x4c, 0x46, 0x00, 0x11, 0x22, 0x33]) // decoy frps /** A realistic frp archive layout: dir entry + frpc + decoy frps + LICENSE. */ function realisticFrpTarGz(prefix = 'frp_0.61.1_darwin_arm64'): Uint8Array { return makeTarGz([ { name: `${prefix}/`, content: new Uint8Array(0), typeflag: TYPE_DIR }, { name: `${prefix}/frps`, content: FRPS_BYTES }, { name: `${prefix}/frpc`, content: FRPC_BYTES }, { name: `${prefix}/LICENSE`, content: new TextEncoder().encode('MIT') }, ]) } interface FakeFsState { writes: Map renames: Array<{ from: string; to: string }> removed: string[] chmods: Array<{ path: string; mode: number }> mkdirs: string[] } function makeFakeDeps(bytesByUrl: Record): { deps: ProvisionFrpcDeps state: FakeFsState fetchedUrls: string[] } { const state: FakeFsState = { writes: new Map(), renames: [], removed: [], chmods: [], mkdirs: [], } const fetchedUrls: string[] = [] const deps: ProvisionFrpcDeps = { fetch: async (url) => { fetchedUrls.push(url) const bytes = bytesByUrl[url] if (!bytes) throw new Error(`test: no fixture bytes for ${url}`) return bytes }, fs: { mkdir: async (dir) => { state.mkdirs.push(dir) }, writeFile: async (path, data) => { state.writes.set(path, data) }, rename: async (from, to) => { state.renames.push({ from, to }) const data = state.writes.get(from) if (data) { state.writes.delete(from) state.writes.set(to, data) } }, chmod: async (path, mode) => { state.chmods.push({ path, mode }) }, rm: async (path) => { state.removed.push(path) state.writes.delete(path) }, }, } return { deps, state, fetchedUrls } } function releaseOverride( platform: FrpcPlatform, ref: FrpcReleaseRef, ): Record { return { ...FRPC_RELEASES, [platform]: ref } } const BIN_DIR = '/opt/wt/bin' const BIN_PATH = '/opt/wt/bin/frpc' describe('detectFrpcPlatform (B3)', () => { it('maps darwin/linux × arm64/amd64 (node x64 → amd64)', () => { expect(detectFrpcPlatform('darwin', 'arm64')).toBe('darwin-arm64') expect(detectFrpcPlatform('darwin', 'x64')).toBe('darwin-amd64') expect(detectFrpcPlatform('linux', 'arm64')).toBe('linux-arm64') expect(detectFrpcPlatform('linux', 'x64')).toBe('linux-amd64') }) it('returns null for unsupported platform/arch', () => { expect(detectFrpcPlatform('win32', 'x64')).toBeNull() expect(detectFrpcPlatform('linux', 'ia32')).toBeNull() expect(detectFrpcPlatform('freebsd', 'arm64')).toBeNull() expect(detectFrpcPlatform('darwin', 'mips')).toBeNull() }) }) describe('FRPC_RELEASES pinning', () => { it('pins a release per supported platform (https url, semver, 64-hex sha256)', () => { expect([...FRPC_PLATFORMS].sort()).toEqual([ 'darwin-amd64', 'darwin-arm64', 'linux-amd64', 'linux-arm64', ]) for (const platform of FRPC_PLATFORMS) { const ref = FRPC_RELEASES[platform] expect(ref.url.startsWith('https://')).toBe(true) expect(ref.url.endsWith('.tar.gz')).toBe(true) expect(ref.version).toMatch(/^\d+\.\d+\.\d+$/) expect(ref.sha256).toMatch(/^[0-9a-f]{64}$/) } }) it('pins REAL (non-placeholder) frp v0.61.1 checksums', () => { for (const platform of FRPC_PLATFORMS) { expect(FRPC_RELEASES[platform].sha256).not.toBe('0'.repeat(64)) } }) }) describe('extractTarFileByBasename (tar path-traversal safe extractor)', () => { it('returns the bytes of the first regular file whose basename matches', () => { const tar = makeTar([ { name: 'frp_x/', content: new Uint8Array(0), typeflag: TYPE_DIR }, { name: 'frp_x/frps', content: FRPS_BYTES }, { name: 'frp_x/frpc', content: FRPC_BYTES }, ]) expect(extractTarFileByBasename(tar, 'frpc')).toEqual(FRPC_BYTES) expect(extractTarFileByBasename(tar, 'frps')).toEqual(FRPS_BYTES) }) it('does NOT match a directory entry that shares the basename', () => { const tar = makeTar([ { name: 'frpc/', content: new Uint8Array(0), typeflag: TYPE_DIR }, { name: 'frp_x/frpc', content: FRPC_BYTES }, ]) expect(extractTarFileByBasename(tar, 'frpc')).toEqual(FRPC_BYTES) }) it('throws when no matching file entry exists', () => { const tar = makeTar([{ name: 'frp_x/frps', content: FRPS_BYTES }]) expect(() => extractTarFileByBasename(tar, 'frpc')).toThrow(TarExtractError) }) it('REJECTS a matching entry whose name contains a ".." traversal segment', () => { const tar = makeTar([{ name: 'frp_x/../../../tmp/frpc', content: FRPC_BYTES }]) expect(() => extractTarFileByBasename(tar, 'frpc')).toThrow(/unsafe|traversal|\.\./i) }) it('REJECTS a matching entry with an absolute path name', () => { const tar = makeTar([{ name: '/etc/frpc', content: FRPC_BYTES }]) expect(() => extractTarFileByBasename(tar, 'frpc')).toThrow(/unsafe|absolute/i) }) it('throws on a corrupt (truncated) tar rather than reading out of bounds', () => { const full = makeTar([{ name: 'frp_x/frpc', content: FRPC_BYTES }]) const truncated = full.subarray(0, TAR_BLOCK + 4) // header + partial content expect(() => extractTarFileByBasename(truncated, 'frpc')).toThrow(TarExtractError) }) }) describe('extractFrpcBinary (gunzip + extract)', () => { it('gunzips then extracts the inner frpc bytes', () => { expect(extractFrpcBinary(realisticFrpTarGz())).toEqual(FRPC_BYTES) }) it('throws on non-gzip input', () => { expect(() => extractFrpcBinary(new Uint8Array([1, 2, 3, 4]))).toThrow(TarExtractError) }) }) describe('provisionFrpc (B3 verify-download + extract discipline)', () => { it('selects the arch URL, VERIFIES the archive, and places the INNER frpc (not the archive)', async () => { const archive = realisticFrpTarGz() const url = 'https://example.test/frp-darwin-arm64.tar.gz' const releases = releaseOverride('darwin-arm64', { version: '0.61.1', url, sha256: sha256Hex(archive), }) const { deps, state, fetchedUrls } = makeFakeDeps({ [url]: archive }) const result = await provisionFrpc( { platform: 'darwin', arch: 'arm64', binDir: BIN_DIR, releases }, deps, ) expect(fetchedUrls).toEqual([url]) expect(result.binPath).toBe(BIN_PATH) expect(result.version).toBe('0.61.1') expect(result.platform).toBe('darwin-arm64') // atomic place: renamed into the final path, made executable expect(state.renames.some((r) => r.to === BIN_PATH)).toBe(true) expect(state.chmods.some((c) => (c.mode & 0o111) !== 0)).toBe(true) // the placed file is the EXTRACTED frpc binary, NOT the .tar.gz archive const placed = state.writes.get(BIN_PATH) expect(placed).toEqual(FRPC_BYTES) expect(placed).not.toEqual(archive) }) it('ignores the decoy frps entry and places frpc even when frps precedes it', async () => { const archive = makeTarGz([ { name: 'frp_x/frps', content: FRPS_BYTES }, { name: 'frp_x/frpc', content: FRPC_BYTES }, ]) const url = 'https://example.test/frp.tar.gz' const releases = releaseOverride('linux-amd64', { version: '0.61.1', url, sha256: sha256Hex(archive), }) const { deps, state } = makeFakeDeps({ [url]: archive }) await provisionFrpc({ platform: 'linux', arch: 'x64', binDir: BIN_DIR, releases }, deps) expect(state.writes.get(BIN_PATH)).toEqual(FRPC_BYTES) }) it('REJECTS on sha256 mismatch and places NO binary (temp cleaned up, no extraction)', async () => { const archive = realisticFrpTarGz() const url = 'https://example.test/frp-linux-amd64.tar.gz' const releases = releaseOverride('linux-amd64', { version: '0.61.1', url, sha256: 'f'.repeat(64), // deliberately wrong }) const { deps, state } = makeFakeDeps({ [url]: archive }) await expect( provisionFrpc({ platform: 'linux', arch: 'x64', binDir: BIN_DIR, releases }, deps), ).rejects.toThrow(/sha-?256|hash|integrity|mismatch/i) expect(state.renames).toEqual([]) expect(state.writes.has(BIN_PATH)).toBe(false) expect(state.removed.length).toBeGreaterThan(0) // unverified temp removed }) it('never places or execs before verifying (mismatch leaves nothing executable)', async () => { const archive = realisticFrpTarGz() const url = 'https://example.test/bad.tar.gz' const releases = releaseOverride('linux-arm64', { version: '0.61.1', url, sha256: '0'.repeat(64), }) const { deps, state } = makeFakeDeps({ [url]: archive }) await expect( provisionFrpc({ platform: 'linux', arch: 'arm64', binDir: BIN_DIR, releases }, deps), ).rejects.toThrow() expect(state.chmods.every((c) => c.path !== BIN_PATH)).toBe(true) }) it('throws and places nothing when the verified archive has NO frpc entry', async () => { const archive = makeTarGz([ { name: 'frp_x/frps', content: FRPS_BYTES }, { name: 'frp_x/LICENSE', content: new TextEncoder().encode('MIT') }, ]) const url = 'https://example.test/no-frpc.tar.gz' const releases = releaseOverride('darwin-amd64', { version: '0.61.1', url, sha256: sha256Hex(archive), }) const { deps, state } = makeFakeDeps({ [url]: archive }) await expect( provisionFrpc({ platform: 'darwin', arch: 'x64', binDir: BIN_DIR, releases }, deps), ).rejects.toThrow(/extract|frpc|tar/i) expect(state.renames).toEqual([]) expect(state.writes.has(BIN_PATH)).toBe(false) expect(state.removed.length).toBeGreaterThan(0) // temp removed on extraction failure }) it('REJECTS a traversal frpc entry and never writes outside binDir', async () => { const archive = makeTarGz([ { name: 'frp_x/../../../tmp/frpc', content: FRPC_BYTES }, ]) const url = 'https://example.test/evil.tar.gz' const releases = releaseOverride('linux-amd64', { version: '0.61.1', url, sha256: sha256Hex(archive), }) const { deps, state } = makeFakeDeps({ [url]: archive }) await expect( provisionFrpc({ platform: 'linux', arch: 'x64', binDir: BIN_DIR, releases }, deps), ).rejects.toThrow() // nothing placed, and every write that ever happened stayed inside binDir expect(state.writes.has(BIN_PATH)).toBe(false) expect(state.renames).toEqual([]) expect([...state.writes.keys()].every((p) => p.startsWith(`${BIN_DIR}/`))).toBe(true) expect(state.removed.every((p) => p.startsWith(`${BIN_DIR}/`))).toBe(true) }) it('REJECTS a hash-matching but corrupt (non-gzip) archive after the gate', async () => { const garbage = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]) // hashes fine, not a gzip const url = 'https://example.test/corrupt.tar.gz' const releases = releaseOverride('darwin-arm64', { version: '0.61.1', url, sha256: sha256Hex(garbage), }) const { deps, state } = makeFakeDeps({ [url]: garbage }) await expect( provisionFrpc({ platform: 'darwin', arch: 'arm64', binDir: BIN_DIR, releases }, deps), ).rejects.toThrow(/extract|gzip|tar/i) expect(state.writes.has(BIN_PATH)).toBe(false) expect(state.removed.length).toBeGreaterThan(0) }) it('throws a clear error on an unsupported platform (nothing fetched)', async () => { const { deps, fetchedUrls } = makeFakeDeps({}) await expect( provisionFrpc({ platform: 'win32', arch: 'x64', binDir: BIN_DIR }, deps), ).rejects.toThrow(/unsupported|platform/i) expect(fetchedUrls).toEqual([]) }) })