From 05a3f36a02acf5f45dec769ebad66647d1ec6930 Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Thu, 18 Jun 2026 12:49:46 -0400 Subject: [PATCH] add dwarfs support --- .gitignore | 1 + flake.nix | 1 + internal/appherder/appimage.go | 87 +++++++++++++++++++++++------ internal/appherder/appimage_test.go | 6 +- internal/appherder/dwarfs.go | 63 +++++++++++++++++++++ package.nix | 9 ++- 6 files changed, 146 insertions(+), 21 deletions(-) create mode 100644 internal/appherder/dwarfs.go diff --git a/.gitignore b/.gitignore index a57f70b..9ac5607 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ squashfs-root/ AppDir/ /appherder /result +/vendor diff --git a/flake.nix b/flake.nix index c8b6f7f..8c5b50d 100644 --- a/flake.nix +++ b/flake.nix @@ -32,6 +32,7 @@ default = pkgs.mkShellNoCC { packages = with pkgs; [ go + dwarfs self.formatter.${system} ]; }; diff --git a/internal/appherder/appimage.go b/internal/appherder/appimage.go index d841998..b41ba4d 100644 --- a/internal/appherder/appimage.go +++ b/internal/appherder/appimage.go @@ -14,26 +14,45 @@ import ( "github.com/CalebQ42/squashfs" ) -// openAppImage exposes a type-2 AppImage's squashfs payload as an fs.FS without -// executing it. The caller must invoke the returned closer to release the file. +// openAppImage exposes a type-2 AppImage's filesystem as an fs.FS. Supports +// SquashFS (in-process) and DwarFS (extracted via the runtime). Caller must +// invoke the returned closer to release resources. func openAppImage(path string) (fs.FS, func(), error) { file, err := os.Open(path) if err != nil { return nil, nil, fmt.Errorf("open AppImage %s: %w", path, err) } - offset, err := appImageSquashfsOffset(file) + offset, err := fileSystemOffset(file) if err != nil { file.Close() return nil, nil, fmt.Errorf("read AppImage %s: %w", path, err) } + if isDwarFS(file, offset) { + file.Close() + return openDwarFS(path) + } + + if isSquashFS(file, offset) { + return openSquashFS(file, offset) + } + + // Not at the expected offset — try scanning forward. + if scanned, ok := scanForSquashFS(file, offset); ok { + return openSquashFS(file, scanned) + } + + file.Close() + return nil, nil, fmt.Errorf("read AppImage %s: unknown or unsupported filesystem", path) +} + +func openSquashFS(file *os.File, offset int64) (fs.FS, func(), error) { reader, err := squashfs.NewReaderAtOffset(file, offset) if err != nil { file.Close() - return nil, nil, fmt.Errorf("read AppImage filesystem %s: %w", path, err) + return nil, nil, fmt.Errorf("read SquashFS at offset %d: %w", offset, err) } - return squashFS{root: &reader.FS}, func() { file.Close() }, nil } @@ -68,36 +87,70 @@ func (s squashFS) ReadDir(name string) ([]fs.DirEntry, error) { return s.root.Re func (s squashFS) Glob(pattern string) ([]string, error) { return s.root.Glob(pattern) } func (s squashFS) Stat(name string) (fs.FileInfo, error) { return s.root.Stat(name) } -// appImageSquashfsOffset returns the byte offset of the squashfs image appended -// to the AppImage's ELF runtime, computed from the ELF section header table. -func appImageSquashfsOffset(reader io.ReaderAt) (int64, error) { +// fileSystemOffset returns the byte offset where the AppImage's payload +// filesystem begins, computed from the ELF section header table. +func fileSystemOffset(file io.ReaderAt) (int64, error) { var header [64]byte - if _, err := reader.ReadAt(header[:], 0); err != nil { + if _, err := file.ReadAt(header[:], 0); err != nil { return 0, fmt.Errorf("read ELF header: %w", err) } if header[0] != 0x7f || header[1] != 'E' || header[2] != 'L' || header[3] != 'F' { return 0, errors.New("not an AppImage (missing ELF header)") } - // AppImages record their type in the otherwise-unused ELF padding bytes. + + // Byte 8-10: AppImage type. 1 = ISO 9660 (unsupported), 2 = appended filesystem. if header[8] == 'A' && header[9] == 'I' && header[10] == 1 { return 0, errors.New("type-1 AppImages are not supported") } - byteOrder := binary.ByteOrder(binary.LittleEndian) + var endian binary.ByteOrder = binary.LittleEndian if header[5] == 2 { - byteOrder = binary.BigEndian + endian = binary.BigEndian } - // The squashfs starts where the ELF ends: e_shoff + e_shnum*e_shentsize. + + // e_shoff + e_shnum * e_shentsize = end of the section header table. switch header[4] { - case 1: // 32-bit ELF: e_shoff@32, e_shentsize@46, e_shnum@48 - return int64(byteOrder.Uint32(header[32:36])) + int64(byteOrder.Uint16(header[46:48]))*int64(byteOrder.Uint16(header[48:50])), nil - case 2: // 64-bit ELF: e_shoff@40, e_shentsize@58, e_shnum@60 - return int64(byteOrder.Uint64(header[40:48])) + int64(byteOrder.Uint16(header[58:60]))*int64(byteOrder.Uint16(header[60:62])), nil + case 1: // 32-bit + tableStart := int64(endian.Uint32(header[32:36])) + entrySize := int64(endian.Uint16(header[46:48])) + entryCount := int64(endian.Uint16(header[48:50])) + return tableStart + entrySize*entryCount, nil + case 2: // 64-bit + tableStart := int64(endian.Uint64(header[40:48])) + entrySize := int64(endian.Uint16(header[58:60])) + entryCount := int64(endian.Uint16(header[60:62])) + return tableStart + entrySize*entryCount, nil default: return 0, errors.New("unknown ELF class") } } +// isSquashFS reports whether a SquashFS superblock begins at offset. +func isSquashFS(file interface{ ReadAt([]byte, int64) (int, error) }, offset int64) bool { + magic := make([]byte, 4) + _, err := file.ReadAt(magic, offset) + return err == nil && string(magic) == "hsqs" +} + +// scanForSquashFS searches for a SquashFS superblock in 4096-byte steps over +// the next 64 MiB starting from offset. Returns the found position or false. +func scanForSquashFS(file interface{ ReadAt([]byte, int64) (int, error) }, offset int64) (int64, bool) { + const window = 64 * 1024 * 1024 + buf := make([]byte, 4096) + for pos := offset; pos < offset+window; pos += 4096 { + bytesRead, err := file.ReadAt(buf, pos) + if err != nil { + return 0, false + } + for i := range bytesRead - 4 { + if buf[i] == 'h' && buf[i+1] == 's' && buf[i+2] == 'q' && buf[i+3] == 's' { + return pos + int64(i), true + } + } + } + return 0, false +} + func (a App) installAppImage(file string, appName string) (string, error) { if err := os.MkdirAll(a.appimagesDir, 0o755); err != nil { return "", fmt.Errorf("create AppImages directory %s: %w", a.appimagesDir, err) diff --git a/internal/appherder/appimage_test.go b/internal/appherder/appimage_test.go index 0ee34a6..13c7ece 100644 --- a/internal/appherder/appimage_test.go +++ b/internal/appherder/appimage_test.go @@ -20,7 +20,7 @@ func TestAppImageSquashfsOffset(t *testing.T) { 0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x38, 0x00, 0x0a, 0x00, 0x40, 0x00, 0x1e, 0x00, 0x1d, 0x00, } - got, err := appImageSquashfsOffset(bytes.NewReader(header)) + got, err := fileSystemOffset(bytes.NewReader(header)) if err != nil { t.Fatal(err) } @@ -30,7 +30,7 @@ func TestAppImageSquashfsOffset(t *testing.T) { } func TestAppImageSquashfsOffsetRejectsNonELF(t *testing.T) { - if _, err := appImageSquashfsOffset(bytes.NewReader(make([]byte, 64))); err == nil { + if _, err := fileSystemOffset(bytes.NewReader(make([]byte, 64))); err == nil { t.Fatal("expected error for non-ELF input") } } @@ -39,7 +39,7 @@ func TestAppImageSquashfsOffsetRejectsType1(t *testing.T) { h := make([]byte, 64) copy(h, []byte{0x7f, 'E', 'L', 'F'}) h[8], h[9], h[10] = 'A', 'I', 1 - if _, err := appImageSquashfsOffset(bytes.NewReader(h)); err == nil { + if _, err := fileSystemOffset(bytes.NewReader(h)); err == nil { t.Fatal("expected error for type-1 AppImage") } } diff --git a/internal/appherder/dwarfs.go b/internal/appherder/dwarfs.go new file mode 100644 index 0000000..ba2a43c --- /dev/null +++ b/internal/appherder/dwarfs.go @@ -0,0 +1,63 @@ +package appherder + +import ( + "context" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "time" +) + +// openDwarFS extracts the DwarFS payload from an AppImage. Tries the system +// dwarfsextract tool first; falls back to the AppImage's own --appimage-extract +// as a last resort. Returns the extracted filesystem and a cleanup function. +func openDwarFS(appimagePath string) (fs.FS, func(), error) { + dir, err := os.MkdirTemp("", "appherder-dwarfs") + if err != nil { + return nil, nil, fmt.Errorf("create temp directory: %w", err) + } + cleanup := func() { os.RemoveAll(dir) } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + cmd := extractCommand(ctx, appimagePath, dir) + if out, err := cmd.CombinedOutput(); err != nil { + cleanup() + return nil, nil, fmt.Errorf("extract DwarFS AppImage: %w\n%s", err, out) + } + + // dwarfsextract -o extracts directly; --appimage-extract nests under squashfs-root. + root := dir + if _, err := os.Stat(filepath.Join(dir, "squashfs-root")); err == nil { + root = filepath.Join(dir, "squashfs-root") + } + return os.DirFS(root), cleanup, nil +} + +func extractCommand(ctx context.Context, appimagePath, destDir string) *exec.Cmd { + extract, err := exec.LookPath("dwarfsextract") + if err == nil { + // Extract only the desktop entry and icon files. + return exec.CommandContext(ctx, extract, + "--input="+appimagePath, + "--output="+destDir, + "--pattern=**.desktop", + "--pattern=**.png", + "--pattern=**.svg", + "--pattern=.DirIcon", + ) + } + cmd := exec.CommandContext(ctx, appimagePath, "--appimage-extract") + cmd.Dir = destDir + return cmd +} + +// isDwarFS reports whether the payload at offset starts with the DwarFS magic. +func isDwarFS(file interface{ ReadAt([]byte, int64) (int, error) }, offset int64) bool { + magic := make([]byte, 6) + _, err := file.ReadAt(magic, offset) + return err == nil && string(magic) == "DWARFS" +} diff --git a/package.nix b/package.nix index a7e828f..3036270 100644 --- a/package.nix +++ b/package.nix @@ -1,5 +1,8 @@ { buildGoModule, + dwarfs, + lib, + makeWrapper, }: let version = "dev"; in @@ -7,7 +10,11 @@ in pname = "appherder"; inherit version; src = ./.; - vendorHash = "sha256-hjyL3/01LWN2VTEBMwJmUyTi+tfh4zMVPWiNTkzCWfk="; + vendorHash = "sha256-DW+OYl2Lr7j4ZGOD/Cml2/2yuauX4EudLRaYH15YtAA="; subPackages = ["cmd/appherder"]; ldflags = ["-X main.version=${version}"]; + nativeBuildInputs = [makeWrapper]; + postInstall = '' + wrapProgram $out/bin/appherder --prefix PATH : ${lib.makeBinPath [dwarfs]} + ''; } -- 2.51.2