Skip to content

Fix review findings: concurrency, plugin sandbox, provider bugs, and dedup#254

Merged
bjarneo merged 48 commits into
mainfrom
review-fixes
May 28, 2026
Merged

Fix review findings: concurrency, plugin sandbox, provider bugs, and dedup#254
bjarneo merged 48 commits into
mainfrom
review-fixes

Conversation

@bjarneo

@bjarneo bjarneo commented May 28, 2026

Copy link
Copy Markdown
Owner

Addresses the findings from a full-codebase review. 48 commits, one fix per commit. Build, go vet, and the full test suite (with -race on the concurrency fixes) are green.

High severity

  • Playlist data race + crashPlaylist had no synchronization, so Lua plugin goroutines reading state raced with the Bubbletea loop mutating it, and Current() could panic on a stale index. Added a mutex guarding every exported method.
  • SSRF in cliamp.http — plugin URLs went straight to the HTTP client. Added a dial-time guard rejecting loopback/RFC1918/link-local (cloud metadata) and a scheme allowlist; also covers redirects.

Medium severity

  • Sandbox plugins list metadata extraction (was running plugin top-level code unsandboxed).
  • fs.read errors on oversized files instead of silently truncating.
  • Wait for async Lua hook goroutines before closing LStates on shutdown.
  • Bound timer and visualizer callbacks with hookTimeout (a stuck callback could freeze a plugin / the UI).
  • Synchronize Jellyfin/Emby client token/userID/album cache (data race across tea.Cmd goroutines).
  • Close in-flight IPC connections on shutdown (was blocking up to 60s).
  • Guard spectrum visualizers against a zero-width panel (divide-by-zero crash).
  • Don't clobber history on a transient load error; detect write errors before committing.
  • Stop the lyrics cleanQuery regex from erasing titles like "Videotape"/"Audioslave"/"Video Games".
  • Bound the startup feed sniff with a short timeout (was up to 30s).

Low severity (selection)

  • Symlink-safe plugin write allowlist; reject partial set_eq_preset bands.
  • Atomic favorites/history writes; propagate save errors.
  • Defensive copies on Spotify/Navidrome caches; Spotify rate-limit backoff fix.
  • MPRIS volume ordering and stale-track SetPosition rejection.
  • IPC accept-loop error handling + waitReply dedup.
  • Jellyfin AlbumList negative-offset guard; Refresh() implemented for the self-hosted providers (was a silent no-op).
  • ffmpeg stderr surfaced in decode errors; reconnect no longer drops queued seek/lyric commands; nav footer counts reflect a committed filter.
  • Removed dead internal/control package; assorted doc/idiomatic cleanups.

Refactors

  • Shared minimal-TOML [[section]] parser (tomlutil.ParseSections) replacing three copies.
  • Shared Emby/Jellyfin client (internal/embyapi) replacing ~700 duplicated lines; the real differences (auth scheme, ping endpoint, user-id discovery, error prefix, metadata key) are isolated behind a dialect. emby/jellyfin are now thin wrappers; external API unchanged.

Deliberately not included

  • Two plugin-API-breaking security gates (fs.read/http permissions) — left as-is to avoid breaking existing plugins.
  • Audio-path transients (gapless position during swap, ffmpeg stream-failure surfacing) — fixes risk audio glitches for low-value gains.
  • navBuffer unbounded growth — by design (retained for seek-back).

Summary by CodeRabbit

  • New Features

    • Added refresh capability for music provider caches to reload playlists and tracks from servers.
    • Added sandboxing support for safely loading external plugins.
  • Bug Fixes

    • Fixed low-power mode override to properly respect explicit disable settings.
    • Fixed atomic file writes for history and favorites to prevent corruption on failures.
    • Fixed playlist thread-safety issues with proper locking.
    • Fixed symlink bypass vulnerability in file write permissions.
    • Improved MPRIS seek behavior to reject stale track references.
  • Documentation

    • Updated Lua API documentation for player functions.
  • Improvements

    • Added SSRF protection for HTTP requests.
    • Enhanced file read limits and caching strategies across providers.
    • Improved error messages and shutdown handling.

