Perf
This commit is contained in:
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Vendored
+1
-4
@@ -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
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 <name></code>. Changed and untrusted plugins do not run.
|
||||
</p>
|
||||
|
||||
<div class="plugin-examples">
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user