Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/snap-template-tar7z-extraction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"app-builder-lib": patch
---

fix: extract `.tar.7z` snap template archives through both compression layers. Since 26.15.0, default-config snap builds packed the template's inner tar as a single file instead of its contents (`desktop-init.sh` etc.), producing snaps that built successfully but failed at launch. The toolset cache directory name for `.tar.7z` archives also changes, so caches poisoned by the broken extraction are automatically re-fetched after upgrading.
28 changes: 17 additions & 11 deletions packages/app-builder-lib/src/util/electronGet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,21 +215,24 @@ export async function extractArchive(archive: string, dir: string) {

if (file.endsWith(".tar.gz") || file.endsWith(".tgz")) {
await tar.extract({ file, cwd: tmpDir, strip: 1 })
} else if (file.endsWith(".tar.xz") || file.endsWith(".txz")) {
// node-tar cannot decompress xz, so use 7za to turn the .tar.xz into a .tar, then extract that tar.
} else if (file.endsWith(".tar.xz") || file.endsWith(".txz") || file.endsWith(".tar.7z")) {
// Compressed tarballs node-tar cannot decompress itself (xz, 7z): use 7za to strip the outer
// compression layer into a .tar, then extract that tar.
// Note: the .tar.7z check MUST stay ahead of the plain .7z branch below, otherwise only the outer
// 7z layer is removed and the inner tar is left behind as-is (see https://github.com/electron-userland/electron-builder/issues/10002).
const cmd7za = await getPath7za()
const xzOutDir = `${tmpDir}.xz`
await fs.rm(xzOutDir, { recursive: true, force: true })
await fs.mkdir(xzOutDir, { recursive: true })
const decompressOutDir = `${tmpDir}.decompress`
await fs.rm(decompressOutDir, { recursive: true, force: true })
await fs.mkdir(decompressOutDir, { recursive: true })
try {
await exec(cmd7za, ["x", "-bd", file, to7zaOutputSwitch(sanitizeDirPath(xzOutDir)), "-y"])
const innerTar = (await fs.readdir(xzOutDir)).find(f => f.endsWith(".tar"))
await exec(cmd7za, ["x", "-bd", file, to7zaOutputSwitch(sanitizeDirPath(decompressOutDir)), "-y"])
const innerTar = (await fs.readdir(decompressOutDir)).find(f => f.endsWith(".tar"))
if (innerTar == null) {
throw new Error(`xz decompression of ${path.basename(file)} produced no .tar archive`)
throw new Error(`decompression of ${path.basename(file)} produced no .tar archive`)
}
await tar.extract({ file: path.join(xzOutDir, innerTar), cwd: tmpDir, strip: 1 })
await tar.extract({ file: path.join(decompressOutDir, innerTar), cwd: tmpDir, strip: 1 })
} finally {
await fs.rm(xzOutDir, { recursive: true, force: true })
await fs.rm(decompressOutDir, { recursive: true, force: true })
}
} else if (file.endsWith(".zip")) {
await extractZipStreaming(file, tmpDir)
Expand Down Expand Up @@ -600,7 +603,10 @@ export async function downloadBuilderToolset(options: {
const baseUrl = getBinariesMirrorUrl(githubOrgRepo)
const fullUrl = resolveBuilderBinaryUrl(releaseName, filenameWithExt, baseUrl, overrideUrl)
const suffix = hashUrlSafe(fullUrl, 5)
const folderName = `${filenameWithExt.replace(/\.(tar\.gz|tgz|tar\.xz|txz|zip|7z)$/, "")}-${suffix}`
// tar.7z is listed before 7z so the full extension is stripped. Deliberate side effect: this changes the
// extract-dir name for .tar.7z toolsets (e.g. the snap template) from "<name>.tar-<hash>" to "<name>-<hash>",
// which busts caches poisoned by the broken .tar.7z extraction in 26.15.0-26.15.6 (issue #10002).
const folderName = `${filenameWithExt.replace(/\.(tar\.gz|tgz|tar\.xz|txz|tar\.7z|zip|7z)$/, "")}-${suffix}`
// releaseName is library input; enforce cache-dir containment (rejects traversal, clears taint into shell extraction)
const cacheDir = await cacheDirectoryOverrideAllowed.value
const extractDir = sanitizeDirPath(path.join(cacheDir, releaseName, folderName), cacheDir)
Expand Down
54 changes: 54 additions & 0 deletions test/src/electronGetTest.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { exec } from "builder-util"
import { readFileSync } from "fs"
import * as fs from "fs/promises"
import * as http from "http"
Expand Down Expand Up @@ -347,6 +348,59 @@ describe("downloadBuilderToolset", { sequential: true }, () => {
await server.close()
}
})

// Regression test for https://github.com/electron-userland/electron-builder/issues/10002
// Snap template toolsets ship as .tar.7z; extractArchive routed them through the plain .7z
// branch, leaving a single inner .tar in the toolset dir instead of the template contents,
// so every default-config snap built with 26.15.0-26.15.6 silently failed at launch.
// Also asserts the cache dir name now strips the full ".tar.7z" extension — a deliberate
// rename that busts caches poisoned by the broken extraction (the old "<name>.tar-<hash>"
// dirs pass the cache-complete check and would otherwise never be re-extracted).
test("toolset .tar.7z is extracted through both layers and gets a cache-busting dir name (#10002)", { timeout: 120_000 }, async ({ expect, tmpDir }) => {
// Resolve 7za before stubbing the mirror env vars: extractArchive needs it, and resolving it
// afterwards would try to download the 7zip toolset from our fixture server.
const { getPath7za } = await import("app-builder-lib/src/toolsets/7zip")
const cmd7za = await getPath7za()

// Craft a fixture mirroring snap-template-electron-*.tar.7z: a 7z layer around a tar with
// "./"-prefixed entries and a mode-755 desktop-init.sh at the root.
const fixtureDir = await tmpDir.createTempDir()
const contentDir = path.join(fixtureDir, "content")
await fs.mkdir(path.join(contentDir, "usr", "share"), { recursive: true })
await fs.writeFile(path.join(contentDir, "desktop-init.sh"), "#!/bin/bash\ntrue\n", { mode: 0o755 })
await fs.writeFile(path.join(contentDir, "usr", "share", "marker.txt"), "ok")
const innerTar = path.join(fixtureDir, "snap-template-test-amd64.tar")
// explicit "./"-prefixed entries so node-tar stores "./"-prefixed names like the real template tars
await tar.create(
{ file: innerTar, cwd: contentDir },
(await fs.readdir(contentDir)).map(e => `./${e}`)
)
const servedArchive = path.join(fixtureDir, "snap-template-test-amd64.tar.7z")
await exec(cmd7za, ["a", "-t7z", servedArchive, innerTar])

const freshTestCache = await tmpDir.createTempDir()
vi.stubEnv("ELECTRON_BUILDER_CACHE", freshTestCache)
const server = await startArtifactServer(servedArchive)
vi.stubEnv("ELECTRON_BUILDER_BINARIES_MIRROR", `http://127.0.0.1:${server.port}/`)

try {
const result = await downloadBuilderToolset({ releaseName: "snap-template-test", filenameWithExt: "snap-template-test-amd64.tar.7z" })

// both layers extracted: template files at the toolset root, no stray inner .tar
const entries = await fs.readdir(result)
expect(entries).toContain("desktop-init.sh")
expect(entries).toContain("usr")
expect(entries.filter(e => e.endsWith(".tar"))).toEqual([])
expect(await fs.readFile(path.join(result, "usr", "share", "marker.txt"), "utf-8")).toBe("ok")

// cache-busting dir name: full ".tar.7z" stripped (26.15.x stripped only ".7z",
// producing "<name>.tar-<hash>" dirs that hold the broken single-tar extraction)
expect(path.basename(result)).toMatch(/^snap-template-test-amd64-/)
expect(path.basename(result)).not.toContain(".tar")
} finally {
await server.close()
}
})
})

// ─── downloadBuilderToolset: filenameWithExt validation ──────────────────────
Expand Down
58 changes: 57 additions & 1 deletion test/src/extractArchiveTest.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { moveDirAtomic } from "builder-util"
import { exec, moveDirAtomic } from "builder-util"
import { extractArchive, isSafeExtractPath } from "app-builder-lib/src/util/electronGet"
import { getPath7za } from "app-builder-lib/src/toolsets/7zip"
import * as fs from "fs/promises"
import * as os from "os"
import * as path from "path"
import * as tar from "tar"
import { afterEach, describe, test, vi } from "vitest"

afterEach(() => {
Expand Down Expand Up @@ -151,6 +153,60 @@ describe("extractArchive ZIP security guards", () => {
})
})

// ─── extractArchive .tar.7z (snap template layout) ────────────────────────────

/**
* Crafts a .tar.7z archive that mirrors the snap-template-electron-*.tar.7z assets from
* electron-builder-binaries: a 7z compression layer around a tar whose entries are all
* "./"-prefixed, containing a mode-755 desktop-init.sh at the root plus a nested tree.
*/
async function createSnapTemplateLikeTar7z(workDir: string, archiveName: string): Promise<string> {
const contentDir = path.join(workDir, "content")
await fs.mkdir(path.join(contentDir, "usr", "share"), { recursive: true })
await fs.writeFile(path.join(contentDir, "desktop-init.sh"), "#!/bin/bash\ntrue\n", { mode: 0o755 })
await fs.writeFile(path.join(contentDir, "usr", "share", "marker.txt"), "ok")

// Explicit "./"-prefixed entries make node-tar store "./"-prefixed names, matching the real
// template tars, so tar.extract({ strip: 1 }) is what flattens them into the extraction root.
const innerTar = path.join(workDir, archiveName.replace(/\.7z$/, ""))
const entries = (await fs.readdir(contentDir)).map(e => `./${e}`)
await tar.create({ file: innerTar, cwd: contentDir }, entries)

const archivePath = path.join(workDir, archiveName)
await exec(await getPath7za(), ["a", "-t7z", archivePath, innerTar])
return archivePath
}

describe("extractArchive .tar.7z", () => {
// Regression test for https://github.com/electron-userland/electron-builder/issues/10002:
// .tar.7z fell through to the plain .7z branch, so only the outer 7z layer was removed and the
// extraction dir contained a single inner .tar instead of the template contents. Snap builds
// then packed that tar as-is and the resulting snaps failed at launch (desktop-init.sh missing).
test("extracts both layers of a .tar.7z, not just the outer 7z", async ({ expect, tmpDir }) => {
const tmpDirPath = await tmpDir.createTempDir()
const archivePath = await createSnapTemplateLikeTar7z(tmpDirPath, "snap-template-test-amd64.tar.7z")
const extractDir = path.join(tmpDirPath, "out")

await extractArchive(archivePath, extractDir)

const entries = await fs.readdir(extractDir)
// template contents must land at the extraction root...
expect(entries).toContain("desktop-init.sh")
expect(entries).toContain("usr")
// ...and the inner tar must not be left behind as a file (the 26.15.x symptom)
expect(entries.filter(e => e.endsWith(".tar"))).toEqual([])

expect(await fs.readFile(path.join(extractDir, "desktop-init.sh"), "utf-8")).toBe("#!/bin/bash\ntrue\n")
expect(await fs.readFile(path.join(extractDir, "usr", "share", "marker.txt"), "utf-8")).toBe("ok")

if (process.platform !== "win32") {
// the template's launch scripts must stay executable, otherwise the snap dies at startup
const stat = await fs.stat(path.join(extractDir, "desktop-init.sh"))
expect(stat.mode & 0o100).toBeTruthy()
}
})
})

// ─── isSafeExtractPath ────────────────────────────────────────────────────────

describe("isSafeExtractPath", () => {
Expand Down
Loading