From 083e8c34989e00d888bd2b413f36082cd8eb8ca7 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 5 Oct 2026 12:13:09 -0500 Subject: [PATCH] Fix(vault): files reached through a symlink are listed as assets ListAssets kept only regular files, so a file linked into the vault was missing from the list even though asset requests serve it (SafeJoin follows links that stay inside the vault). The web app now uses that list to tell a missing file from a present one: an embed whose file is not listed says it is not there instead of drawing a dead player. Without this, every embed of a linked file would say so. A link whose target is a file inside the vault is listed with the target's size and time. One that escapes the vault (refused when served), a broken one, and one that names a folder stay out. --- internal/vault/vault.go | 17 ++++++++++++- internal/vault/vault_test.go | 47 ++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/internal/vault/vault.go b/internal/vault/vault.go index 40e46a6..d38cdfc 100644 --- a/internal/vault/vault.go +++ b/internal/vault/vault.go @@ -1415,7 +1415,8 @@ func (v *Vault) ListAssets() ([]AssetMeta, error) { } continue } - if !entry.Type().IsRegular() || strings.EqualFold(filepath.Ext(name), ".md") || isExcalidrawName(name) { + isLink := entry.Type()&os.ModeSymlink != 0 + if (!entry.Type().IsRegular() && !isLink) || strings.EqualFold(filepath.Ext(name), ".md") || isExcalidrawName(name) { continue } info, err := entry.Info() @@ -1426,6 +1427,20 @@ func (v *Vault) ListAssets() ([]AssetMeta, error) { if err != nil { continue } + if isLink { + // A link to a file inside the vault is an attachment like any + // other: asset requests serve it through SafeJoin, and the web + // app calls an embed missing from this list "not on this + // device". A link that escapes the vault is refused there, so + // it is left out here too, as is a broken one or one to a + // folder. + if _, err := SafeJoin(v.root, filepath.ToSlash(rel)); err != nil { + continue + } + if info, err = os.Stat(full); err != nil || !info.Mode().IsRegular() { + continue + } + } out = append(out, AssetMeta{ Path: filepath.ToSlash(rel), Name: name, diff --git a/internal/vault/vault_test.go b/internal/vault/vault_test.go index 89bfedb..b5a6e70 100644 --- a/internal/vault/vault_test.go +++ b/internal/vault/vault_test.go @@ -1172,6 +1172,53 @@ func TestCreateExcalidrawSeedsEmptyScene(t *testing.T) { } } +func TestListAssetsIncludesFilesReachedThroughSymlinks(t *testing.T) { + root := t.TempDir() + v, err := New(root, Options{}) + if err != nil { + t.Fatal(err) + } + assets := filepath.Join(root, "assets") + if err := os.MkdirAll(assets, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(assets, "original.mp4"), []byte("123"), 0o600); err != nil { + t.Fatal(err) + } + outside := filepath.Join(t.TempDir(), "outside.mp4") + if err := os.WriteFile(outside, []byte("secret"), 0o600); err != nil { + t.Fatal(err) + } + links := map[string]string{ + "clip.mp4": filepath.Join(assets, "original.mp4"), + "escape.mp4": outside, + "gone.mp4": filepath.Join(assets, "missing.mp4"), + "folder.link": assets, + } + for name, target := range links { + if err := os.Symlink(target, filepath.Join(assets, name)); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + } + + listed, err := v.ListAssets() + if err != nil { + t.Fatal(err) + } + sizes := map[string]int64{} + for _, asset := range listed { + sizes[asset.Path] = asset.Size + } + if size, ok := sizes["assets/clip.mp4"]; !ok || size != 3 { + t.Errorf("assets/clip.mp4 listed=%v size=%d, want listed with the target's size 3", ok, size) + } + for _, path := range []string{"assets/escape.mp4", "assets/gone.mp4", "assets/folder.link"} { + if _, ok := sizes[path]; ok { + t.Errorf("%s is listed, want it left out", path) + } + } +} + func TestListAssetsIgnoresAtomicWriteScratchFiles(t *testing.T) { root := t.TempDir() v, err := New(root, Options{})