Review Change Stack

bjarneo added 30 commits May 28, 2026 20:21
Lua plugin callbacks run on goroutines (hooks, timers, keybinds) and read
playlist state through StateProvider closures, while the Bubbletea loop
mutates the same *Playlist. The struct had no synchronization, so reads of
p.tracks/p.order/p.pos raced with Next/Prev/Add/Remove/shuffle, and
Current()'s unguarded p.tracks[idx] could panic on a stale index.

Add a sync.Mutex and guard every exported method. Reads and writes are now
serialized; no exported method calls another, so defer-unlock cannot deadlock.

Fixes the high-severity track/player getter race and the playlist-mutation
data race.
doHTTP passed plugin-supplied URLs straight to the HTTP client with no
validation, letting a plugin reach loopback, RFC1918, and link-local
169.254.169.254 (cloud metadata), and follow redirects into those targets.

Add an ssrfGuard on the dialer Control hook so every connection attempt
(including redirects and DNS-rebound hosts) is checked against the resolved
IP and rejected if non-public, and reject non-http(s) schemes up front.

A per-plugin network permission gate remains a possible follow-up; it is
omitted here to avoid breaking existing plugins that legitimately use http.
extractMetadata ran DoFile on the entire plugin file with full stdlib, so
any top-level os.execute/io call ran unsandboxed the moment a user listed
installed plugins. Apply the same luaplugin.Sandbox used by the runtime
before executing the file. Exports luaplugin.Sandbox for reuse.
fs.read used os.ReadFile (whole file into memory) then sliced to 1MB,
returning a silently truncated value as success and not bounding memory for
huge files. Stream through io.LimitReader(maxSize+1) and return an explicit
error when the file exceeds the limit.
isWriteAllowed validated paths lexically: the strings.Contains(abs, "..")
check was dead (filepath.Abs already cleans "..", and it false-positived on
filenames like "..bar"), and a symlink planted inside an allowed dir could
redirect a write outside it. Resolve symlinks on the deepest existing
ancestor of the target and on the allow dirs themselves before the prefix
check. Also fixes the same gap in the exec cwd check, which reuses this
function.
timer.after/every called CallByParam directly with no context timeout, so a
runaway callback held the plugin mutex forever, blocking every other hook,
keybind, and visualizer for that plugin. Add a shared callBounded helper that
applies hookTimeout (mirroring invokeHook) and use it from both timer paths.
RenderVis runs on the UI render loop and called CallByParam with no timeout
while holding the plugin mutex; a hanging render() froze the UI with no
escape. InitVis/DestroyVis had the same gap. Route all three through
callBounded so a misbehaving visualizer times out and render falls back to
the previous frame.
Emit spawned untracked goroutines that call CallByParam on a plugin LState.
Close stopped timers/execs then closed every LState without waiting, so a UI
event dispatched just before shutdown could run against an already-closed
LState (p.L.Close does not take the plugin mutex). Track Emit goroutines in a
WaitGroup, set a closing flag under mu to reject late dispatch, and Wait
before closing the LStates.
renderFirework/renderBubbles/renderSakura compute dotCols = PanelWidth*2 and
then mod by dotCols/dotRows/len(bands); on a narrow terminal PanelWidth can be
0, so seed % uint64(dotCols) panicked and crashed the TUI. Add the same
dotRows<4 || dotCols<4 early-out the braille-grid renderers already use.
Record discarded loadLocked's error and proceeded as if history were empty,
so a one-off read failure (permission/I-O glitch) made saveLocked atomically
rewrite the file with only the new entry, destroying all prior rows.
Propagate the error instead, leaving the on-disk file untouched.
saveLocked ignored errors from writeEntry's formatted writes, so a failure
mid-write (e.g. ENOSPC) could still rename a truncated temp file over the
real history. Thread writes through an errWriter and abort the rename when a
write fails.
The noise regex matched official/lyric/audio/video as bare substrings
followed by .*, so 'Videotape', 'Audioslave', and 'Video Games' were cleaned
to empty or truncated queries, breaking lyric lookups. Restrict bare-label
stripping to genuine trailing labels (dash suffix, or 'official video/audio'
and 'lyric(s) video' phrases). Add regression test cases.
Client.token, userID, and albumCache were lazily read and written from
concurrent tea.Cmd goroutines (Playlists, album browse, SearchTracks all run
in their own goroutines) with no synchronization, a data race on the lazy
auth writes and the cached slice. Add a mutex guarding those fields, taken
only around field access and never across network I/O, with double-checked
auth so at most a redundant (not racy) auth can occur.
handleConn's read loop only observed the 60s per-request read deadline, never
s.done, and Close closed only the listener. A still-connected client (e.g. a
vis-bands polling client) kept its handler goroutine alive, so Close blocked
on wg.Wait for up to 60s. Track live connections and close them in Close so
their scanner.Scan unblocks immediately; addConn shares the conn mutex with
the done check to close the accept-during-shutdown race.
The reply/timeout/shutdown select was open-coded in load/theme/vis/bands/
status and waitReply was used elsewhere with a generic 'timeout' message.
Parameterize waitReply with a label and timeout and use it everywhere, which
also gives the previously-generic commands specific timeout messages.
acceptLoop swallowed every non-shutdown Accept error and retried at 10/s,
treating a permanently closed listener as transient. Return on net.ErrClosed
and log other errors before backing off.
The jump enter handler called notifyPlayback (MPRIS only) plus notifier.Seeked,
skipping plugin notification. Use finishSeek, which calls notifyAll, so a jump
seek emits the playback-state event to plugins like every other completed
seek. Seek stays synchronous (immediate seek), matching the existing test.
Args runs on the startup path and called sniffFeedURL, which did a HEAD on
the 30s feed/M3U client, so one slow CDN could stall cliamp startup for up to
30s during pure URL classification. Give the sniff a dedicated 5s client.
An empty 200 response left download returning a non-nil empty slice, so
Install wrote a 0-byte plugin file. Treat an empty body as a download error
so the next candidate URL is tried (or the install fails cleanly).
A raw URL ending in '/' made path.Base yield '.' or '/', producing a
degenerate plugin filename. Return a clear error instead.
The override only set LowPower when the flag was true, so --low-power=false
could not disable a config.toml low_power=true. Assign the flag value
directly, matching the other boolean overrides.
wireMediaCtl's error was silently discarded in TUI mode, so a dbus/MPRIS
setup failure left media-control silently disabled with no trace. Log it to
the app log (not stderr, which would corrupt the TUI).
cliamp.player.eq_preset(), visualizer(), and theme() were documented but
never registered in the player API (calling them errors). Implementing them
would require exposing model state to plugin goroutines, which would add a
data race; remove them from the docs so the reference matches the API.
A bands table missing entries silently zeroed the unset bands. Require all
10 values and raise an arg error otherwise so partial input can't corrupt
the EQ curve.
EmitKey spawned fire-and-forget goroutines per keypress that were untracked
and not gated on shutdown, so a keypress during Close could call into a
closed LState. Track them in the manager WaitGroup and skip dispatch once
closing, matching Emit.
Each volume Change spawned its own goroutine to send SetVolumeMsg, so rapid
changes could be delivered out of order and an older volume win. send
(prog.Send) is goroutine-safe and non-blocking, so call it directly.
trackid was a fixed constant for every track, so SetPosition ignored its
trackID argument and would seek whatever track became current after the
client read its position. Assign a unique track object path per track change
and ignore SetPosition when its trackID does not match the current track.
save() truncated the real favorites file with os.Create and ignored every
write error, so a partial write could lose all favorites. Build the content
in memory, write a temp file, and rename so a failed write can't corrupt the
existing file and the error surfaces to the caller.
ToggleFavorite discarded the error from Add/Remove (which persist favorites),
so a failed save was silently swallowed. Return it to the caller.
The mutex around appending the Your Music entry only touched the
function-local 'all' slice, guarding nothing shared (the other locks in the
loop legitimately guard trackCache).
bjarneo added 18 commits May 28, 2026 20:55
Playlists returned the cached slice directly (both on cache hit and after
building it), so a caller mutating the result corrupted the cache. Return a
clone on both paths.
Tracks handed out the cached track slice directly, so a caller mutating it
corrupted the per-playlist cache. Clone on both the cache-hit and freshly
built return paths. (The snapshot-based invalidation within the playlist-list
TTL is an intentional caching tradeoff and is left as-is.)
On the final retry the loop slept the full backoff (up to 128s) and then
returned the rate-limited error without making another request. Break out
immediately on the last attempt.
A negative offset passed the offset>=len check and then panicked at
out[offset:end]. Clamp offset to 0 first, matching the Emby client.
MoveQueue swaps any two positions, not only adjacent ones.
math.Min(0.65, 0.55) is a constant 0.55; replace with the literal and drop
the now-unused math import.
resp.Code is NetEase's application-level status, not an HTTP status; it was
compared against http.StatusOK which only coincidentally shares the value
200. Introduce neteaseCodeOK and use it at all four comparison sites.
humanizeBasename left a trailing extension (e.g. .mp3) in the title derived
from a URL basename. Drop a recognized audio extension first, leaving
non-media dotted suffixes untouched.
resolveFeed decoded the response body with no size limit. Wrap it in an
io.LimitReader (32 MB) so an oversized or malicious feed can't exhaust
memory.
Playlists and Tracks handed out the internal cached slices, so a caller
mutating the result corrupted the cache. Return slices.Clone on the cache-hit
and freshly built return paths.
decodeFFmpeg wrapped the exec error but dropped ffmpeg's stderr, where the
actual failure reason is written. Surface ExitError.Stderr in the message.
beep.Streamer already declares Err() error, so the local errorer type
assertion was redundant. Call ss.s.Err() directly, matching the eq and
volume streamers.
When the scheduled stream reconnect fired, the tick handler returned early
with only playTrack + tick, discarding the seekCmd/lyricCmd computed earlier
in the same tick. Fold them into the reconnect batch.
The footer only showed the filtered count while the search input bar was
open (searching); once a filter was committed with Enter it fell back to the
full count. Add navFilteredTotal and use the filtered count whenever a query
is active, across the artist, album, and track footers.
internal/control duplicated the message types in internal/playback and had no
importers (only its own test); the live code uses internal/playback. Remove
it.
Navidrome, Plex, Jellyfin, and Emby cache playlist/track (and album) data but
none implemented playlist.Refresher, so the UI refresh key was a silent
no-op for them. Add Refresh to clear the caches (including the jellyfin/emby
client album cache) so the next fetch hits the server.
The [[section]] parse loop was duplicated three times (radio loadStations,
radio loadFavoriteStations, local loadTOML). Extract tomlutil.ParseSections
and rewrite all three against it. Behavior is preserved, including the
single-section-type assumption of the original parsers.
The emby and jellyfin clients were ~95% identical (~700 duplicated lines) and
had already drifted (jellyfin lacked the negative-offset guard and error
wrapping). Extract one shared Client into internal/embyapi, isolating the real
differences behind a dialect: auth header scheme (Emby Authorization vs
Jellyfin X-Emby-Authorization), ping endpoint, user-id discovery, error
prefix, and metadata key.

