windows: fix config, IPC, path, and Lua compatibility (#258)
* windows: fix config, IPC, path, and Lua compatibility * spotify: share API structures and helper so tests compile on Windows * windows: address PR comments and fix socket unavailable detection, tasklist matching, api_fs tests, and doc comment * windows: address remaining PR reviews (table-driven tests, m3u resolve comments & tests, blockquote formatting, and site/index.html description) * feat: implement cross-platform IPC server with Unix and Windows support * feat: implement Unix socket IPC server and Windows process liveness check * ipc: detect dead processes via os.ErrProcessDone os.Process.Signal converts ESRCH to os.ErrProcessDone since Go 1.16, so comparing against raw syscall.ESRCH never matched and a stale socket from a crashed instance made NewServer fail instead of cleaning it up. * windows: simplify fs allowlist normalization, exec env, and ipc error checks - normalize write allow-dirs once in the memoized writeAllowDirs - collapse duplicate test helpers and repeated getenv blocks - drop redundant errors.As branch; name WSAECONNREFUSED - revert single-entry table test to linear form - gofmt: trailing newlines and indentation --------- Co-authored-by: Bjarne Øverli <bjarne.oeverli@gmail.com>
This commit is contained in:
@@ -46,6 +46,10 @@ Download from [GitHub Releases](https://github.com/bjarneo/cliamp/releases/lates
|
||||
> **Linux:** the pre-built binaries statically link FLAC, Vorbis, and Ogg, so no
|
||||
> extra codec packages are required. You may still need an ALSA bridge for your
|
||||
> sound server — see [Troubleshooting](#troubleshooting).
|
||||
>
|
||||
> **Windows:** download `cliamp-windows-amd64.exe` from Releases. If `HOME` is not
|
||||
> set, cliamp stores its config under `%APPDATA%\cliamp`. The Spotify provider is
|
||||
> currently unavailable on Windows builds.
|
||||
|
||||
**Optional runtime dependencies** (all platforms, all install methods):
|
||||
|
||||
@@ -54,6 +58,8 @@ Download from [GitHub Releases](https://github.com/bjarneo/cliamp/releases/lates
|
||||
|
||||
On macOS: `brew install ffmpeg yt-dlp`. On Linux, use your distribution's package manager.
|
||||
|
||||
On Windows, install `ffmpeg` and `yt-dlp` with your preferred package manager and keep both on `PATH`.
|
||||
|
||||
**Build from source**
|
||||
|
||||
```sh
|
||||
@@ -76,13 +82,13 @@ Press `Ctrl+K` to see all keybindings.
|
||||
cliamp setup
|
||||
```
|
||||
|
||||
It walks you through each provider, validates the connection, and writes the right block to `~/.config/cliamp/config.toml`. See [docs/cli.md](docs/cli.md#setup-wizard) for details.
|
||||
It walks you through each provider, validates the connection, and writes the right block to your config file (`~/.config/cliamp/config.toml`, or `%APPDATA%\cliamp\config.toml` on Windows when `HOME` is unset). See [docs/cli.md](docs/cli.md#setup-wizard) for details.
|
||||
|
||||
## Radio
|
||||
|
||||
Press `R` in the player to browse and search 30,000+ online radio stations from the [Radio Browser](https://www.radio-browser.info/) directory.
|
||||
|
||||
Add your own stations to `~/.config/cliamp/radios.toml`. See [docs/configuration.md](docs/configuration.md#custom-radio-stations).
|
||||
Add your own stations to `~/.config/cliamp/radios.toml` (or `%APPDATA%\cliamp\radios.toml` on Windows when `HOME` is unset). See [docs/configuration.md](docs/configuration.md#custom-radio-stations).
|
||||
|
||||
Want to host your own radio? Check out [cliamp-server](https://github.com/bjarneo/cliamp-server).
|
||||
|
||||
@@ -113,6 +119,8 @@ sudo pacman -S alsa-lib
|
||||
|
||||
**macOS:** No extra dependencies — CoreAudio is used.
|
||||
|
||||
**Windows:** No extra SDKs required for the core player. `ffmpeg.exe` and `yt-dlp.exe` remain optional runtime dependencies for the same formats/providers as on other platforms. Spotify is not available on Windows builds.
|
||||
|
||||
**Clone and build:**
|
||||
|
||||
```sh
|
||||
|
||||
@@ -8,6 +8,17 @@ cliamp setup
|
||||
|
||||
It validates your credentials live and writes the right TOML block without touching the rest of your config. See [cli.md](cli.md#setup-wizard) for details.
|
||||
|
||||
## Config directory
|
||||
|
||||
cliamp resolves its config directory in this order:
|
||||
|
||||
- `CLIAMP_CONFIG_DIR`
|
||||
- `XDG_CONFIG_HOME/cliamp`
|
||||
- `HOME/.config/cliamp`
|
||||
- on Windows, `%APPDATA%\cliamp` when `HOME` is not set
|
||||
|
||||
The examples below use `~/.config/cliamp` for brevity. On Windows without `HOME`, replace that path with `%APPDATA%\cliamp`.
|
||||
|
||||
For everything else, copy the example config and edit by hand:
|
||||
|
||||
```sh
|
||||
|
||||
+1
-1
@@ -303,7 +303,7 @@ cliamp.fs.mkdir(path) -- create directory (recursive)
|
||||
cliamp.fs.listdir(path) --> {names}, err
|
||||
```
|
||||
|
||||
Writes are restricted to `/tmp/`, `~/.config/cliamp/`, `~/.local/share/cliamp/`, and `~/Music/cliamp/`. Reads are allowed from anywhere.
|
||||
Writes are restricted to the system temp directory (`/tmp/` on Unix), `~/.config/cliamp/`, `~/.local/share/cliamp/`, and `~/Music/cliamp/`. Reads are allowed from anywhere. On Windows, if `HOME` is unset, the config directory portion resolves to `%APPDATA%\cliamp`.
|
||||
|
||||
### cliamp.json
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Control a running cliamp instance from another terminal, a shell script, or an AI coding assistant.
|
||||
|
||||
When cliamp starts, it listens on a Unix domain socket at `~/.config/cliamp/cliamp.sock`. CLI subcommands connect to this socket to send playback commands and receive status.
|
||||
When cliamp starts, it listens on a local IPC socket at `~/.config/cliamp/cliamp.sock` (or `%APPDATA%\cliamp\cliamp.sock` on Windows when `HOME` is unset). CLI subcommands connect to this socket to send playback commands and receive status. On Windows 10/11, this uses the same local socket transport via Go's AF_UNIX support.
|
||||
|
||||
## Playback Commands
|
||||
|
||||
@@ -96,7 +96,7 @@ Response:
|
||||
|
||||
## Protocol
|
||||
|
||||
The IPC protocol is newline-delimited JSON over a Unix domain socket. Each request is a single JSON object followed by a newline. The server responds with a single JSON object followed by a newline.
|
||||
The IPC protocol is newline-delimited JSON over a local stream socket. Each request is a single JSON object followed by a newline. The server responds with a single JSON object followed by a newline.
|
||||
|
||||
Request format:
|
||||
|
||||
@@ -118,7 +118,7 @@ Response format:
|
||||
|
||||
## Socket Details
|
||||
|
||||
- **Path**: `~/.config/cliamp/cliamp.sock` (created on TUI start, removed on shutdown)
|
||||
- **Path**: `~/.config/cliamp/cliamp.sock` (or `%APPDATA%\cliamp\cliamp.sock` on Windows when `HOME` is unset; created on TUI start, removed on shutdown)
|
||||
- **Permissions**: `0600` (owner only)
|
||||
- **Stale detection**: A PID file (`cliamp.sock.pid`) tracks the owning process. If cliamp crashes, the next instance detects the stale socket and cleans it up.
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Cliamp can stream your [Spotify](https://www.spotify.com/) library directly through its audio pipeline. EQ, visualizer, and all effects apply. Requires a [Spotify Premium](https://www.spotify.com/premium/) account.
|
||||
|
||||
> **Windows:** Spotify is currently unavailable on Windows builds because the `go-librespot` playback backend used by cliamp does not compile there yet.
|
||||
>
|
||||
> **Quick start:** run `cliamp setup`, pick Spotify, and follow the prompts. The recommended path is to register your own Spotify Developer app and paste its `client_id` — it gives you a private rate-limit quota and works for playback, library, and playlists. There's also a built-in shared `client_id` available for users who specifically need Spotify search.
|
||||
|
||||
## Setup
|
||||
|
||||
Vendored
-112
@@ -37,42 +37,6 @@ var (
|
||||
)
|
||||
|
||||
// maxResponseBody limits JSON API responses to 10 MB.
|
||||
const maxResponseBody = 10 << 20
|
||||
|
||||
// Pagination limits for the Spotify Web API.
|
||||
const (
|
||||
spotifyPlaylistPageSize = 50
|
||||
// spotifyTrackPageSize is capped at 50 because /v1/playlists/{id}/items
|
||||
// silently truncates larger limits; requesting more would cause the loop
|
||||
// to skip items when offset advances by the requested limit.
|
||||
spotifyTrackPageSize = 50
|
||||
)
|
||||
|
||||
// spotifyPlaylistItem is the raw playlist object returned by /v1/me/playlists.
|
||||
type spotifyPlaylistItem struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
SnapshotID string `json:"snapshot_id"`
|
||||
Collaborative bool `json:"collaborative"`
|
||||
Owner struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"owner"`
|
||||
Items *struct {
|
||||
Total int `json:"total"`
|
||||
} `json:"items"`
|
||||
}
|
||||
|
||||
// playlistAccessible reports whether the playlist should be shown to the user.
|
||||
// Playlists saved from other users (not owned, not collaborative) are excluded
|
||||
// because the Spotify API returns 403 when listing their tracks.
|
||||
// When userID is empty (fetch failed), all playlists are included as a fallback.
|
||||
func playlistAccessible(item spotifyPlaylistItem, userID string) bool {
|
||||
if userID == "" {
|
||||
return true
|
||||
}
|
||||
return item.Owner.ID == userID || item.Collaborative
|
||||
}
|
||||
|
||||
// SpotifyProvider implements playlist.Provider using the Spotify Web API
|
||||
// for playlist/track metadata and go-librespot for audio streaming.
|
||||
// playlistCache holds a snapshot_id and the fetched tracks for a playlist,
|
||||
@@ -358,82 +322,6 @@ func (p *SpotifyProvider) Playlists() ([]playlist.PlaylistInfo, error) {
|
||||
return slices.Clone(all), nil
|
||||
}
|
||||
|
||||
type spotifyArtist struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// spotifyItem is a track or podcast episode object from the Spotify Web API.
|
||||
// Playlists can hold both; episodes carry a show instead of artists/album.
|
||||
type spotifyItem struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"` // "track" or "episode"
|
||||
URI string `json:"uri"` // canonical spotify:track:... / spotify:episode:...
|
||||
Artists []spotifyArtist `json:"artists"`
|
||||
Album struct {
|
||||
Name string `json:"name"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
} `json:"album"`
|
||||
Show struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"show"`
|
||||
ReleaseDate string `json:"release_date"` // episodes carry this at top level
|
||||
DurationMs int `json:"duration_ms"`
|
||||
TrackNumber int `json:"track_number"`
|
||||
IsPlayable *bool `json:"is_playable"`
|
||||
Restrictions struct {
|
||||
Reason string `json:"reason"`
|
||||
} `json:"restrictions"`
|
||||
}
|
||||
|
||||
// trackFromItem converts a Spotify playlist/library item into a playlist.Track,
|
||||
// handling both music tracks and podcast episodes. It uses the canonical uri
|
||||
// the API returns (spotify:track:... or spotify:episode:...) as the path, so
|
||||
// the player routes episodes to go-librespot's episode metadata path; building
|
||||
// "spotify:track:<id>" for an episode makes go-librespot request track metadata
|
||||
// for an episode id, which 404s. Episodes carry no artists/album, so the show
|
||||
// name fills those slots for display.
|
||||
func trackFromItem(t *spotifyItem) playlist.Track {
|
||||
artists := make([]string, len(t.Artists))
|
||||
for i, a := range t.Artists {
|
||||
artists[i] = a.Name
|
||||
}
|
||||
artist := strings.Join(artists, ", ")
|
||||
album := t.Album.Name
|
||||
if t.Type == "episode" {
|
||||
artist = t.Show.Name
|
||||
album = t.Show.Name
|
||||
}
|
||||
|
||||
releaseDate := t.Album.ReleaseDate
|
||||
if releaseDate == "" {
|
||||
releaseDate = t.ReleaseDate
|
||||
}
|
||||
var year int
|
||||
if len(releaseDate) >= 4 {
|
||||
if y, err := strconv.Atoi(releaseDate[:4]); err == nil {
|
||||
year = y
|
||||
}
|
||||
}
|
||||
|
||||
path := t.URI
|
||||
if path == "" {
|
||||
path = fmt.Sprintf("spotify:track:%s", t.ID) // fallback if uri is absent
|
||||
}
|
||||
|
||||
return playlist.Track{
|
||||
Path: path,
|
||||
Title: t.Name,
|
||||
Artist: artist,
|
||||
Album: album,
|
||||
Year: year,
|
||||
Stream: false, // must be false: true causes togglePlayPause to stop+restart instead of pause/resume
|
||||
DurationSecs: t.DurationMs / 1000,
|
||||
TrackNumber: t.TrackNumber,
|
||||
Unplayable: (t.IsPlayable != nil && !*t.IsPlayable) || t.Restrictions.Reason != "",
|
||||
}
|
||||
}
|
||||
|
||||
// Tracks returns all tracks for the given Spotify playlist ID.
|
||||
// Track.Path is set to the canonical spotify: URI for the player to resolve.
|
||||
// Results are cached by snapshot_id; unchanged playlists skip the API call.
|
||||
|
||||
Vendored
+122
@@ -0,0 +1,122 @@
|
||||
package spotify
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"cliamp/playlist"
|
||||
)
|
||||
|
||||
// maxResponseBody limits JSON API responses to 10 MB.
|
||||
const maxResponseBody = 10 << 20
|
||||
|
||||
// Pagination limits for the Spotify Web API.
|
||||
const (
|
||||
spotifyPlaylistPageSize = 50
|
||||
// spotifyTrackPageSize is capped at 50 because /v1/playlists/{id}/items
|
||||
// silently truncates larger limits; requesting more would cause the loop
|
||||
// to skip items when offset advances by the requested limit.
|
||||
spotifyTrackPageSize = 50
|
||||
)
|
||||
|
||||
// spotifyPlaylistItem is the raw playlist object returned by /v1/me/playlists.
|
||||
type spotifyPlaylistItem struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
SnapshotID string `json:"snapshot_id"`
|
||||
Collaborative bool `json:"collaborative"`
|
||||
Owner struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"owner"`
|
||||
Items *struct {
|
||||
Total int `json:"total"`
|
||||
} `json:"items"`
|
||||
}
|
||||
|
||||
// playlistAccessible reports whether the playlist should be shown to the user.
|
||||
// Playlists saved from other users (not owned, not collaborative) are excluded
|
||||
// because the Spotify API returns 403 when listing their tracks.
|
||||
// When userID is empty (fetch failed), all playlists are included as a fallback.
|
||||
func playlistAccessible(item spotifyPlaylistItem, userID string) bool {
|
||||
if userID == "" {
|
||||
return true
|
||||
}
|
||||
return item.Owner.ID == userID || item.Collaborative
|
||||
}
|
||||
|
||||
type spotifyArtist struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// spotifyItem is a track or podcast episode object from the Spotify Web API.
|
||||
// Playlists can hold both; episodes carry a show instead of artists/album.
|
||||
type spotifyItem struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"` // "track" or "episode"
|
||||
URI string `json:"uri"` // canonical spotify:track:... / spotify:episode:...
|
||||
Artists []spotifyArtist `json:"artists"`
|
||||
Album struct {
|
||||
Name string `json:"name"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
} `json:"album"`
|
||||
Show struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"show"`
|
||||
ReleaseDate string `json:"release_date"` // episodes carry this at top level
|
||||
DurationMs int `json:"duration_ms"`
|
||||
TrackNumber int `json:"track_number"`
|
||||
IsPlayable *bool `json:"is_playable"`
|
||||
Restrictions struct {
|
||||
Reason string `json:"reason"`
|
||||
} `json:"restrictions"`
|
||||
}
|
||||
|
||||
// trackFromItem converts a Spotify playlist/library item into a playlist.Track,
|
||||
// handling both music tracks and podcast episodes. It uses the canonical uri
|
||||
// the API returns (spotify:track:... or spotify:episode:...) as the path, so
|
||||
// the player routes episodes to go-librespot's episode metadata path; building
|
||||
// "spotify:track:<id>" for an episode makes go-librespot request track metadata
|
||||
// for an episode id, which 404s. Episodes carry no artists/album, so the show
|
||||
// name fills those slots for display.
|
||||
func trackFromItem(t *spotifyItem) playlist.Track {
|
||||
artists := make([]string, len(t.Artists))
|
||||
for i, a := range t.Artists {
|
||||
artists[i] = a.Name
|
||||
}
|
||||
artist := strings.Join(artists, ", ")
|
||||
album := t.Album.Name
|
||||
if t.Type == "episode" {
|
||||
artist = t.Show.Name
|
||||
album = t.Show.Name
|
||||
}
|
||||
|
||||
releaseDate := t.Album.ReleaseDate
|
||||
if releaseDate == "" {
|
||||
releaseDate = t.ReleaseDate
|
||||
}
|
||||
var year int
|
||||
if len(releaseDate) >= 4 {
|
||||
if y, err := strconv.Atoi(releaseDate[:4]); err == nil {
|
||||
year = y
|
||||
}
|
||||
}
|
||||
|
||||
path := t.URI
|
||||
if path == "" {
|
||||
path = fmt.Sprintf("spotify:track:%s", t.ID) // fallback if uri is absent
|
||||
}
|
||||
|
||||
return playlist.Track{
|
||||
Path: path,
|
||||
Title: t.Name,
|
||||
Artist: artist,
|
||||
Album: album,
|
||||
Year: year,
|
||||
Stream: false, // must be false: true causes togglePlayPause to stop+restart instead of pause/resume
|
||||
DurationSecs: t.DurationMs / 1000,
|
||||
TrackNumber: t.TrackNumber,
|
||||
Unplayable: (t.IsPlayable != nil && !*t.IsPlayable) || t.Restrictions.Reason != "",
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,32 @@ package appdir
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// Dir returns the cliamp configuration directory (~/.config/cliamp).
|
||||
// Dir returns the cliamp configuration directory.
|
||||
//
|
||||
// Resolution order:
|
||||
// - CLIAMP_CONFIG_DIR (explicit override)
|
||||
// - XDG_CONFIG_HOME/cliamp
|
||||
// - HOME/.config/cliamp
|
||||
// - on Windows: APPDATA/cliamp
|
||||
// - fallback: os.UserHomeDir()/.config/cliamp
|
||||
func Dir() (string, error) {
|
||||
if dir, ok := os.LookupEnv("CLIAMP_CONFIG_DIR"); ok && dir != "" {
|
||||
return dir, nil
|
||||
}
|
||||
if xdg, ok := os.LookupEnv("XDG_CONFIG_HOME"); ok && xdg != "" {
|
||||
return filepath.Join(xdg, "cliamp"), nil
|
||||
}
|
||||
if home, ok := os.LookupEnv("HOME"); ok && home != "" {
|
||||
return filepath.Join(home, ".config", "cliamp"), nil
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
if appData, ok := os.LookupEnv("APPDATA"); ok && appData != "" {
|
||||
return filepath.Join(appData, "cliamp"), nil
|
||||
}
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -14,7 +36,7 @@ func Dir() (string, error) {
|
||||
return filepath.Join(home, ".config", "cliamp"), nil
|
||||
}
|
||||
|
||||
// PluginDir returns the cliamp plugin directory (~/.config/cliamp/plugins).
|
||||
// PluginDir returns the cliamp plugin directory.
|
||||
func PluginDir() (string, error) {
|
||||
dir, err := Dir()
|
||||
if err != nil {
|
||||
|
||||
@@ -1,26 +1,69 @@
|
||||
package appdir
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDir(t *testing.T) {
|
||||
dir, err := Dir()
|
||||
if err != nil {
|
||||
t.Fatalf("Dir() error: %v", err)
|
||||
tests := []struct {
|
||||
name string
|
||||
env map[string]string
|
||||
want func(tempDir string) string
|
||||
windowsOnly bool
|
||||
}{
|
||||
{
|
||||
name: "home config",
|
||||
env: map[string]string{"CLIAMP_CONFIG_DIR": "", "XDG_CONFIG_HOME": "", "APPDATA": "", "HOME": "TEMPDIR"},
|
||||
want: func(tmp string) string { return filepath.Join(tmp, ".config", "cliamp") },
|
||||
},
|
||||
{
|
||||
name: "xdg config",
|
||||
env: map[string]string{"CLIAMP_CONFIG_DIR": "", "HOME": "", "APPDATA": "", "XDG_CONFIG_HOME": "TEMPDIR"},
|
||||
want: func(tmp string) string { return filepath.Join(tmp, "cliamp") },
|
||||
},
|
||||
{
|
||||
name: "appdata on windows when home missing",
|
||||
windowsOnly: true,
|
||||
env: map[string]string{"CLIAMP_CONFIG_DIR": "", "XDG_CONFIG_HOME": "", "HOME": "", "APPDATA": "TEMPDIR"},
|
||||
want: func(tmp string) string { return filepath.Join(tmp, "cliamp") },
|
||||
},
|
||||
}
|
||||
|
||||
home, _ := os.UserHomeDir()
|
||||
want := filepath.Join(home, ".config", "cliamp")
|
||||
if dir != want {
|
||||
t.Fatalf("Dir() = %q, want %q", dir, want)
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.windowsOnly && runtime.GOOS != "windows" {
|
||||
t.Skip("Windows-specific fallback")
|
||||
}
|
||||
var tempDir string
|
||||
for k, v := range tt.env {
|
||||
if v == "TEMPDIR" {
|
||||
tempDir = t.TempDir()
|
||||
t.Setenv(k, tempDir)
|
||||
} else {
|
||||
t.Setenv(k, v)
|
||||
}
|
||||
}
|
||||
got, err := Dir()
|
||||
if err != nil {
|
||||
t.Fatalf("Dir() error: %v", err)
|
||||
}
|
||||
want := tt.want(tempDir)
|
||||
if got != want {
|
||||
t.Fatalf("Dir() = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginDir(t *testing.T) {
|
||||
t.Setenv("CLIAMP_CONFIG_DIR", "")
|
||||
t.Setenv("XDG_CONFIG_HOME", "")
|
||||
t.Setenv("APPDATA", "")
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
dir, err := PluginDir()
|
||||
if err != nil {
|
||||
t.Fatalf("PluginDir() error: %v", err)
|
||||
@@ -32,6 +75,11 @@ func TestPluginDir(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPluginDirIsSubdirOfDir(t *testing.T) {
|
||||
t.Setenv("CLIAMP_CONFIG_DIR", "")
|
||||
t.Setenv("XDG_CONFIG_HOME", "")
|
||||
t.Setenv("APPDATA", "")
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
base, _ := Dir()
|
||||
plugin, _ := PluginDir()
|
||||
|
||||
|
||||
+2
-5
@@ -3,12 +3,9 @@ package ipc
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"cliamp/internal/appdir"
|
||||
@@ -33,9 +30,9 @@ func Send(sockPath string, req Request) (Response, error) {
|
||||
// deadline. Plugin commands can legitimately run for minutes (downloads), so
|
||||
// the generic 5s cap is too short for them.
|
||||
func SendWithDeadline(sockPath string, req Request, deadline time.Duration) (Response, error) {
|
||||
conn, err := net.DialTimeout("unix", sockPath, 3*time.Second)
|
||||
conn, err := dialSocket(sockPath, 3*time.Second)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ECONNREFUSED) {
|
||||
if isSocketUnavailable(err) {
|
||||
return Response{}, fmt.Errorf("cliamp is not running (no socket at %s)", sockPath)
|
||||
}
|
||||
return Response{}, fmt.Errorf("connect: %w", err)
|
||||
|
||||
+1
-1
@@ -9,5 +9,5 @@ import (
|
||||
// package can send arbitrary bytes instead of going through Send (which
|
||||
// wraps a Request as JSON).
|
||||
func dialWithTimeout(sockPath string, d time.Duration) (net.Conn, error) {
|
||||
return net.DialTimeout("unix", sockPath, d)
|
||||
return dialSocket(sockPath, d)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package ipc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
func dialSocket(sockPath string, timeout time.Duration) (net.Conn, error) {
|
||||
return net.DialTimeout("unix", sockPath, timeout)
|
||||
}
|
||||
|
||||
func listenSocket(sockPath string) (net.Listener, error) {
|
||||
return net.Listen("unix", sockPath)
|
||||
}
|
||||
|
||||
// wsaeConnRefused is Windows' WSAECONNREFUSED, returned when dialing an
|
||||
// AF_UNIX socket nobody is listening on.
|
||||
const wsaeConnRefused = syscall.Errno(10061)
|
||||
|
||||
func isSocketUnavailable(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ECONNREFUSED) || errors.Is(err, wsaeConnRefused) {
|
||||
return true
|
||||
}
|
||||
// Last resort for platform errors that arrive untyped (Windows AF_UNIX
|
||||
// messages vary by version).
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "refused") ||
|
||||
strings.Contains(msg, "dead network") ||
|
||||
strings.Contains(msg, "no such file") ||
|
||||
strings.Contains(msg, "cannot find the file")
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package ipc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsSocketUnavailable(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "windows dead network AF_UNIX error",
|
||||
err: errors.New("connect: A socket operation encountered a dead network"),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "actively refused",
|
||||
err: errors.New("connect: No connection could be made because the target machine actively refused it"),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "unrelated network error",
|
||||
err: errors.New("connect: some other error"),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "nil error",
|
||||
err: nil,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "wrapped not-exist",
|
||||
err: fmt.Errorf("dial: %w", os.ErrNotExist),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "wrapped ECONNREFUSED",
|
||||
err: fmt.Errorf("dial: %w", syscall.ECONNREFUSED),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "WSAECONNREFUSED error",
|
||||
err: syscall.Errno(10061),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "wrapped WSAECONNREFUSED error",
|
||||
err: fmt.Errorf("dial: %w", syscall.Errno(10061)),
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isSocketUnavailable(tt.err); got != tt.want {
|
||||
t.Fatalf("isSocketUnavailable(%v) = %v, want %v", tt.err, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//go:build !windows
|
||||
|
||||
package ipc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func processAlive(pid int) (bool, error) {
|
||||
if pid <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
if pid == os.Getpid() {
|
||||
return true, nil
|
||||
}
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("probe process liveness: %w", err)
|
||||
}
|
||||
err = proc.Signal(syscall.Signal(0))
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
// os.Process.Signal converts the kernel's ESRCH into os.ErrProcessDone.
|
||||
if errors.Is(err, os.ErrProcessDone) || errors.Is(err, syscall.ESRCH) {
|
||||
return false, nil
|
||||
}
|
||||
// EPERM means the process exists but belongs to another user.
|
||||
if errors.Is(err, syscall.EPERM) {
|
||||
return true, nil
|
||||
}
|
||||
return false, fmt.Errorf("probe process liveness: %w", err)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//go:build windows
|
||||
|
||||
package ipc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func processAlive(pid int) (bool, error) {
|
||||
if pid <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
if pid == os.Getpid() {
|
||||
return true, nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
out, err := exec.CommandContext(ctx, "tasklist", "/FI", fmt.Sprintf("PID eq %d", pid), "/FO", "CSV", "/NH").Output()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("probe process liveness: %w", err)
|
||||
}
|
||||
r := csv.NewReader(strings.NewReader(string(out)))
|
||||
r.FieldsPerRecord = -1
|
||||
records, err := r.ReadAll()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("parse tasklist output: %w", err)
|
||||
}
|
||||
pidStr := strconv.Itoa(pid)
|
||||
for _, record := range records {
|
||||
if len(record) > 1 {
|
||||
if strings.Trim(record[1], ` "`) == pidStr {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//go:build windows
|
||||
|
||||
package ipc
|
||||
|
||||
import (
|
||||
"math"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProcessAlive(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pid int
|
||||
want bool
|
||||
}{
|
||||
{name: "negative pid", pid: -1, want: false},
|
||||
{name: "zero pid", pid: 0, want: false},
|
||||
{name: "current process", pid: os.Getpid(), want: true},
|
||||
{name: "unlikely pid", pid: math.MaxInt32, want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := processAlive(tt.pid)
|
||||
if err != nil {
|
||||
t.Fatalf("processAlive(%d) unexpected error: %v", tt.pid, err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("processAlive(%d) = %v, want %v", tt.pid, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+4
-10
@@ -11,7 +11,6 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"cliamp/applog"
|
||||
@@ -85,7 +84,7 @@ func NewServer(sockPath string, disp Dispatcher) (*Server, error) {
|
||||
return nil, fmt.Errorf("ipc: mkdir: %w", err)
|
||||
}
|
||||
|
||||
ln, err := net.Listen("unix", sockPath)
|
||||
ln, err := listenSocket(sockPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ipc: listen: %w", err)
|
||||
}
|
||||
@@ -382,16 +381,11 @@ func cleanStaleSocket(sockPath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
proc, err := os.FindProcess(pid)
|
||||
alive, err := processAlive(pid)
|
||||
if err != nil {
|
||||
// Can't find process — clean up.
|
||||
os.Remove(pidPath)
|
||||
os.Remove(sockPath)
|
||||
return nil
|
||||
return fmt.Errorf("checking process liveness for socket %s: %w", sockPath, err)
|
||||
}
|
||||
|
||||
// Signal 0 checks if the process exists without actually sending a signal.
|
||||
if err := proc.Signal(syscall.Signal(0)); err != nil {
|
||||
if !alive {
|
||||
// Process is dead — clean up stale files.
|
||||
os.Remove(pidPath)
|
||||
os.Remove(sockPath)
|
||||
|
||||
+2
-6
@@ -4,12 +4,8 @@ import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -21,9 +17,9 @@ func StreamBands(ctx context.Context, sockPath string, interval time.Duration, o
|
||||
interval = 33 * time.Millisecond
|
||||
}
|
||||
|
||||
conn, err := net.DialTimeout("unix", sockPath, 3*time.Second)
|
||||
conn, err := dialSocket(sockPath, 3*time.Second)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ECONNREFUSED) {
|
||||
if isSocketUnavailable(err) {
|
||||
return fmt.Errorf("cliamp is not running (no socket at %s)", sockPath)
|
||||
}
|
||||
return fmt.Errorf("connect: %w", err)
|
||||
|
||||
+16
-9
@@ -243,7 +243,7 @@ func registerExecAPI(L *lua.LState, cliamp *lua.LTable, em *execManager, p *Plug
|
||||
}
|
||||
// Empty env by default — plugins should not inherit secrets like
|
||||
// AWS_*, SSH_*, etc. yt-dlp and ffmpeg both run fine with a minimal env.
|
||||
cmd.Env = []string{"PATH=/usr/local/bin:/usr/bin:/bin", "HOME=" + homeEnv(), "LANG=C.UTF-8"}
|
||||
cmd.Env = minimalExecEnv()
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
@@ -312,18 +312,21 @@ func registerExecAPI(L *lua.LState, cliamp *lua.LTable, em *execManager, p *Plug
|
||||
go func() {
|
||||
wg.Wait()
|
||||
waitErr := cmd.Wait()
|
||||
ctxErr := ctx.Err()
|
||||
cancel()
|
||||
em.remove(entry)
|
||||
|
||||
code := 0
|
||||
if waitErr != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(waitErr, &exitErr) {
|
||||
code = exitErr.ExitCode()
|
||||
} else if ctx.Err() != nil {
|
||||
if ctxErr != nil {
|
||||
code = -1 // cancelled or timed out
|
||||
} else {
|
||||
code = -2 // other error
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(waitErr, &exitErr) {
|
||||
code = exitErr.ExitCode()
|
||||
} else {
|
||||
code = -2 // other error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,11 +366,15 @@ func registerExecAPI(L *lua.LState, cliamp *lua.LTable, em *execManager, p *Plug
|
||||
L.SetField(cliamp, "exec", tbl)
|
||||
}
|
||||
|
||||
// homeEnv returns the user's home directory for subprocess HOME, or "/" if
|
||||
// unset. yt-dlp and ffmpeg both read HOME (~/.cache, ~/.config).
|
||||
// homeEnv returns the user's home directory for subprocess HOME, preferring
|
||||
// $HOME, then os.UserHomeDir(), falling back to os.TempDir() when unset.
|
||||
// yt-dlp and ffmpeg both read HOME (~/.cache, ~/.config).
|
||||
func homeEnv() string {
|
||||
if home, ok := os.LookupEnv("HOME"); ok && home != "" {
|
||||
return home
|
||||
}
|
||||
if h, err := os.UserHomeDir(); err == nil {
|
||||
return h
|
||||
}
|
||||
return "/"
|
||||
return os.TempDir()
|
||||
}
|
||||
|
||||
+88
-25
@@ -1,7 +1,10 @@
|
||||
package luaplugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -11,7 +14,8 @@ import (
|
||||
)
|
||||
|
||||
// newExecTestState builds a minimal plugin + manager wired up for exec tests.
|
||||
// The binary allowlist is scoped to basic POSIX tools so tests don't need yt-dlp.
|
||||
// The binary allowlist is scoped to a few harmless OS-native tools so tests
|
||||
// don't need yt-dlp or ffmpeg.
|
||||
func newExecTestState(t *testing.T, perms []string) (*lua.LState, *Plugin, *execManager, func()) {
|
||||
t.Helper()
|
||||
L := lua.NewState()
|
||||
@@ -24,7 +28,7 @@ func newExecTestState(t *testing.T, perms []string) (*lua.LState, *Plugin, *exec
|
||||
}
|
||||
}
|
||||
|
||||
em := newExecManager([]string{"echo", "false", "sleep", "sh", "cat"})
|
||||
em := newExecManager(execTestAllowedBinaries())
|
||||
cliamp := L.NewTable()
|
||||
registerExecAPI(L, cliamp, em, p, newPluginLogger(""))
|
||||
L.SetGlobal("cliamp", cliamp)
|
||||
@@ -32,6 +36,55 @@ func newExecTestState(t *testing.T, perms []string) (*lua.LState, *Plugin, *exec
|
||||
return L, p, em, func() { em.stopAll(); L.Close() }
|
||||
}
|
||||
|
||||
func execTestAllowedBinaries() []string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return []string{"cmd", "powershell"}
|
||||
}
|
||||
return []string{"echo", "false", "sleep", "sh", "cat"}
|
||||
}
|
||||
|
||||
func execOutputCommand() (binary string, args []string, wantLine string) {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "powershell", []string{"-NoProfile", "-Command", "Write-Output 'hello world'"}, "hello world"
|
||||
}
|
||||
return "echo", []string{"hello", "world"}, "hello world"
|
||||
}
|
||||
|
||||
func execFailureCommand() (binary string, args []string) {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "powershell", []string{"-NoProfile", "-Command", "exit 1"}
|
||||
}
|
||||
return "false", nil
|
||||
}
|
||||
|
||||
func execSleepCommand(seconds int) (binary string, args []string) {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "powershell", []string{"-NoProfile", "-Command", fmt.Sprintf("Start-Sleep -Seconds %d", seconds)}
|
||||
}
|
||||
return "sleep", []string{strconv.Itoa(seconds)}
|
||||
}
|
||||
|
||||
func execDisallowedCWD() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
if root := os.Getenv("WINDIR"); root != "" {
|
||||
return root
|
||||
}
|
||||
return `C:\Windows`
|
||||
}
|
||||
return "/etc"
|
||||
}
|
||||
|
||||
func luaStringList(items []string) string {
|
||||
if len(items) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
parts = append(parts, fmt.Sprintf("%q", item))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// waitExec polls for a Lua global to be set. The plugin mutex must be held
|
||||
// while touching LState — exec goroutines also take it before calling into
|
||||
// Lua, so reading without the lock would race on LState itself.
|
||||
@@ -53,20 +106,21 @@ func waitExec(t *testing.T, p *Plugin, L *lua.LState, name string, timeout time.
|
||||
func TestExecRunsAllowedBinary(t *testing.T) {
|
||||
L, p, _, cleanup := newExecTestState(t, []string{"exec"})
|
||||
defer cleanup()
|
||||
binary, args, wantLine := execOutputCommand()
|
||||
|
||||
// Drive callbacks into globals so the test can assert state, but acquire
|
||||
// the plugin mutex first — the exec goroutines also take it before calling
|
||||
// Lua, so without locking here we'd race on LState itself.
|
||||
p.mu.Lock()
|
||||
err := L.DoString(`
|
||||
err := L.DoString(fmt.Sprintf(`
|
||||
_G.lines = {}
|
||||
_G.exit_code = nil
|
||||
local h, err = cliamp.exec.run("echo", {"hello", "world"}, {
|
||||
local h, err = cliamp.exec.run(%q, {%s}, {
|
||||
on_stdout = function(line) table.insert(_G.lines, line) end,
|
||||
on_exit = function(code) _G.exit_code = code end,
|
||||
})
|
||||
assert(h, tostring(err))
|
||||
`)
|
||||
`, binary, luaStringList(args)))
|
||||
p.mu.Unlock()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -80,7 +134,14 @@ func TestExecRunsAllowedBinary(t *testing.T) {
|
||||
t.Fatalf("exit_code = %v, want 0", code)
|
||||
}
|
||||
lines := L.GetGlobal("lines").(*lua.LTable)
|
||||
if lines.Len() != 1 || lines.RawGetInt(1).String() != "hello world" {
|
||||
matched := false
|
||||
for i := 1; i <= lines.Len(); i++ {
|
||||
if strings.TrimSpace(lines.RawGetInt(i).String()) == wantLine {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
t.Fatalf("unexpected stdout: %v", lines)
|
||||
}
|
||||
}
|
||||
@@ -128,14 +189,15 @@ func TestExecRequiresPermission(t *testing.T) {
|
||||
func TestExecPropagatesExitCode(t *testing.T) {
|
||||
L, p, _, cleanup := newExecTestState(t, []string{"exec"})
|
||||
defer cleanup()
|
||||
binary, args := execFailureCommand()
|
||||
|
||||
p.mu.Lock()
|
||||
err := L.DoString(`
|
||||
err := L.DoString(fmt.Sprintf(`
|
||||
_G.exit_code = nil
|
||||
cliamp.exec.run("false", {}, {
|
||||
cliamp.exec.run(%q, {%s}, {
|
||||
on_exit = function(code) _G.exit_code = code end,
|
||||
})
|
||||
`)
|
||||
`, binary, luaStringList(args)))
|
||||
p.mu.Unlock()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -152,14 +214,15 @@ func TestExecPropagatesExitCode(t *testing.T) {
|
||||
func TestExecCancel(t *testing.T) {
|
||||
L, p, _, cleanup := newExecTestState(t, []string{"exec"})
|
||||
defer cleanup()
|
||||
binary, args := execSleepCommand(10)
|
||||
|
||||
p.mu.Lock()
|
||||
err := L.DoString(`
|
||||
err := L.DoString(fmt.Sprintf(`
|
||||
_G.exit_code = nil
|
||||
_G.handle = cliamp.exec.run("sleep", {"10"}, {
|
||||
_G.handle = cliamp.exec.run(%q, {%s}, {
|
||||
on_exit = function(code) _G.exit_code = code end,
|
||||
})
|
||||
`)
|
||||
`, binary, luaStringList(args)))
|
||||
p.mu.Unlock()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -184,20 +247,21 @@ func TestExecCancel(t *testing.T) {
|
||||
func TestExecConcurrencyCap(t *testing.T) {
|
||||
L, p, _, cleanup := newExecTestState(t, []string{"exec"})
|
||||
defer cleanup()
|
||||
binary, args := execSleepCommand(2)
|
||||
|
||||
p.mu.Lock()
|
||||
err := L.DoString(`
|
||||
err := L.DoString(fmt.Sprintf(`
|
||||
_G.errs = {}
|
||||
_G.handles = {}
|
||||
for i = 1, 6 do
|
||||
local h, err = cliamp.exec.run("sleep", {"2"}, {})
|
||||
local h, err = cliamp.exec.run(%q, {%s}, {})
|
||||
if h then
|
||||
table.insert(_G.handles, h)
|
||||
else
|
||||
table.insert(_G.errs, err or "?")
|
||||
end
|
||||
end
|
||||
`)
|
||||
`, binary, luaStringList(args)))
|
||||
p.mu.Unlock()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -228,6 +292,7 @@ func TestExecConcurrencyCap(t *testing.T) {
|
||||
func TestExecStopPluginKillsChildren(t *testing.T) {
|
||||
L, p, em, cleanup := newExecTestState(t, []string{"exec"})
|
||||
defer cleanup()
|
||||
binary, args := execSleepCommand(10)
|
||||
|
||||
var exits sync.WaitGroup
|
||||
exits.Add(1)
|
||||
@@ -237,11 +302,11 @@ func TestExecStopPluginKillsChildren(t *testing.T) {
|
||||
exits.Done()
|
||||
return 0
|
||||
}))
|
||||
err := L.DoString(`
|
||||
cliamp.exec.run("sleep", {"10"}, {
|
||||
err := L.DoString(fmt.Sprintf(`
|
||||
cliamp.exec.run(%q, {%s}, {
|
||||
on_exit = function() notify() end,
|
||||
})
|
||||
`)
|
||||
`, binary, luaStringList(args)))
|
||||
p.mu.Unlock()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -290,13 +355,15 @@ func TestResolveAllowedBinaries(t *testing.T) {
|
||||
func TestExecCwdMustBeAllowed(t *testing.T) {
|
||||
L, p, _, cleanup := newExecTestState(t, []string{"exec"})
|
||||
defer cleanup()
|
||||
binary, args, _ := execOutputCommand()
|
||||
cwd := execDisallowedCWD()
|
||||
|
||||
p.mu.Lock()
|
||||
err := L.DoString(`
|
||||
local h, err = cliamp.exec.run("echo", {"hi"}, {cwd = "/etc"})
|
||||
err := L.DoString(fmt.Sprintf(`
|
||||
local h, err = cliamp.exec.run(%q, {%s}, {cwd = %q})
|
||||
_G.handle = h
|
||||
_G.err = err
|
||||
`)
|
||||
`, binary, luaStringList(args), cwd))
|
||||
p.mu.Unlock()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -311,10 +378,6 @@ func TestExecCwdMustBeAllowed(t *testing.T) {
|
||||
|
||||
// Guard against a regression where a missing binary silently returns a handle.
|
||||
func TestExecBinaryNotOnPath(t *testing.T) {
|
||||
if _, err := os.Stat("/bin/echo"); err != nil {
|
||||
t.Skip("/bin/echo missing, can't run exec tests on this host")
|
||||
}
|
||||
|
||||
L := lua.NewState()
|
||||
defer L.Close()
|
||||
p := &Plugin{Name: "test", L: L, perms: map[string]bool{"exec": true}}
|
||||
|
||||
+17
-5
@@ -4,6 +4,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@@ -19,8 +20,9 @@ var (
|
||||
|
||||
// writeAllowDirs returns the directories where plugins can write files, with
|
||||
// symlinks resolved so the prefix check in isWriteAllowed cannot be bypassed
|
||||
// by a symlinked allow dir (e.g. /tmp -> /private/tmp on macOS). The result is
|
||||
// cached since these paths never change at runtime.
|
||||
// by a symlinked allow dir (e.g. /tmp -> /private/tmp on macOS). Entries are
|
||||
// normalized via normalizeWritePath so isWriteAllowed can compare directly.
|
||||
// The result is cached since these paths never change at runtime.
|
||||
func writeAllowDirs() []string {
|
||||
allowDirsOnce.Do(func() {
|
||||
raw := []string{"/tmp", os.TempDir()}
|
||||
@@ -31,7 +33,6 @@ func writeAllowDirs() []string {
|
||||
raw = append(raw, filepath.Join(home, ".local", "share", "cliamp"))
|
||||
raw = append(raw, filepath.Join(home, "Music", "cliamp"))
|
||||
}
|
||||
sep := string(os.PathSeparator)
|
||||
for _, d := range raw {
|
||||
abs, err := filepath.Abs(d)
|
||||
if err != nil {
|
||||
@@ -40,7 +41,7 @@ func writeAllowDirs() []string {
|
||||
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
|
||||
abs = resolved
|
||||
}
|
||||
allowDirs = append(allowDirs, abs+sep)
|
||||
allowDirs = append(allowDirs, normalizeWritePath(abs))
|
||||
}
|
||||
})
|
||||
return allowDirs
|
||||
@@ -80,14 +81,25 @@ func isWriteAllowed(path string) bool {
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
abs = normalizeWritePath(abs)
|
||||
for _, dir := range writeAllowDirs() {
|
||||
if strings.HasPrefix(abs, dir) {
|
||||
if abs == dir || strings.HasPrefix(abs, dir+string(os.PathSeparator)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// normalizeWritePath canonicalizes an absolute path for prefix comparison:
|
||||
// cleaned and, on Windows, case-folded (Windows paths are case-insensitive).
|
||||
func normalizeWritePath(path string) string {
|
||||
path = filepath.Clean(path)
|
||||
if runtime.GOOS == "windows" {
|
||||
path = strings.ToLower(path)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// registerFSAPI adds cliamp.fs.{write,append,read,remove,exists} to the cliamp table.
|
||||
func registerFSAPI(L *lua.LState, cliamp *lua.LTable) {
|
||||
tbl := L.NewTable()
|
||||
|
||||
+27
-14
@@ -1,13 +1,29 @@
|
||||
package luaplugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
func fsAllowedPath(name string) string {
|
||||
return filepath.Join(os.TempDir(), name)
|
||||
}
|
||||
|
||||
func fsDisallowedPath() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
if root := os.Getenv("WINDIR"); root != "" {
|
||||
return filepath.Join(root, "System32", "drivers", "etc", "hosts")
|
||||
}
|
||||
return `C:\Windows\System32\drivers\etc\hosts`
|
||||
}
|
||||
return "/etc/passwd"
|
||||
}
|
||||
|
||||
func TestFSWriteAndRead(t *testing.T) {
|
||||
L := lua.NewState()
|
||||
defer L.Close()
|
||||
@@ -15,7 +31,7 @@ func TestFSWriteAndRead(t *testing.T) {
|
||||
registerFSAPI(L, cliamp)
|
||||
L.SetGlobal("cliamp", cliamp)
|
||||
|
||||
tmp := filepath.Join("/tmp", "cliamp-test-"+t.Name())
|
||||
tmp := fsAllowedPath("cliamp-test-" + t.Name())
|
||||
defer os.Remove(tmp)
|
||||
|
||||
L.SetGlobal("path", lua.LString(tmp))
|
||||
@@ -44,7 +60,7 @@ func TestFSAppend(t *testing.T) {
|
||||
registerFSAPI(L, cliamp)
|
||||
L.SetGlobal("cliamp", cliamp)
|
||||
|
||||
tmp := filepath.Join("/tmp", "cliamp-test-append-"+t.Name())
|
||||
tmp := fsAllowedPath("cliamp-test-append-" + t.Name())
|
||||
defer os.Remove(tmp)
|
||||
|
||||
L.SetGlobal("path", lua.LString(tmp))
|
||||
@@ -69,12 +85,12 @@ func TestFSExists(t *testing.T) {
|
||||
registerFSAPI(L, cliamp)
|
||||
L.SetGlobal("cliamp", cliamp)
|
||||
|
||||
tmp := filepath.Join("/tmp", "cliamp-test-exists-"+t.Name())
|
||||
tmp := fsAllowedPath("cliamp-test-exists-" + t.Name())
|
||||
os.WriteFile(tmp, []byte("x"), 0o644)
|
||||
defer os.Remove(tmp)
|
||||
|
||||
L.SetGlobal("path", lua.LString(tmp))
|
||||
L.SetGlobal("fake", lua.LString("/tmp/cliamp-definitely-not-here"))
|
||||
L.SetGlobal("fake", lua.LString(fsAllowedPath("cliamp-definitely-not-here")))
|
||||
err := L.DoString(`
|
||||
_G.exists = cliamp.fs.exists(path)
|
||||
_G.not_exists = cliamp.fs.exists(fake)
|
||||
@@ -98,7 +114,7 @@ func TestFSRemove(t *testing.T) {
|
||||
registerFSAPI(L, cliamp)
|
||||
L.SetGlobal("cliamp", cliamp)
|
||||
|
||||
tmp := filepath.Join("/tmp", "cliamp-test-remove-"+t.Name())
|
||||
tmp := fsAllowedPath("cliamp-test-remove-" + t.Name())
|
||||
os.WriteFile(tmp, []byte("x"), 0o644)
|
||||
|
||||
L.SetGlobal("path", lua.LString(tmp))
|
||||
@@ -123,9 +139,8 @@ func TestIsWriteAllowed(t *testing.T) {
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{"/tmp/test.txt", true},
|
||||
{"/etc/passwd", false},
|
||||
{"/home/user/.ssh/id_rsa", false},
|
||||
{fsAllowedPath("test.txt"), true},
|
||||
{fsDisallowedPath(), false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -142,7 +157,7 @@ func TestFSMkdirAndListdir(t *testing.T) {
|
||||
registerFSAPI(L, cliamp)
|
||||
L.SetGlobal("cliamp", cliamp)
|
||||
|
||||
base := filepath.Join("/tmp", "cliamp-test-mkdir-"+t.Name())
|
||||
base := fsAllowedPath("cliamp-test-mkdir-" + t.Name())
|
||||
defer os.RemoveAll(base)
|
||||
|
||||
L.SetGlobal("base", lua.LString(base))
|
||||
@@ -176,17 +191,15 @@ func TestFSMkdirRejectsOutsideAllowlist(t *testing.T) {
|
||||
registerFSAPI(L, cliamp)
|
||||
L.SetGlobal("cliamp", cliamp)
|
||||
|
||||
err := L.DoString(`cliamp.fs.mkdir("/etc/cliamp-evil")`)
|
||||
err := L.DoString(fmt.Sprintf("cliamp.fs.mkdir(%q)", fsDisallowedPath()))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for path outside allowlist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMusicDirIsAllowed(t *testing.T) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
t.Skip("no home dir")
|
||||
}
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
path := filepath.Join(home, "Music", "cliamp", "album", "01.mp3")
|
||||
if !isWriteAllowed(path) {
|
||||
t.Errorf("~/Music/cliamp/... should be writable")
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
//go:build !windows
|
||||
|
||||
package luaplugin
|
||||
|
||||
import "os"
|
||||
|
||||
func minimalExecEnv() []string {
|
||||
path := os.Getenv("PATH")
|
||||
if path == "" {
|
||||
path = "/usr/local/bin:/usr/bin:/bin"
|
||||
}
|
||||
return []string{
|
||||
"PATH=" + path,
|
||||
"HOME=" + homeEnv(),
|
||||
"LANG=C.UTF-8",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//go:build windows
|
||||
|
||||
package luaplugin
|
||||
|
||||
import "os"
|
||||
|
||||
func minimalExecEnv() []string {
|
||||
home := homeEnv()
|
||||
env := []string{
|
||||
"PATH=" + os.Getenv("PATH"),
|
||||
"HOME=" + home,
|
||||
"USERPROFILE=" + home,
|
||||
}
|
||||
// Pass through the Windows variables subprocesses commonly need; skip
|
||||
// any that are unset.
|
||||
for _, key := range []string{"APPDATA", "LOCALAPPDATA", "ComSpec", "PATHEXT", "SystemRoot", "WINDIR", "TEMP", "TMP"} {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
env = append(env, key+"="+v)
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
@@ -411,6 +411,7 @@ func TestControlClampsBounds(t *testing.T) {
|
||||
func TestControlWithoutPermissionIsNoop(t *testing.T) {
|
||||
m := newTestManager()
|
||||
m.logger = newPluginLogger(filepath.Join(t.TempDir(), "test.log"))
|
||||
t.Cleanup(m.Close)
|
||||
called := false
|
||||
m.SetControlProvider(ControlProvider{
|
||||
SetVolume: func(db float64) { called = true },
|
||||
|
||||
@@ -98,7 +98,11 @@ func run(overrides config.Overrides, positional []string, daemon bool) error {
|
||||
if cfg.Spotify.IsSet() {
|
||||
clientID := cfg.Spotify.ResolveClientID(spotify.DefaultClientID)
|
||||
spotifyProv = spotify.New(nil, clientID, cfg.Spotify.Bitrate)
|
||||
providers = append(providers, model.ProviderEntry{Key: "spotify", Name: "Spotify", Provider: spotifyProv})
|
||||
if spotifyProv != nil {
|
||||
providers = append(providers, model.ProviderEntry{Key: "spotify", Name: "Spotify", Provider: spotifyProv})
|
||||
} else {
|
||||
fmt.Fprintln(os.Stderr, "Spotify is unavailable in this Windows build.")
|
||||
}
|
||||
}
|
||||
|
||||
if scProv := soundcloud.NewFromConfig(soundcloud.Config{
|
||||
|
||||
@@ -4,6 +4,7 @@ package playlist
|
||||
import (
|
||||
"math/rand"
|
||||
"net/url"
|
||||
pathpkg "path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -108,7 +109,7 @@ func IsM3U(path string) bool {
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(u.Path))
|
||||
ext := strings.ToLower(pathpkg.Ext(u.Path))
|
||||
return ext == ".m3u" || ext == ".m3u8"
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
@@ -127,7 +128,7 @@ func IsPLS(path string) bool {
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.ToLower(filepath.Ext(u.Path)) == ".pls"
|
||||
return strings.ToLower(pathpkg.Ext(u.Path)) == ".pls"
|
||||
}
|
||||
return strings.ToLower(filepath.Ext(path)) == ".pls"
|
||||
}
|
||||
@@ -243,7 +244,7 @@ func IsFeed(path string) bool {
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(u.Path))
|
||||
ext := strings.ToLower(pathpkg.Ext(u.Path))
|
||||
return ext == ".xml" || ext == ".rss" || ext == ".atom"
|
||||
}
|
||||
|
||||
@@ -268,10 +269,11 @@ func trackFromURL(rawURL string) Track {
|
||||
return t
|
||||
}
|
||||
|
||||
// Extract filename from URL path
|
||||
base := filepath.Base(u.Path)
|
||||
// Extract filename from URL path using slash semantics, not OS-specific
|
||||
// filepath rules. URL paths always use '/'.
|
||||
base := pathpkg.Base(u.Path)
|
||||
if base != "" && base != "." && base != "/" {
|
||||
name := strings.TrimSuffix(base, filepath.Ext(base))
|
||||
name := strings.TrimSuffix(base, pathpkg.Ext(base))
|
||||
if name != "" && name != "stream" && name != "rest" {
|
||||
t.Title = name
|
||||
return t
|
||||
|
||||
+49
-2
@@ -4,6 +4,7 @@ import (
|
||||
"bufio"
|
||||
"io"
|
||||
"os"
|
||||
pathpkg "path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -72,8 +73,8 @@ func parseM3U(r io.Reader, baseDir string) ([]m3uEntry, error) {
|
||||
|
||||
// This is a path/URL line.
|
||||
path := line
|
||||
if baseDir != "" && !playlist.IsURL(path) && !filepath.IsAbs(path) {
|
||||
path = filepath.Join(baseDir, path)
|
||||
if baseDir != "" && !playlist.IsURL(path) {
|
||||
path = resolveM3UPath(baseDir, path)
|
||||
}
|
||||
|
||||
if pending != nil {
|
||||
@@ -134,3 +135,49 @@ func resolveLocalM3U(path string) ([]playlist.Track, error) {
|
||||
}
|
||||
return entriesToTracks(entries), nil
|
||||
}
|
||||
|
||||
// resolveM3UPath resolves an M3U entry path against a base directory.
|
||||
// It returns the entry unchanged for URLs or empty paths.
|
||||
// It checks for explicit Windows or POSIX absolute paths via
|
||||
// isWindowsAbsolutePath and isPOSIXAbsolutePath, falling back to filepath.IsAbs.
|
||||
// If usesWindowsPathSemantics(baseDir) is true, it resolves the path with Windows
|
||||
// filepath semantics (filepath.Join and filepath.FromSlash); otherwise, it uses
|
||||
// POSIX semantics via pathpkg.Join.
|
||||
func resolveM3UPath(baseDir, entry string) string {
|
||||
if entry == "" || playlist.IsURL(entry) {
|
||||
return entry
|
||||
}
|
||||
if isWindowsAbsolutePath(entry) {
|
||||
return filepath.Clean(entry)
|
||||
}
|
||||
if isPOSIXAbsolutePath(entry) {
|
||||
return pathpkg.Clean(entry)
|
||||
}
|
||||
if filepath.IsAbs(entry) {
|
||||
return filepath.Clean(entry)
|
||||
}
|
||||
if usesWindowsPathSemantics(baseDir) {
|
||||
return filepath.Join(baseDir, filepath.FromSlash(entry))
|
||||
}
|
||||
return pathpkg.Join(baseDir, entry)
|
||||
}
|
||||
|
||||
func usesWindowsPathSemantics(base string) bool {
|
||||
return strings.Contains(base, `\`) || hasWindowsDrivePrefix(base)
|
||||
}
|
||||
|
||||
func isPOSIXAbsolutePath(path string) bool {
|
||||
return strings.HasPrefix(path, "/")
|
||||
}
|
||||
|
||||
func isWindowsAbsolutePath(path string) bool {
|
||||
return strings.HasPrefix(path, `\\`) || hasWindowsDrivePrefix(path)
|
||||
}
|
||||
|
||||
func hasWindowsDrivePrefix(path string) bool {
|
||||
if len(path) < 2 || path[1] != ':' {
|
||||
return false
|
||||
}
|
||||
c := path[0]
|
||||
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package resolve
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -186,3 +187,46 @@ func TestM3UEntryToTrackFile(t *testing.T) {
|
||||
t.Errorf("DurationSecs = %d, want 180", tr.DurationSecs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveM3UPathWindows(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
baseDir string
|
||||
entry string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "relative backslash",
|
||||
baseDir: `C:\Music`,
|
||||
entry: `artist\song.mp3`,
|
||||
want: filepath.Clean(filepath.Join(`C:\Music`, filepath.FromSlash(`artist\song.mp3`))),
|
||||
},
|
||||
{
|
||||
name: "relative forward slash",
|
||||
baseDir: `C:\Music`,
|
||||
entry: `artist/song.mp3`,
|
||||
want: filepath.Clean(filepath.Join(`C:\Music`, filepath.FromSlash(`artist/song.mp3`))),
|
||||
},
|
||||
{
|
||||
name: "absolute drive-letter",
|
||||
baseDir: `C:\Music`,
|
||||
entry: `D:\Other\track.mp3`,
|
||||
want: filepath.Clean(`D:\Other\track.mp3`),
|
||||
},
|
||||
{
|
||||
name: "absolute UNC path",
|
||||
baseDir: `\\server\share`,
|
||||
entry: `sub\file.mp3`,
|
||||
want: filepath.Clean(filepath.Join(`\\server\share`, filepath.FromSlash(`sub\file.mp3`))),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := resolveM3UPath(tt.baseDir, tt.entry)
|
||||
if got != tt.want {
|
||||
t.Fatalf("resolveM3UPath(%q, %q) = %q, want %q", tt.baseDir, tt.entry, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+5
-5
@@ -1479,7 +1479,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="install-note">
|
||||
Or build from source — <a href="https://github.com/bjarneo/cliamp">see the README</a>.
|
||||
Or build from source — <a href="https://github.com/bjarneo/cliamp">see the README</a>. Windows builds are available from Releases; when <code>HOME</code> is unset, config lives under <code>%APPDATA%\cliamp</code>.
|
||||
</div>
|
||||
|
||||
<div class="next-step">
|
||||
@@ -1510,7 +1510,7 @@
|
||||
<div class="source" style="--src-color:#1db954">
|
||||
<div class="source-badge">OAuth · cached</div>
|
||||
<div class="source-name">Spotify</div>
|
||||
<div class="source-desc">Stream your Premium library. Bring your own developer <code>client_id</code> for a private quota, or use the built-in shared one. Search & add tracks with <kbd>F</kbd>.</div>
|
||||
<div class="source-desc">Stream your Premium library. Bring your own developer <code>client_id</code> for a private quota, or use the built-in shared one. Search & add tracks with <kbd>F</kbd>. Currently unavailable on Windows builds.</div>
|
||||
</div>
|
||||
<div class="source" style="--src-color:#ff0000">
|
||||
<div class="source-badge">yt-dlp</div>
|
||||
@@ -1683,7 +1683,7 @@ user_id = "your-account-user-id"</code></pre>
|
||||
<div class="feature">
|
||||
<div class="feature-icon">⌁</div>
|
||||
<div class="feature-name">Remote Control</div>
|
||||
<p>Control a running instance from another terminal via Unix-socket IPC. Run with <code>--daemon</code> for headless playback driven entirely by scripts or Waybar.</p>
|
||||
<p>Control a running instance from another terminal via local-socket IPC. Run with <code>--daemon</code> for headless playback driven entirely by scripts or Waybar.</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<div class="feature-icon">⎔</div>
|
||||
@@ -2071,7 +2071,7 @@ user_id = "your-account-user-id"</code></pre>
|
||||
</div>
|
||||
<p class="cli-remote-intro">
|
||||
CLI flags override any config option for a single session.
|
||||
Remote commands control a running instance over Unix-socket IPC — open another terminal and talk to <code>cliamp</code> directly.
|
||||
Remote commands control a running instance over local-socket IPC — open another terminal and talk to <code>cliamp</code> directly.
|
||||
</p>
|
||||
<div class="cli-remote-grid">
|
||||
|
||||
@@ -2248,7 +2248,7 @@ p:<span class="fn">on</span>(<span class="str">"track.change"</span>, <span clas
|
||||
</div>
|
||||
<div class="plugin-api">
|
||||
<div class="plugin-api-name">fs</div>
|
||||
<p>Sandboxed I/O: <code>write()</code>, <code>read()</code>, <code>append()</code>, <code>remove()</code>, <code>exists()</code>, <code>mkdir()</code>, <code>listdir()</code></p>
|
||||
<p>Sandboxed I/O: <code>write()</code>, <code>read()</code>, <code>append()</code>, <code>remove()</code>, <code>exists()</code>, <code>mkdir()</code>, <code>listdir()</code>. Writes are restricted to the system temp directory (<code>/tmp/</code> on Unix), <code>~/.config/cliamp/</code>, <code>~/.local/share/cliamp/</code>, and <code>~/Music/cliamp/</code> (on Windows, if <code>HOME</code> is unset, the config directory resolves to <code>%APPDATA%\cliamp</code>).</p>
|
||||
</div>
|
||||
<div class="plugin-api">
|
||||
<div class="plugin-api-name">exec</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -120,8 +121,8 @@ func TestDownloadAndReplace(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Stat: %v", err)
|
||||
}
|
||||
// Verify executable bit is set (0o755).
|
||||
if info.Mode().Perm()&0o111 == 0 {
|
||||
// Verify executable bit is set where the platform models one.
|
||||
if runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 {
|
||||
t.Errorf("target mode = %o, want executable", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user