gastownhall/beads · error

legacy SQLite source %q must not be a symlink

Error message

legacy SQLite source %q must not be a symlink

What it means

fingerprintFile uses os.Lstat and explicitly rejects symlinks before opening any file that is part of the legacy SQLite source set. The library audits an exact on-disk layout, and a symlink could silently redirect reads to a different file (or be swapped between lstat and open). If the database file (or a required sidecar being fingerprinted) is a symlink, Export fails immediately with this message.

Source

Thrown at internal/migration/legacysqlite/reader.go:190

		return sourceSet{}, err
	}
	journal, err := fingerprintFile(path+"-journal", false)
	if err != nil {
		return sourceSet{}, err
	}
	return sourceSet{db, wal, journal}, nil
}

func fingerprintFile(path string, required bool) (fingerprint, error) {
	info, err := os.Lstat(path)
	if os.IsNotExist(err) && !required {
		return fingerprint{}, nil
	}
	if err != nil {
		return fingerprint{}, err
	}
	if info.Mode()&os.ModeSymlink != 0 {
		return fingerprint{}, fmt.Errorf("legacy SQLite source %q must not be a symlink", path)
	}
	if !info.Mode().IsRegular() {
		return fingerprint{}, fmt.Errorf("legacy SQLite source %q must be a regular file", path)
	}
	f, err := os.Open(path) //nolint:gosec // G304: source is lstat-checked and fingerprinted again after sealing.
	if err != nil {
		return fingerprint{}, err
	}
	defer f.Close()
	h := sha256.New()
	if _, err = io.Copy(h, f); err != nil {
		return fingerprint{}, err
	}
	return fingerprint{true, info.Size(), info.ModTime(), hex.EncodeToString(h.Sum(nil)), info}, nil
}

func sameSet(a, b sourceSet) bool {
	return sameFingerprint(a.db, b.db) && sameFingerprint(a.wal, b.wal) && sameFingerprint(a.journal, b.journal)

View on GitHub (pinned to 71377f2769)

Solutions

  1. Replace the symlink with the real file: rm the link and cp the target to the same path (keep -wal/-shm/-journal next to it)
  2. Point Export directly at the real file path instead of the symlink
  3. If you need indirection, bind-mount or copy the file into place rather than symlinking

Example fix

// before
$ ls -l beads.db
lrwxr-xr-x beads.db -> ~/sync/beads.db
// after
$ rm beads.db && cp ~/sync/beads.db beads.db && rm -f beads.db-wal beads.db-shm  # only if copies, not moving live sidecars
$ bd migrate --legacy ./beads.db ...
Defensive patterns

Strategy: validation

Validate before calling

func requireRegularNonSymlink(path string) error {
	info, err := os.Lstat(path)
	if err != nil { return err }
	if info.Mode()&os.ModeSymlink != 0 {
		return fmt.Errorf("%s is a symlink; replace with a real file", path)
	}
	if !info.Mode().IsRegular() {
		return fmt.Errorf("%s is not a regular file", path)
	}
	return nil
}

Type guard

func isRegularFile(path string) bool {
	info, err := os.Lstat(path)
	return err == nil && info.Mode().IsRegular() && info.Mode()&os.ModeSymlink == 0
}

Try / catch

if err := isRegularFileErr(src); err != nil {
	return fmt.Errorf("fix source path before export: %w", err)
}
return legacysqlite.Export(ctx, src, out, os.Stdout)

Prevention

When it happens

Trigger: Export -> seal -> fingerprintSource -> fingerprintFile on the path given as the legacy source (the .db itself, or -wal/-shm/-journal when present), and os.Lstat reports os.ModeSymlink. Note the .db path is 'required', so this fires even if it is a dangling symlink (Lstat succeeds on the link itself).

Common situations: User keeps beads.db as a symlink into a synced folder or dotfiles repo; packaging scripts link the database from a data volume; container setups symlink /data/beads.db to a mounted path.

Related errors


AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30). Data as JSON: /api/errors/5fe5490bc14b3ec5. Report an issue: GitHub.