emby and jellyfin become thin wrappers (NewClient, IsStreamURL, type aliases)
plus their providers. Client tests are consolidated in embyapi: shared
behavior once, dialect specifics per dialect. The client now uses a per-
instance http.Client (SetHTTPClient) instead of a package global, which is
what makes it testable from the provider packages.
@coderabbitai

coderabbitai Bot commented May 28, 2026

Copy link
Copy Markdown

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1159e5e7-7623-40d5-939e-671a84cac48f

📥 Commits

Reviewing files that changed from the base of the PR and between a233599 and f84e584.

⛔ Files ignored due to path filters (1)
  • cliamp_whips_terminal_ass.mp3 is excluded by !**/*.mp3
📒 Files selected for processing (55)
  • config/flags.go
  • docs/plugins.md
  • external/emby/client.go
  • external/emby/client_test.go
  • external/emby/provider.go
  • external/emby/provider_test.go
  • external/jellyfin/client.go
  • external/jellyfin/client_test.go
  • external/jellyfin/provider.go
  • external/jellyfin/provider_test.go
  • external/local/provider.go
  • external/navidrome/client.go
  • external/netease/provider.go
  • external/plex/provider.go
  • external/radio/favorites.go
  • external/radio/provider.go
  • external/spotify/provider.go
  • history/history.go
  • internal/control/msg.go
  • internal/control/msg_test.go
  • internal/embyapi/client.go
  • internal/embyapi/client_test.go
  • internal/embyapi/dialect.go
  • internal/tomlutil/sections.go
  • internal/tomlutil/sections_test.go
  • ipc/server.go
  • luaplugin/api_control.go
  • luaplugin/api_fs.go
  • luaplugin/api_http.go
  • luaplugin/api_keymap.go
  • luaplugin/api_timer.go
  • luaplugin/hooks.go
  • luaplugin/luaplugin.go
  • luaplugin/sandbox.go
  • luaplugin/visualizer.go
  • lyrics/lyrics.go
  • lyrics/lyrics_test.go
  • main.go
  • mediactl/metadata.go
  • mediactl/metadata_test.go
  • mediactl/service_linux.go
  • player/ffmpeg.go
  • player/speed.go
  • playlist/playlist.go
  • pluginmgr/pluginmgr.go
  • pluginmgr/resolve.go
  • resolve/resolve.go
  • ui/model/keys.go
  • ui/model/update.go
  • ui/model/view_helpers.go
  • ui/model/view_nav.go
  • ui/vis_bubbles.go
  • ui/vis_firework.go
  • ui/vis_flame.go
  • ui/vis_sakura.go

📝 Walkthrough

Walkthrough

This PR consolidates Emby and Jellyfin HTTP client implementations into a shared internal/embyapi package, adds widespread concurrency protection to playlist operations, hardens Lua plugin execution lifecycle to prevent shutdown races, improves resource bounds and error handling across multiple systems, and refactors TOML parsing to use a common utility.

Changes

Emby/Jellyfin consolidation, playlist concurrency, and plugin lifecycle

Layer / File(s) Summary
TOML section parsing utility
internal/tomlutil/sections.go, internal/tomlutil/sections_test.go
Introduces ParseSections function for parsing repeated [[section]] blocks with key = value pairs, used by provider implementations to replace manual line-by-line TOML parsing.
Shared Emby/Jellyfin client with dialect abstraction
internal/embyapi/client.go, internal/embyapi/dialect.go
Creates unified HTTP client for Emby and Jellyfin with shared request/auth pipeline, library/album/track queries, and playback reporting; dialect interface abstracts Emby vs Jellyfin behavior differences (ping endpoints, auth headers, user-ID discovery).
Shared client test suite
internal/embyapi/client_test.go
Comprehensive test coverage for both dialects including Ping behavior, authentication flows, user-ID fallback logic, auth header schemes, playback reporting, album/track parsing, StreamURL generation, and scrobble reporting.
Emby and Jellyfin adapter wrappers
external/emby/client.go, external/jellyfin/client.go
Converts implementation files into thin type aliases and forwarding constructors that delegate to shared internal/embyapi client; removes 700+ lines of duplicate code.
Provider refresh and test refactoring
external/emby/provider.go, external/emby/provider_test.go, external/jellyfin/provider.go, external/jellyfin/provider_test.go
Adds Refresh() method to Emby and Jellyfin providers to clear cached data; refactors provider tests to use new mockProvider HTTP transport helpers; removes old test files.
Cache refresh for remaining providers
external/navidrome/client.go, external/plex/provider.go, external/spotify/provider.go
Adds Refresh() method to Navidrome and Plex providers; updates Navidrome and Spotify to defensively clone cached results before returning to prevent caller mutations.
Radio and local provider TOML parsing refactoring
external/local/provider.go, external/radio/favorites.go, external/radio/provider.go
Refactors loadTOML, loadFavoriteStations, and loadStations to use ParseSections utility instead of manual parsing; adds atomic file writes for radio favorites using temporary file + rename.
Netease provider response code validation
external/netease/provider.go
Updates API response validation to use explicit neteaseCodeOK constant for application-level success code instead of HTTP status semantics.
Playlist concurrency protection
playlist/playlist.go
Adds mutex to Playlist type and protects all exported methods with lock/unlock pairs, ensuring thread-safe concurrent access to tracks, order, position, queue, shuffle, and repeat state.
Lua plugin lifecycle and shutdown coordination
luaplugin/luaplugin.go, luaplugin/hooks.go
Adds closing flag and wg waitgroup to Manager to track in-flight async event dispatch; prevents new async dispatch during shutdown and waits for all in-flight callbacks before closing Lua states.
Plugin callBounded helper for bounded Lua execution
luaplugin/hooks.go, luaplugin/api_timer.go, luaplugin/visualizer.go
Introduces Plugin.callBounded helper that executes Lua callbacks with timeout context; updates visualizer callbacks and timer execution to use bounded calls instead of direct CallByParam.
Hook and key dispatch with shutdown coordination
luaplugin/hooks.go, luaplugin/api_keymap.go
Updates Manager.Emit and EmitKey to check shutdown state, track async goroutines with waitgroup, and prevent hook invocation against closed Lua states.
Lua plugin security improvements
luaplugin/api_http.go, luaplugin/api_fs.go, luaplugin/api_control.go, luaplugin/sandbox.go
Adds SSRF guard to HTTP client, symlink-aware path canonicalization for filesystem writes, 1MB file read limit, URL scheme validation, stricter EQ band requirement validation, and exports Sandbox() for external tooling.
Plugin metadata extraction with sandboxing
pluginmgr/pluginmgr.go, pluginmgr/resolve.go
Applies sandbox constraints during metadata extraction; validates empty response bodies during plugin downloads and plugin name derivation from URLs.
IPC server connection management and shutdown
ipc/server.go
Adds active connection tracking to force-close connections during shutdown; refactors command response waiting into shared waitReply helper with labeled timeouts and shutdown coordination.
MPRIS track ID sequencing and metadata generation
mediactl/metadata.go, mediactl/metadata_test.go, mediactl/service_linux.go
Adds per-track sequence numbering to MPRIS Service; generates unique track object paths per sequence; validates seeks against current track ID; sends volume updates synchronously to preserve ordering.
History persistence and feed resolution improvements
history/history.go, resolve/resolve.go
Hardens history record to error on load failures; refactors history saving with error-capturing writer; bounds feed XML parsing to 32MB; improves feed sniffing with dedicated client.
UI navigation, visualizer bounds checks, and minor fixes
ui/model/view_helpers.go, ui/model/view_nav.go, ui/model/keys.go, ui/model/update.go, ui/vis_*.go, lyrics/lyrics.go, lyrics/lyrics_test.go, config/flags.go, docs/plugins.md, main.go, player/ffmpeg.go, player/speed.go
Adds filtered-total helper for navigation; updates view footers to show filtered counts; adds dimension guards to visualizers; simplifies ffmpeg error reporting; fixes low-power override merging; improves lyrics regex precision; updates plugin API docs.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • bjarneo/cliamp#207: Introduces initial Emby provider implementation; this PR consolidates that into shared internal/embyapi and refactors both Emby and Jellyfin adapters to use the unified client.
  • bjarneo/cliamp#249: Implements --low-power mode UI/player behavior; this PR fixes the LowPower override flag to allow both enabling and disabling via direct assignment rather than only enabling.
  • bjarneo/cliamp#181: Related due to Lua lifecycle changes in luaplugin/api_timer.go and plugin Manager shutdown coordination to prevent concurrent access to the same *lua.LState.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

@bjarneo bjarneo merged commit bd8d5d0 into main May 28, 2026
1 check was pending
@bjarneo bjarneo deleted the review-fixes branch May 28, 2026 20:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant