This commit is contained in:
Bjarne Øverli
2026-07-10 21:46:19 +02:00
parent 5e16bb7c8c
commit c110b228cd
18 changed files with 453 additions and 177 deletions
+18 -1
View File
@@ -2,7 +2,7 @@ VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
BINARY ?= cliamp
LDFLAGS := -s -w -X main.version=$(VERSION)
.PHONY: build test vet lint fmt check clean install
.PHONY: build test vet lint staticcheck fmt fmt-check coverage security ci check clean install
build:
go build -trimpath -ldflags="$(LDFLAGS)" -o $(BINARY) .
@@ -16,9 +16,26 @@ vet:
lint: vet
@if command -v staticcheck >/dev/null 2>&1; then staticcheck ./...; else echo "staticcheck not installed — skipping (go install honnef.co/go/tools/cmd/staticcheck@latest)"; fi
staticcheck:
@command -v staticcheck >/dev/null 2>&1 || { echo "staticcheck is required"; exit 1; }
staticcheck ./...
fmt:
gofmt -l -w .
fmt-check:
@test -z "$$(gofmt -l .)" || { gofmt -l .; exit 1; }
coverage:
go test -count=1 -coverprofile=coverage.out ./...
go tool cover -func=coverage.out
security:
@command -v govulncheck >/dev/null 2>&1 || { echo "govulncheck is required"; exit 1; }
govulncheck ./...
ci: fmt-check vet staticcheck test security
check: fmt vet test
clean:
+18 -1
View File
@@ -234,11 +234,28 @@ func pluginsCommand() *cli.Command {
Name: "install",
Usage: "install a plugin",
ArgsUsage: "<source>",
Flags: []cli.Flag{
&cli.BoolFlag{Name: "yes", Aliases: []string{"y"}, Usage: "approve plugin trust without prompting"},
},
Action: func(ctx context.Context, c *cli.Command) error {
if c.Args().Len() == 0 {
return fmt.Errorf("usage: cliamp plugins install <source>")
}
return pluginmgr.Install(c.Args().First())
return pluginmgr.Install(c.Args().First(), c.Bool("yes"))
},
},
{
Name: "trust",
Usage: "approve the current contents of an installed plugin",
ArgsUsage: "<name>",
Flags: []cli.Flag{
&cli.BoolFlag{Name: "yes", Aliases: []string{"y"}, Usage: "approve plugin trust without prompting"},
},
Action: func(ctx context.Context, c *cli.Command) error {
if c.Args().Len() == 0 {
return fmt.Errorf("usage: cliamp plugins trust <name>")
}
return pluginmgr.Trust(c.Args().First(), c.Bool("yes"))
},
},
{
+8 -7
View File
@@ -13,6 +13,7 @@ import (
"time"
"github.com/bjarneo/cliamp/internal/appdir"
"github.com/bjarneo/cliamp/internal/fileutil"
)
// configPath returns the path to the config file.
@@ -571,7 +572,7 @@ func Save(key, value string) error {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
@@ -582,7 +583,7 @@ func Save(key, value string) error {
if !errors.Is(err, fs.ErrNotExist) {
return err
}
return os.WriteFile(path, []byte(line+"\n"), 0o644)
return fileutil.WriteFileAtomic(path, []byte(line+"\n"), 0o600)
}
// Scan existing lines and replace the matching key in-place,
@@ -622,7 +623,7 @@ func Save(key, value string) error {
}
}
return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644)
return fileutil.WriteFileAtomic(path, []byte(strings.Join(lines, "\n")), 0o600)
}
// SaveNavidromeSort persists the given album browse sort type to the
@@ -635,7 +636,7 @@ func SaveNavidromeSort(sortType string) error {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
@@ -647,7 +648,7 @@ func SaveNavidromeSort(sortType string) error {
return err
}
// No file: create with section + key.
return os.WriteFile(path, []byte("[navidrome]\n"+line+"\n"), 0o644)
return fileutil.WriteFileAtomic(path, []byte("[navidrome]\n"+line+"\n"), 0o600)
}
lines := strings.Split(string(data), "\n")
@@ -664,7 +665,7 @@ func SaveNavidromeSort(sortType string) error {
k, _, ok := strings.Cut(trimmed, "=")
if ok && strings.TrimSpace(k) == "browse_sort" {
lines[i] = line
return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644)
return fileutil.WriteFileAtomic(path, []byte(strings.Join(lines, "\n")), 0o600)
}
}
}
@@ -695,7 +696,7 @@ func SaveNavidromeSort(sortType string) error {
lines = append(lines, "[navidrome]", line)
}
return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644)
return fileutil.WriteFileAtomic(path, []byte(strings.Join(lines, "\n")), 0o600)
}
// PlayerConfig is the subset of player controls needed to apply config.
+6 -3
View File
@@ -99,7 +99,7 @@ Item {
BandStream {
id: stream
fps: 30
enabled: root.ready
enabled: root.visible && root.ready
}
Rectangle {
@@ -127,8 +127,8 @@ Item {
height: 13
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
text: root.ready ? (root.player.trackTitle || "Unknown title")
: "cliamp: not running"
text: root.ready ? (root.player.trackTitle || qsTr("Unknown title"))
: qsTr("cliamp: not running")
color: root.fg
font.family: "monospace"
font.pixelSize: 12
@@ -164,6 +164,7 @@ Item {
TransportButton {
width: 16; height: 16
shape: "prev"
accessibleName: qsTr("Previous track")
iconSize: 10
enabled: root.ready && root.player.canGoPrevious
fgColor: root.dim
@@ -173,6 +174,7 @@ Item {
TransportButton {
width: 20; height: 16
shape: root.playing ? "pause" : "play"
accessibleName: root.playing ? qsTr("Pause") : qsTr("Play")
iconSize: 12
enabled: root.ready && root.player.canTogglePlaying
fgColor: root.accent
@@ -182,6 +184,7 @@ Item {
TransportButton {
width: 16; height: 16
shape: "next"
accessibleName: qsTr("Next track")
iconSize: 10
enabled: root.ready && root.player.canGoNext
fgColor: root.dim
+7
View File
@@ -10,12 +10,19 @@ Item {
property color hoverColor: "#d8a657"
property bool enabled: true
property real iconSize: 14
property string accessibleName: shape
signal activated()
property bool hovered: false
implicitWidth: iconSize + 12
implicitHeight: iconSize + 8
activeFocusOnTab: enabled
Accessible.role: Accessible.Button
Accessible.name: accessibleName
Accessible.onPressAction: if (root.enabled) root.activated()
Keys.onSpacePressed: if (root.enabled) root.activated()
Keys.onReturnPressed: if (root.enabled) root.activated()
MediaIcon {
anchors.centerIn: parent
+36 -67
View File
@@ -22,6 +22,8 @@ Item {
// segGap >= 1 so the dark line between segments stays visible.
property int segH: 3
property int segGap: 1
readonly property int bandCount: Math.max(10, bands ? bands.length : 0)
readonly property int rows: Math.max(4, Math.floor(height / (segH + segGap)))
implicitWidth: 320
implicitHeight: 56
@@ -32,8 +34,8 @@ Item {
// Drives peak decay independent of band update rate. Skips the state
// write when nothing moved so a paused player doesn't allocate and
// emit a peaksChanged signal at 30 Hz.
interval: 33
running: true
interval: 50
running: root.visible
repeat: true
onTriggered: {
const cur = root.peaks;
@@ -47,74 +49,41 @@ Item {
}
if (dirty) {
root.peaks = next;
canvas.requestPaint();
}
}
}
onBandsChanged: canvas.requestPaint()
onBarColorChanged: canvas.requestPaint()
onAccentColorChanged: canvas.requestPaint()
onWarnColorChanged: canvas.requestPaint()
Canvas {
id: canvas
anchors.fill: parent
onPaint: {
const ctx = getContext("2d");
ctx.reset();
const w = width, h = height;
const b = root.bands || [];
const n = b.length || 10;
// Bar geometry: integer bar widths for crisp pixels, then absorb
// any leftover space into the inter-bar gap so the block spans
// edge-to-edge (matches the seek line's width).
const minGap = 2;
const bw = Math.max(2, Math.floor((w - minGap * (n - 1)) / n));
const gap = n > 1 ? (w - bw * n) / (n - 1) : 0;
const xStart = 0;
// Number of LED rows that fit. Cap at 24-ish for the classic
// Winamp density.
const rows = Math.max(4, Math.floor(h / (root.segH + root.segGap)));
const lowRows = Math.round(rows * 0.55);
const midRows = Math.round(rows * 0.30);
// Build the per-row color stack once.
const rowColors = new Array(rows);
for (let r = 0; r < rows; ++r) {
if (r < lowRows) rowColors[r] = root.barColor;
else if (r < lowRows + midRows) rowColors[r] = root.accentColor;
else rowColors[r] = root.warnColor;
}
// Draw stacks.
for (let i = 0; i < n; ++i) {
const v = Math.max(0, Math.min(1, b[i] || 0));
const lit = Math.round(v * rows);
const x = xStart + i * (bw + gap);
for (let r = 0; r < lit; ++r) {
const y = h - (r + 1) * (root.segH + root.segGap) + root.segGap;
if (y < 0) break;
ctx.fillStyle = rowColors[r];
ctx.fillRect(x, y, bw, root.segH);
}
}
// Peak caps: one bright segment, theme-yellow, sitting at the
// top of the peak position.
ctx.fillStyle = root.accentColor;
for (let i = 0; i < n; ++i) {
const p = Math.max(0, Math.min(1, root.peaks[i] || 0));
if (p <= 0) continue;
const peakRow = Math.max(1, Math.round(p * rows));
const y = h - peakRow * (root.segH + root.segGap) + root.segGap;
if (y < 0) continue;
const x = xStart + i * (bw + gap);
ctx.fillRect(x, y, bw, root.segH);
}
}
Row {
anchors.fill: parent
spacing: 2
Repeater {
model: root.bandCount
Item {
required property int index
width: (parent.width - parent.spacing * (root.bandCount - 1)) / root.bandCount
height: parent.height
readonly property real value: Math.max(0, Math.min(1, root.bands[index] || 0))
Repeater {
model: root.rows
Rectangle {
required property int index
width: parent.width
height: root.segH
y: parent.height - (index + 1) * (root.segH + root.segGap) + root.segGap
visible: index < Math.round(parent.value * root.rows)
color: index < Math.round(root.rows * 0.55) ? root.barColor
: index < Math.round(root.rows * 0.85) ? root.accentColor : root.warnColor
}
}
Rectangle {
width: parent.width
height: root.segH
visible: (root.peaks[index] || 0) > 0
y: parent.height - Math.max(1, Math.round((root.peaks[index] || 0) * root.rows))
* (root.segH + root.segGap) + root.segGap
color: root.accentColor
}
}
}
}
}
+8 -5
View File
@@ -8,7 +8,7 @@ Plugins live in `~/.config/cliamp/plugins/`. Create the directory:
mkdir -p ~/.config/cliamp/plugins
```
Drop a `.lua` file in and restart cliamp. That's it.
Plugins run only after their exact contents have been approved. Existing and manually copied plugins start untrusted; approve one with `cliamp plugins trust <name>`.
## Plugin manager
@@ -16,9 +16,12 @@ Drop a `.lua` file in and restart cliamp. That's it.
cliamp plugins # show help
cliamp plugins list # list installed plugins
cliamp plugins install <source> # install a plugin
cliamp plugins trust <name> # approve installed plugin contents
cliamp plugins remove <name> # remove a plugin
```
Install and trust display the source, SHA-256, declared permissions, and implicit filesystem/network access before prompting. Use `--yes` only after independently reviewing the same content in non-interactive environments. Approvals are stored in `plugins/.trust.json`; editing a plugin changes its hash and disables it until it is approved again. Unknown permission names are rejected.
### Install sources
| Format | Example |
@@ -86,7 +89,7 @@ p:on("track.change", function(track)
end)
```
Note: `os.execute` is removed by the sandbox. For shell commands, use `cliamp.http.post` to a local webhook, or write to a file that a watcher picks up.
Note: `os.execute` is removed by the sandbox. Public HTTP endpoints are available through `cliamp.http`; private, loopback, link-local, multicast, and unspecified addresses are blocked. For local automation, write to an allowlisted file that a watcher picks up or declare the permission-gated `exec` capability.
### Webhook
@@ -560,7 +563,7 @@ For security, plugins run with restricted access. The sandbox removes dangerous
|---------|-------------|
| `os.execute`, `os.remove`, `os.rename`, `os.exit`, `os.setlocale`, `os.tmpname` | Use `cliamp.fs`, `cliamp.http`, or permission-gated `cliamp.exec` |
| `io` module (all of it) | Use `cliamp.fs` |
| `dofile`, `loadfile` | Not available |
| `dofile`, `loadfile`, `load`, `loadstring`, `require`, `module`, `package`, `debug` | Not available |
### Kept functions
@@ -583,8 +586,8 @@ Attempts to write outside these directories will raise a Lua error. Directory tr
- Each plugin runs in its own Lua VM. Plugins cannot access each other's state or variables.
- A crash in one plugin does not affect other plugins or the player.
- Network access is available via `cliamp.http` (no raw socket access).
- There is no process spawning — `os.execute` is removed. For shell commands, write to a file that a watcher picks up, or use `cliamp.http.post` to a local webhook.
- Public network access is available via `cliamp.http` (no raw socket access). Private, loopback, link-local, multicast, and unspecified destinations are blocked after DNS resolution and across redirects.
- `os.execute` is removed. Permission-gated `cliamp.exec` can spawn only configured allowlisted binaries.
## Debugging
+1 -4
View File
@@ -429,10 +429,7 @@ func isAuthError(err error) bool {
return false
}
var keyErr *audio.KeyProviderError
if errors.As(err, &keyErr) {
return true
}
return false
return errors.As(err, &keyErr)
}
// URISchemes returns the URI prefixes handled by this provider.
+26 -29
View File
@@ -38,6 +38,8 @@ URL="https://github.com/${REPO}/releases/latest/download/${BINARY}"
echo "Downloading ${BINARY}..."
TMP=$(mktemp)
CHECKSUMS=$(mktemp)
trap 'rm -f "$TMP" "$CHECKSUMS"' EXIT HUP INT TERM
if command -v curl > /dev/null; then
curl -fSL -o "$TMP" "$URL"
elif command -v wget > /dev/null; then
@@ -46,47 +48,42 @@ else
echo "Error: curl or wget required" >&2; exit 1
fi
chmod +x "$TMP"
# Verify checksum if checksums.txt is available
# A release without a matching checksum is not installable.
CHECKSUM_URL="https://github.com/${REPO}/releases/latest/download/checksums.txt"
CHECKSUMS=$(mktemp)
GOT_CHECKSUMS=false
if command -v curl > /dev/null; then
curl -fSL -o "$CHECKSUMS" "$CHECKSUM_URL" 2>/dev/null && GOT_CHECKSUMS=true
curl -fSL -o "$CHECKSUMS" "$CHECKSUM_URL"
elif command -v wget > /dev/null; then
wget -qO "$CHECKSUMS" "$CHECKSUM_URL" 2>/dev/null && GOT_CHECKSUMS=true
wget -qO "$CHECKSUMS" "$CHECKSUM_URL"
fi
if [ "$GOT_CHECKSUMS" = true ]; then
EXPECTED=$(grep "${BINARY}$" "$CHECKSUMS" | awk '{print $1}')
if [ -n "$EXPECTED" ]; then
if command -v sha256sum > /dev/null; then
ACTUAL=$(sha256sum "$TMP" | awk '{print $1}')
elif command -v shasum > /dev/null; then
ACTUAL=$(shasum -a 256 "$TMP" | awk '{print $1}')
else
ACTUAL=""
fi
if [ -n "$ACTUAL" ] && [ "$ACTUAL" != "$EXPECTED" ]; then
echo "Error: checksum mismatch" >&2
echo " expected: $EXPECTED" >&2
echo " got: $ACTUAL" >&2
rm -f "$TMP" "$CHECKSUMS"
exit 1
fi
if [ -n "$ACTUAL" ]; then
echo "Checksum verified."
fi
fi
EXPECTED=$(awk -v file="$BINARY" '$2 == file || $2 == "*" file { print $1 }' "$CHECKSUMS")
if [ -z "$EXPECTED" ]; then
echo "Error: release has no checksum for ${BINARY}" >&2
exit 1
fi
rm -f "$CHECKSUMS"
if command -v sha256sum > /dev/null; then
ACTUAL=$(sha256sum "$TMP" | awk '{print $1}')
elif command -v shasum > /dev/null; then
ACTUAL=$(shasum -a 256 "$TMP" | awk '{print $1}')
else
echo "Error: sha256sum or shasum is required" >&2
exit 1
fi
if [ "$ACTUAL" != "$EXPECTED" ]; then
echo "Error: checksum mismatch" >&2
echo " expected: $EXPECTED" >&2
echo " got: $ACTUAL" >&2
exit 1
fi
echo "Checksum verified."
chmod +x "$TMP"
if [ -w "$INSTALL_DIR" ]; then
mv "$TMP" "${INSTALL_DIR}/cliamp"
else
sudo mv "$TMP" "${INSTALL_DIR}/cliamp"
fi
TMP=""
echo "Installed cliamp to ${INSTALL_DIR}/cliamp"
+18 -1
View File
@@ -15,6 +15,7 @@ import (
lua "github.com/yuin/gopher-lua"
"github.com/bjarneo/cliamp/internal/appdir"
"github.com/bjarneo/cliamp/internal/plugintrust"
)
// Plugin represents a single loaded Lua plugin.
@@ -146,6 +147,10 @@ func New(pluginCfg map[string]map[string]string) (*Manager, error) {
}
return m, fmt.Errorf("read plugin dir: %w", err)
}
trustManifest, err := plugintrust.Load(dir)
if err != nil {
return m, err
}
// Collect plugin files: *.lua and directories with init.lua.
type pluginFile struct {
@@ -192,6 +197,10 @@ func New(pluginCfg map[string]map[string]string) (*Manager, error) {
continue
}
}
if err := plugintrust.Verify(trustManifest, f.name, f.path); err != nil {
loadErrs = append(loadErrs, fmt.Sprintf("%s: %v; run `cliamp plugins trust %s`", f.name, err, f.name))
continue
}
p, err := m.loadPlugin(f.path, f.name, cfg)
if err != nil {
@@ -322,8 +331,16 @@ func (m *Manager) registerPluginAPI(L *lua.LState, p *Plugin) {
if tbl, ok := perms.(*lua.LTable); ok {
p.perms = make(map[string]bool)
tbl.ForEach(func(_, v lua.LValue) {
p.perms[v.String()] = true
permission := v.String()
switch permission {
case PermControl, PermExec, PermKeymap:
p.perms[permission] = true
default:
L.RaiseError("unknown permission %q", permission)
}
})
} else {
L.RaiseError("permissions must be an array")
}
}
+7
View File
@@ -3,7 +3,10 @@ package luaplugin
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/bjarneo/cliamp/internal/plugintrust"
)
// TestBundledPluginsLoad is the backward-compatibility guard for the plugin
@@ -45,6 +48,10 @@ func TestBundledPluginsLoad(t *testing.T) {
if err := os.WriteFile(filepath.Join(pluginDir, name), data, 0o644); err != nil {
t.Fatalf("copy %s: %v", name, err)
}
pluginName := strings.TrimSuffix(name, ".lua")
if _, err := plugintrust.Approve(pluginDir, pluginName, filepath.Join(pluginDir, name)); err != nil {
t.Fatalf("approve %s: %v", name, err)
}
}
mgr, err := New(nil)
+3 -1
View File
@@ -12,9 +12,11 @@ func Sandbox(L *lua.LState) { sandbox(L) }
// compatibility helpers missing from Lua 5.1 (e.g. utf8.char).
func sandbox(L *lua.LState) {
// Remove top-level functions that can load/execute arbitrary code.
for _, name := range []string{"dofile", "loadfile"} {
for _, name := range []string{"dofile", "loadfile", "load", "loadstring", "require", "module"} {
L.SetGlobal(name, lua.LNil)
}
L.SetGlobal("package", lua.LNil)
L.SetGlobal("debug", lua.LNil)
// Remove the io module entirely (replaced by cliamp.fs).
L.SetGlobal("io", lua.LNil)
+158 -10
View File
@@ -3,6 +3,11 @@
package pluginmgr
import (
"bufio"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
@@ -14,6 +19,8 @@ import (
lua "github.com/yuin/gopher-lua"
"github.com/bjarneo/cliamp/internal/appdir"
"github.com/bjarneo/cliamp/internal/fileutil"
"github.com/bjarneo/cliamp/internal/plugintrust"
"github.com/bjarneo/cliamp/luaplugin"
)
@@ -21,6 +28,13 @@ var httpClient = &http.Client{Timeout: 30 * time.Second}
const maxPluginSize = 1 << 20 // 1 MB
const metadataTimeout = 250 * time.Millisecond
var (
input io.Reader = os.Stdin
output io.Writer = os.Stdout
)
// pluginInfo holds metadata extracted from a plugin's register() call.
type pluginInfo struct {
file string
@@ -28,6 +42,10 @@ type pluginInfo struct {
version string
description string
typ string
permissions []string
path string
trust string
err error
}
// List prints all installed plugins with their metadata.
@@ -47,6 +65,21 @@ func List() error {
return nil
}
manifest, trustErr := plugintrust.Load(dir)
if trustErr != nil {
return trustErr
}
for i := range plugins {
switch err := plugintrust.Verify(manifest, strings.TrimSuffix(plugins[i].file, "/"), plugins[i].path); {
case err == nil:
plugins[i].trust = "trusted"
case err == plugintrust.ErrHashMismatch:
plugins[i].trust = "changed"
default:
plugins[i].trust = "untrusted"
}
}
// Calculate column widths.
nameW, typeW, verW := 4, 4, 7 // "NAME", "TYPE", "VERSION"
for _, p := range plugins {
@@ -61,27 +94,33 @@ func List() error {
}
}
fmt.Printf("%-*s %-*s %-*s %s\n", nameW, "NAME", typeW, "TYPE", verW, "VERSION", "DESCRIPTION")
fmt.Fprintf(output, "%-*s %-*s %-*s %-9s %s\n", nameW, "NAME", typeW, "TYPE", verW, "VERSION", "TRUST", "DESCRIPTION")
for _, p := range plugins {
fmt.Printf("%-*s %-*s %-*s %s\n", nameW, p.name, typeW, p.typ, verW, p.version, p.description)
fmt.Fprintf(output, "%-*s %-*s %-*s %-9s %s\n", nameW, p.name, typeW, p.typ, verW, p.version, p.trust, p.description)
}
return nil
}
// Install downloads a plugin from the given source and saves it to the plugins directory.
func Install(source string) error {
func Install(source string, assumeYes ...bool) error {
urls, name, err := resolveSource(source)
if err != nil {
return err
}
if err := validateName(name); err != nil {
return err
}
dir, err := appdir.PluginDir()
if err != nil {
return err
}
if err := os.MkdirAll(dir, 0o755); err != nil {
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("creating plugins directory: %w", err)
}
if err := os.Chmod(dir, 0o700); err != nil {
return fmt.Errorf("securing plugins directory: %w", err)
}
// Check if already installed (file or directory).
dest := filepath.Join(dir, name+".lua")
@@ -106,16 +145,98 @@ func Install(source string) error {
return fmt.Errorf("could not download plugin from any of: %s", strings.Join(urls, ", "))
}
if err := os.WriteFile(dest, body, 0o644); err != nil {
info := extractMetadataSource(string(body))
if info.err != nil {
return fmt.Errorf("inspect plugin metadata: %w", info.err)
}
h := sha256.Sum256(body)
hash := hex.EncodeToString(h[:])
fmt.Fprintf(output, "Source: %s\nSHA-256: %s\nDeclared permissions: %s\nImplicit access: unrestricted reads; allowlisted writes; public HTTP\n",
source, hash, displayPermissions(info.permissions))
yes := len(assumeYes) > 0 && assumeYes[0]
if !yes {
fmt.Fprint(output, "Trust and install this plugin? [y/N] ")
answer, err := bufio.NewReader(input).ReadString('\n')
if err != nil && len(answer) == 0 {
return errors.New("approval required; rerun with --yes for non-interactive installation")
}
answer = strings.ToLower(strings.TrimSpace(answer))
if answer != "y" && answer != "yes" {
return errors.New("plugin installation not approved")
}
}
if err := fileutil.WriteFileAtomic(dest, body, 0o600); err != nil {
return fmt.Errorf("writing plugin: %w", err)
}
if _, err := plugintrust.Approve(dir, name, dest); err != nil {
_ = os.Remove(dest)
return fmt.Errorf("recording plugin trust: %w", err)
}
fmt.Printf("Installed %s → %s\n", name, dest)
return nil
}
// Trust approves the current contents of an installed plugin.
func Trust(name string, assumeYes bool) error {
if err := validateName(name); err != nil {
return err
}
dir, err := appdir.PluginDir()
if err != nil {
return err
}
path := filepath.Join(dir, name+".lua")
if st, statErr := os.Stat(path); statErr != nil {
path = filepath.Join(dir, name, "init.lua")
} else if st.IsDir() {
return fmt.Errorf("plugin %q not found", name)
}
info := extractMetadata(path)
if info.err != nil {
return fmt.Errorf("inspect plugin metadata: %w", info.err)
}
hash, err := plugintrust.HashFile(path)
if err != nil {
return err
}
fmt.Fprintf(output, "Plugin: %s\nSHA-256: %s\nDeclared permissions: %s\nImplicit access: unrestricted reads; allowlisted writes; public HTTP\n",
name, hash, displayPermissions(info.permissions))
if !assumeYes {
fmt.Fprint(output, "Trust this plugin content? [y/N] ")
answer, readErr := bufio.NewReader(input).ReadString('\n')
if readErr != nil && len(answer) == 0 {
return errors.New("approval required; rerun with --yes")
}
answer = strings.ToLower(strings.TrimSpace(answer))
if answer != "y" && answer != "yes" {
return errors.New("plugin trust not approved")
}
}
_, err = plugintrust.Approve(dir, name, path)
return err
}
func validateName(name string) error {
if name == "" || name == "." || name == ".." || filepath.Base(name) != name || strings.ContainsAny(name, `/\\`) {
return fmt.Errorf("invalid plugin name %q", name)
}
return nil
}
func displayPermissions(perms []string) string {
if len(perms) == 0 {
return "none"
}
return strings.Join(perms, ", ")
}
// Remove deletes a plugin by name.
func Remove(name string) error {
if err := validateName(name); err != nil {
return err
}
dir, err := appdir.PluginDir()
if err != nil {
return err
@@ -194,6 +315,7 @@ func scanPlugins(dir string) ([]pluginInfo, error) {
info := extractMetadata(path)
info.file = file
info.path = path
if info.name == "" {
info.name = strings.TrimSuffix(e.Name(), ".lua")
}
@@ -204,6 +326,14 @@ func scanPlugins(dir string) ([]pluginInfo, error) {
// extractMetadata runs a Lua file in a minimal VM to capture the plugin.register() call.
func extractMetadata(path string) pluginInfo {
data, err := os.ReadFile(path)
if err != nil {
return pluginInfo{err: err}
}
return extractMetadataSource(string(data))
}
func extractMetadataSource(source string) pluginInfo {
L := lua.NewState(lua.Options{SkipOpenLibs: false})
defer L.Close()
@@ -213,6 +343,10 @@ func extractMetadata(path string) pluginInfo {
luaplugin.Sandbox(L)
var info pluginInfo
ctx, cancel := context.WithTimeout(context.Background(), metadataTimeout)
defer cancel()
L.SetContext(ctx)
defer L.RemoveContext()
// Stub out plugin.register() to capture metadata without side effects.
pluginTbl := L.NewTable()
@@ -230,6 +364,21 @@ func extractMetadata(path string) pluginInfo {
if v := opts.RawGetString("type"); v != lua.LNil {
info.typ = v.String()
}
if v := opts.RawGetString("permissions"); v != lua.LNil {
tbl, ok := v.(*lua.LTable)
if !ok {
info.err = errors.New("permissions must be an array")
} else {
known := map[string]bool{"control": true, "exec": true, "keymap": true}
tbl.ForEach(func(_, value lua.LValue) {
permission := value.String()
if !known[permission] && info.err == nil {
info.err = fmt.Errorf("unknown permission %q", permission)
}
info.permissions = append(info.permissions, permission)
})
}
}
// Return a dummy object with stub on/config methods.
obj := L.NewTable()
noop := L.NewFunction(func(L *lua.LState) int {
@@ -243,11 +392,10 @@ func extractMetadata(path string) pluginInfo {
}))
L.SetGlobal("plugin", pluginTbl)
// Stub cliamp global so plugins don't error on API calls.
L.SetGlobal("cliamp", L.NewTable())
// Ignore errors — we just want the metadata from register().
_ = L.DoFile(path)
// No cliamp API is installed: metadata inspection happens before trust.
if err := L.DoString(source); err != nil && info.name == "" {
info.err = err
}
return info
}
+4 -4
View File
@@ -233,7 +233,7 @@ func TestInstallFromRawURL(t *testing.T) {
defer srv.Close()
installTestClient(t, srv.URL)
if err := Install(srv.URL + "/example.lua"); err != nil {
if err := Install(srv.URL+"/example.lua", true); err != nil {
t.Fatalf("Install: %v", err)
}
@@ -262,7 +262,7 @@ func TestInstallAlreadyExists(t *testing.T) {
defer srv.Close()
installTestClient(t, srv.URL)
err := Install(srv.URL + "/mypl.lua")
err := Install(srv.URL+"/mypl.lua", true)
if err == nil {
t.Fatal("Install over existing plugin should error")
}
@@ -279,7 +279,7 @@ func TestInstallAllURLsFail(t *testing.T) {
defer srv.Close()
installTestClient(t, srv.URL)
err := Install(srv.URL + "/nonexistent.lua")
err := Install(srv.URL+"/nonexistent.lua", true)
if err == nil {
t.Error("Install with all failing URLs should error")
}
@@ -294,7 +294,7 @@ func TestInstallTooLarge(t *testing.T) {
defer srv.Close()
installTestClient(t, srv.URL)
err := Install(srv.URL + "/huge.lua")
err := Install(srv.URL+"/huge.lua", true)
if err == nil {
t.Error("Install of oversized plugin should fail")
}
+1 -1
View File
@@ -2196,7 +2196,7 @@ user_id = "your-account-user-id"</code></pre>
<p class="plugins-intro">
Lua 5.1 plugin system. Hook into playback events, add custom visualizers, or push data to external services.
Each plugin runs in an isolated sandbox — a crash in one cannot affect others or the player.
Drop a <code>.lua</code> file in <span class="path">~/.config/cliamp/plugins/</span> and restart.
Install or copy a <code>.lua</code> file into <span class="path">~/.config/cliamp/plugins/</span>, then approve its exact SHA-256 with <code>cliamp plugins trust &lt;name&gt;</code>. Changed and untrusted plugins do not run.
</p>
<div class="plugin-examples">
-36
View File
@@ -81,42 +81,6 @@ func (g *brailleGrid) render(rows int) string {
return strings.Join(lines, "\n")
}
// drawLine plots a Bresenham line into the grid at the given tier.
func (g *brailleGrid) drawLine(x0, y0, x1, y1 int, tier int8) {
dx := x1 - x0
if dx < 0 {
dx = -dx
}
dy := -(y1 - y0)
if dy > 0 {
dy = -dy
}
sx := 1
if x0 >= x1 {
sx = -1
}
sy := 1
if y0 >= y1 {
sy = -1
}
err := dx + dy
for {
g.set(x0, y0, tier)
if x0 == x1 && y0 == y1 {
return
}
e2 := 2 * err
if e2 >= dy {
err += dy
x0 += sx
}
if e2 <= dx {
err += dx
y0 += sy
}
}
}
// rng64 advances a 64-bit LCG and returns a [0,1) double.
func rng64(state *uint64) float64 {
*state = *state*6364136223846793005 + 1442695040888963407
+74 -4
View File
@@ -3,13 +3,17 @@
package upgrade
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"time"
)
@@ -44,7 +48,9 @@ func Run(currentVersion string) error {
binaryName += ".exe"
}
url := fmt.Sprintf("https://github.com/%s/releases/latest/download/%s", repo, binaryName)
baseURL := fmt.Sprintf("https://github.com/%s/releases/download/%s", repo, latest)
checksumURL := baseURL + "/checksums.txt"
binaryURL := baseURL + "/" + binaryName
fmt.Printf("Downloading %s...\n", binaryName)
@@ -57,7 +63,11 @@ func Run(currentVersion string) error {
return fmt.Errorf("resolving binary path: %w", err)
}
if err := downloadAndReplace(url, exe); err != nil {
expectedHash, err := releaseChecksum(checksumURL, binaryName)
if err != nil {
return fmt.Errorf("verifying release checksum: %w", err)
}
if err := downloadAndReplace(binaryURL, exe, expectedHash); err != nil {
return err
}
@@ -82,10 +92,48 @@ func latestVersion() (string, error) {
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&r); err != nil {
return "", fmt.Errorf("parsing response: %w", err)
}
if strings.TrimSpace(r.TagName) == "" || strings.ContainsAny(r.TagName, "/\\") {
return "", errors.New("release response contains an invalid tag")
}
return r.TagName, nil
}
func downloadAndReplace(url, destPath string) error {
func releaseChecksum(url, binaryName string) (string, error) {
resp, err := httpClient.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("checksum download failed: %s", resp.Status)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20+1))
if err != nil {
return "", err
}
if len(data) > 1<<20 {
return "", errors.New("checksum file is too large")
}
for line := range strings.Lines(string(data)) {
fields := strings.Fields(line)
if len(fields) != 2 || strings.TrimPrefix(fields[1], "*") != binaryName {
continue
}
if len(fields[0]) != sha256.Size*2 {
return "", fmt.Errorf("invalid SHA-256 entry for %s", binaryName)
}
if _, err := hex.DecodeString(fields[0]); err != nil {
return "", fmt.Errorf("invalid SHA-256 entry for %s", binaryName)
}
return strings.ToLower(fields[0]), nil
}
return "", fmt.Errorf("no SHA-256 entry for %s", binaryName)
}
func downloadAndReplace(url, destPath, expectedHash string) error {
if len(expectedHash) != sha256.Size*2 {
return errors.New("valid expected SHA-256 is required")
}
resp, err := httpClient.Get(url)
if err != nil {
return fmt.Errorf("downloading: %w", err)
@@ -108,11 +156,33 @@ func downloadAndReplace(url, destPath string) error {
// Limit download to 200 MB to prevent unbounded disk usage from a
// rogue redirect or compromised CDN.
const maxBinarySize = 200 << 20
if _, err := io.Copy(tmp, io.LimitReader(resp.Body, maxBinarySize)); err != nil {
h := sha256.New()
written, err := io.Copy(io.MultiWriter(tmp, h), io.LimitReader(resp.Body, maxBinarySize+1))
if err != nil {
tmp.Close()
os.Remove(tmpPath)
return fmt.Errorf("writing binary: %w", err)
}
if written == 0 || written > maxBinarySize {
tmp.Close()
os.Remove(tmpPath)
return errors.New("download is empty or exceeds maximum size")
}
if resp.ContentLength >= 0 && written != resp.ContentLength {
tmp.Close()
os.Remove(tmpPath)
return fmt.Errorf("truncated download: received %d of %d bytes", written, resp.ContentLength)
}
if got := hex.EncodeToString(h.Sum(nil)); !strings.EqualFold(got, expectedHash) {
tmp.Close()
os.Remove(tmpPath)
return fmt.Errorf("SHA-256 mismatch: got %s", got)
}
if err := tmp.Sync(); err != nil {
tmp.Close()
os.Remove(tmpPath)
return fmt.Errorf("syncing binary: %w", err)
}
if err := tmp.Close(); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("writing binary: %w", err)
+60 -3
View File
@@ -1,6 +1,8 @@
package upgrade
import (
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"net/http/httptest"
@@ -13,6 +15,11 @@ import (
"time"
)
func testHash(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
type rewriter struct {
target *url.URL
rt http.RoundTripper
@@ -89,6 +96,56 @@ func TestLatestVersionBadJSON(t *testing.T) {
}
}
func TestReleaseChecksum(t *testing.T) {
want := strings.Repeat("a", 64)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, want+" cliamp-linux-amd64\n")
}))
defer srv.Close()
installTestClient(t, srv.URL)
got, err := releaseChecksum(srv.URL+"/checksums.txt", "cliamp-linux-amd64")
if err != nil {
t.Fatal(err)
}
if got != want {
t.Errorf("checksum = %q, want %q", got, want)
}
}
func TestReleaseChecksumMissingEntry(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, strings.Repeat("a", 64)+" another-file\n")
}))
defer srv.Close()
installTestClient(t, srv.URL)
if _, err := releaseChecksum(srv.URL+"/checksums.txt", "cliamp-linux-amd64"); err == nil {
t.Fatal("releaseChecksum accepted a missing asset entry")
}
}
func TestDownloadAndReplaceChecksumMismatchPreservesOriginal(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "cliamp")
if err := os.WriteFile(target, []byte("OLD"), 0o755); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, "NEW")
}))
defer srv.Close()
installTestClient(t, srv.URL)
err := downloadAndReplace(srv.URL+"/cliamp", target, testHash([]byte("different")))
if err == nil || !strings.Contains(err.Error(), "SHA-256 mismatch") {
t.Fatalf("downloadAndReplace error = %v, want checksum mismatch", err)
}
got, readErr := os.ReadFile(target)
if readErr != nil || string(got) != "OLD" {
t.Fatalf("original after mismatch = %q, %v", got, readErr)
}
}
func TestDownloadAndReplace(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "cliamp")
@@ -105,7 +162,7 @@ func TestDownloadAndReplace(t *testing.T) {
defer srv.Close()
installTestClient(t, srv.URL)
if err := downloadAndReplace(srv.URL+"/cliamp-linux-amd64", target); err != nil {
if err := downloadAndReplace(srv.URL+"/cliamp-linux-amd64", target, testHash(newContent)); err != nil {
t.Fatalf("downloadAndReplace: %v", err)
}
@@ -140,7 +197,7 @@ func TestDownloadAndReplaceHTTPError(t *testing.T) {
defer srv.Close()
installTestClient(t, srv.URL)
err := downloadAndReplace(srv.URL+"/cliamp", target)
err := downloadAndReplace(srv.URL+"/cliamp", target, testHash([]byte("unused")))
if err == nil {
t.Error("downloadAndReplace should error on 404")
}
@@ -169,7 +226,7 @@ func TestDownloadAndReplaceTruncatesOversize(t *testing.T) {
defer srv.Close()
installTestClient(t, srv.URL)
if err := downloadAndReplace(srv.URL+"/cliamp", target); err != nil {
if err := downloadAndReplace(srv.URL+"/cliamp", target, testHash([]byte(body))); err != nil {
t.Fatalf("downloadAndReplace: %v", err)
}
got, _ := os.ReadFile(target)