Compare commits

...

19 Commits

Author SHA1 Message Date
6868bde5e6 Reject unknown DB versions
All checks were successful
Alpine 3.21 Success
2025-09-06 09:12:27 +02:00
d3a046d85d Avoid disaster with DB migrations
All checks were successful
Alpine 3.21 Success
2025-09-04 10:38:31 +02:00
6622ea0e1c Improve formatting of durations
All checks were successful
Alpine 3.20 Success
Since "m" could stand for both "minute" and "month",
and months vary in length, let's stop at days.
2025-01-02 00:36:03 +01:00
a492b3b668 Clean up 2024-12-28 00:27:46 +01:00
280114a5d3 Unify our usage of the local shell 2024-12-27 02:16:14 +01:00
d83517f67b Refresh task view dynamically with Javascript
All checks were successful
Alpine 3.20 Success
This is more efficient, responsive, and user friendly.
2024-12-27 00:25:49 +01:00
4f2c2dc8da Order tasks by change date first
All checks were successful
Alpine 3.20 Success
The user presumably does not want to look everywhere for recent tasks.
2024-12-26 16:24:54 +01:00
55a6693942 Fix deployment error processing 2024-12-26 16:18:37 +01:00
d5981249b1 Add time information 2024-12-26 16:17:45 +01:00
4a7fc55c92 Runtime configuration changes
All checks were successful
Alpine 3.20 Success
Through an RPC command, because systemd documentation told us to.
2024-12-26 12:03:00 +01:00
2bd231b84f Fix Makefile dependencies, extend tests
All checks were successful
Alpine 3.20 Success
2024-12-26 00:40:58 +01:00
fb291b6def Improve the terminal filter
All checks were successful
Alpine 3.20 Success
The new filter comes with these enhancements:

 - Processing is rune-wise rather than byte-wise;
   it assumes UTF-8 input and single-cell wide characters,
   but this condition should be /usually/ satisfied.
 - Unprocessed control characters are escaped, `cat -v` style.
 - A lot of escape sequences is at least recognised, if not processed.
 - Rudimentary preparation for efficient dynamic updates
   of task views, through Javascript.

We make terminal resets and screen clearing commands
flush all output and assume that the terminal has a new origin
for any later positioning commands.
This appears to work well enough with GRUB, at least.

The filter is now exposed through a command line option.
2024-12-25 23:14:54 +01:00
14a15e8b59 Prevent a data race 2024-12-25 23:14:54 +01:00
0746797c73 Add optional raw log redirection
For now using an environment variable.
2024-12-25 23:14:38 +01:00
ec656d8b2a Make do with a2x when there is no asciidoctor 2024-12-24 15:43:35 +01:00
a09b11256b Clean up, add a deployment stage
All checks were successful
Alpine 3.20 Success
Errors should be handled a bit more nicely now.

The SFTP part could also be done from deploy scripts like:

 scp -i {runner.ssh.identity} \
   -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no \
   {runner.ssh.user}@{runner.ssh.address%:*} -p {runner.ssh.address#*:}

but that is deemed way too annoying, so we do it from Go.
2024-12-23 14:35:46 +01:00
bd13053773 Make manually invoked runners possible
All checks were successful
Alpine 3.20 Success
This is intended for runners that are only available on request.
2024-12-22 09:02:09 +01:00
92fd2db1c1 Add Qt Creator project files to .gitignore 2024-12-21 09:37:11 +01:00
0db2ff3409 Set a time limit on runners 2024-04-19 04:26:48 +02:00
10 changed files with 1446 additions and 320 deletions

8
.gitignore vendored
View File

@@ -1,2 +1,10 @@
/acid
/acid.1
/acid.cflags
/acid.config
/acid.creator
/acid.creator.user
/acid.cxxflags
/acid.files
/acid.includes

View File

@@ -5,10 +5,11 @@ version = dev
outputs = acid acid.1
all: $(outputs)
acid: acid.go
acid: acid.go terminal.go
go build -ldflags "-X 'main.projectVersion=$(version)'" -o $@
acid.1: acid.adoc
asciidoctor -b manpage -a release-version=$(version) -o $@ acid.adoc
asciidoctor -b manpage -a release-version=$(version) -o $@ acid.adoc || \
a2x -d manpage -f manpage -a release-version=$(version) acid.adoc
test: all
go test
clean:

View File

@@ -37,6 +37,8 @@ Commands
*restart* [_ID_]...::
Schedule tasks with the given IDs to be rerun.
Run this command without arguments to pick up external database changes.
*reload*::
Reload configuration.
Configuration
-------------
@@ -45,7 +47,7 @@ file present in the distribution.
All paths are currently relative to the directory you launch *acid* from.
The *notify*, *setup*, and *build* scripts are processed using Go's
The *notify*, *setup*, *build*, and *deploy* scripts are processed using Go's
_text/template_ package, and take an object describing the task,
which has the following fields:
@@ -65,6 +67,17 @@ which has the following fields:
*RunnerName*::
Descriptive name of the runner.
// Intentionally not documenting CreatedUnix, ChangedUnix, DurationSeconds,
// which can be derived from the objects.
*Created*, *Changed*::
`*time.Time` of task creation and last task state change respectively,
or nil if not known.
*CreatedAgo*, *ChangedAgo*::
Abbreviated human-friendly relative elapsed time duration
since *Created* and *Changed* respectively.
*Duration*::
`*time.Duration` of the last run in seconds, or nil if not known.
*URL*::
*acid* link to the task, where its log output can be seen.
*RepoURL*::
@@ -79,7 +92,8 @@ in *sh*(1) command arguments.
Runners
-------
Runners receive the following additional environment variables:
Runners and deploy scripts receive the following additional
environment variables:
*ACID_ROOT*:: The same as the base directory for configuration.
*ACID_RUNNER*:: The same as *Runner* in script templates.

1148
acid.go

File diff suppressed because it is too large Load Diff

View File

@@ -61,4 +61,14 @@ projects:
# Project build script.
build: |
echo Computing line count...
find . -not -path '*/.*' -type f -print0 | xargs -0 cat | wc -l
mkdir ~/acid-deploy
find . -not -path '*/.*' -type f -print0 | xargs -0 cat | wc -l \
> ~/acid-deploy/count
# Project deployment script (runs locally in a temporary directory).
deploy: |
cat count
# Time limit in time.ParseDuration format.
# The default of one hour should suffice.
timeout: 1h

View File

@@ -4,6 +4,7 @@ import (
"bytes"
"testing"
ttemplate "text/template"
"time"
)
func TestTemplateQuote(t *testing.T) {
@@ -30,3 +31,19 @@ func TestTemplateQuote(t *testing.T) {
}
}
}
func TestShortDurationString(t *testing.T) {
for _, test := range []struct {
d time.Duration
expect string
}{
{72 * time.Hour, "3d"},
{-3 * time.Hour, "-3h"},
{12 * time.Minute, "12m"},
{time.Millisecond, "0s"},
} {
if sd := shortDurationString(test.d); sd != test.expect {
t.Errorf("%s = %s; want %s\n", test.d, sd, test.expect)
}
}
}

10
go.mod
View File

@@ -3,9 +3,13 @@ module janouch.name/acid
go 1.22.0
require (
github.com/mattn/go-sqlite3 v1.14.22
golang.org/x/crypto v0.21.0
github.com/mattn/go-sqlite3 v1.14.24
github.com/pkg/sftp v1.13.7
golang.org/x/crypto v0.31.0
gopkg.in/yaml.v3 v3.0.1
)
require golang.org/x/sys v0.18.0 // indirect
require (
github.com/kr/fs v0.1.0 // indirect
golang.org/x/sys v0.28.0 // indirect
)

69
go.sum
View File

@@ -1,12 +1,65 @@
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pkg/sftp v1.13.7 h1:uv+I3nNJvlKZIQGSr8JVQLNHFU9YhhNpvC14Y6KgmSM=
github.com/pkg/sftp v1.13.7/go.mod h1:KMKI0t3T6hfA+lTR/ssZdunHo+uwq7ghoN09/FSu3DY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

379
terminal.go Normal file
View File

@@ -0,0 +1,379 @@
package main
import (
"bytes"
"io"
"log"
"os"
"strconv"
"strings"
"sync"
"unicode/utf8"
)
type terminalLine struct {
// For simplicity, we assume that all runes take up one cell,
// including TAB and non-spacing ones.
// The next step would be grouping non-spacing characters,
// in particular Unicode modifier letters, with their base.
columns []rune
// updateGroup is the topmost line that has changed since this line
// has appeared, for the purpose of update tracking.
updateGroup int
}
// terminalWriter does a best-effort approximation of an infinite-size
// virtual terminal.
type terminalWriter struct {
sync.Mutex
Tee io.WriteCloser
lines []terminalLine
// Zero-based coordinates within lines.
column, line int
// lineTop is used as the base for positioning commands.
lineTop int
written int
byteBuffer []byte
runeBuffer []rune
}
func (tw *terminalWriter) log(format string, v ...interface{}) {
if os.Getenv("ACID_TERMINAL_DEBUG") != "" {
log.Printf("terminal: "+format+"\n", v...)
}
}
// SerializeUpdates returns an update block for a client with a given last line,
// and the index of the first line in the update block.
func (tw *terminalWriter) SerializeUpdates(last int) (string, int) {
if last < 0 || last >= len(tw.lines) {
return "", last
}
top := tw.lines[last].updateGroup
return string(tw.Serialize(top)), top
}
func (tw *terminalWriter) Serialize(top int) []byte {
var b bytes.Buffer
for i := top; i < len(tw.lines); i++ {
b.WriteString(string(tw.lines[i].columns))
b.WriteByte('\n')
}
return b.Bytes()
}
func (tw *terminalWriter) Write(p []byte) (written int, err error) {
tw.Lock()
defer tw.Unlock()
// TODO(p): Rather use io.MultiWriter?
// Though I'm not sure what to do about closing (FD leaks).
// Eventually, any handles would be garbage collected in any case.
if tw.Tee != nil {
tw.Tee.Write(p)
}
// Enough is enough, writing too much is highly suspicious.
ok, remaining := true, 64<<20-tw.written
if remaining < 0 {
ok, p = false, nil
} else if remaining < len(p) {
ok, p = false, p[:remaining]
}
tw.written += len(p)
// By now, more or less everything should run in UTF-8.
//
// This might have better performance with a ring buffer,
// so as to avoid reallocations.
b := append(tw.byteBuffer, p...)
if !ok {
b = append(b, "\nToo much terminal output\n"...)
}
for utf8.FullRune(b) {
r, len := utf8.DecodeRune(b)
b, tw.runeBuffer = b[len:], append(tw.runeBuffer, r)
}
tw.byteBuffer = b
for tw.processRunes() {
}
return len(p), nil
}
func (tw *terminalWriter) processPrint(r rune) {
// Extend the buffer vertically.
for len(tw.lines) <= tw.line {
tw.lines = append(tw.lines,
terminalLine{updateGroup: len(tw.lines)})
}
// Refresh update trackers, if necessary.
if tw.lines[len(tw.lines)-1].updateGroup > tw.line {
for i := tw.line; i < len(tw.lines); i++ {
tw.lines[i].updateGroup = min(tw.lines[i].updateGroup, tw.line)
}
}
// Emulate `cat -v` for C0 characters.
seq := make([]rune, 0, 2)
if r < 32 && r != '\t' {
seq = append(seq, '^', 64+r)
} else {
seq = append(seq, r)
}
// Extend the line horizontally and write the rune.
for _, r := range seq {
line := &tw.lines[tw.line]
for len(line.columns) <= tw.column {
line.columns = append(line.columns, ' ')
}
line.columns[tw.column] = r
tw.column++
}
}
func (tw *terminalWriter) processFlush() {
tw.column = 0
tw.line = len(tw.lines)
tw.lineTop = tw.line
}
func (tw *terminalWriter) processParsedCSI(
private rune, param, intermediate []rune, final rune) bool {
var params []int
if len(param) > 0 {
for _, p := range strings.Split(string(param), ";") {
i, _ := strconv.Atoi(p)
params = append(params, i)
}
}
if private == '?' && len(intermediate) == 0 &&
(final == 'h' || final == 'l') {
for _, p := range params {
// 25 (DECTCEM): There is no cursor to show or hide.
// 7 (DECAWM): We cannot wrap, we're infinite.
if !(p == 25 || (p == 7 && final == 'l')) {
return false
}
}
return true
}
if private != 0 || len(intermediate) > 0 {
return false
}
switch {
case final == 'C': // Cursor Forward
if len(params) == 0 {
tw.column++
} else if len(params) >= 1 {
tw.column += params[0]
}
return true
case final == 'D': // Cursor Backward
if len(params) == 0 {
tw.column--
} else if len(params) >= 1 {
tw.column -= params[0]
}
if tw.column < 0 {
tw.column = 0
}
return true
case final == 'E': // Cursor Next Line
if len(params) == 0 {
tw.line++
} else if len(params) >= 1 {
tw.line += params[0]
}
tw.column = 0
return true
case final == 'F': // Cursor Preceding Line
if len(params) == 0 {
tw.line--
} else if len(params) >= 1 {
tw.line -= params[0]
}
if tw.line < tw.lineTop {
tw.line = tw.lineTop
}
tw.column = 0
return true
case final == 'H': // Cursor Position
if len(params) == 0 {
tw.line = tw.lineTop
tw.column = 0
} else if len(params) >= 2 && params[0] != 0 && params[1] != 0 {
tw.line = tw.lineTop + params[0] - 1
tw.column = params[1] - 1
} else {
return false
}
return true
case final == 'J': // Erase in Display
if len(params) == 0 || params[0] == 0 || params[0] == 2 {
// We're not going to erase anything, thank you very much.
tw.processFlush()
} else {
return false
}
return true
case final == 'K': // Erase in Line
if tw.line >= len(tw.lines) {
return true
}
line := &tw.lines[tw.line]
if len(params) == 0 || params[0] == 0 {
if len(line.columns) > tw.column {
line.columns = line.columns[:tw.column]
}
} else if params[0] == 1 {
for i := 0; i < tw.column; i++ {
line.columns[i] = ' '
}
} else if params[0] == 2 {
line.columns = nil
} else {
return false
}
return true
case final == 'm':
// Straight up ignoring all attributes, at least for now.
return true
}
return false
}
func (tw *terminalWriter) processCSI(rb []rune) ([]rune, bool) {
if len(rb) < 3 {
return nil, true
}
i, private, param, intermediate := 2, rune(0), []rune{}, []rune{}
if rb[i] >= 0x3C && rb[i] <= 0x3F {
private = rb[i]
i++
}
for i < len(rb) && ((rb[i] >= '0' && rb[i] <= '9') || rb[i] == ';') {
param = append(param, rb[i])
i++
}
for i < len(rb) && rb[i] >= 0x20 && rb[i] <= 0x2F {
intermediate = append(intermediate, rb[i])
i++
}
if i == len(rb) {
return nil, true
}
if rb[i] < 0x40 || rb[i] > 0x7E {
return rb, false
}
if !tw.processParsedCSI(private, param, intermediate, rb[i]) {
tw.log("unhandled CSI %s", string(rb[2:i+1]))
return rb, false
}
return rb[i+1:], true
}
func (tw *terminalWriter) processEscape(rb []rune) ([]rune, bool) {
if len(rb) < 2 {
return nil, true
}
// Very roughly following https://vt100.net/emu/dec_ansi_parser
// but being a bit stricter.
switch r := rb[1]; {
case r == '[':
return tw.processCSI(rb)
case r == ']':
// TODO(p): Skip this properly, once we actually hit it.
tw.log("unhandled OSC")
return rb, false
case r == 'P':
// TODO(p): Skip this properly, once we actually hit it.
tw.log("unhandled DCS")
return rb, false
// Only handling sequences we've seen bother us in real life.
case r == 'c':
// Full reset, use this to flush all output.
tw.processFlush()
return rb[2:], true
case r == 'M':
tw.line--
return rb[2:], true
case (r >= 0x30 && r <= 0x4F) || (r >= 0x51 && r <= 0x57) ||
r == 0x59 || r == 0x5A || r == 0x5C || (r >= 0x60 && r <= 0x7E):
// → esc_dispatch
tw.log("unhandled ESC %c", r)
return rb, false
//return rb[2:], true
case r >= 0x20 && r <= 0x2F:
// → escape intermediate
i := 2
for i < len(rb) && rb[i] >= 0x20 && rb[i] <= 0x2F {
i++
}
if i == len(rb) {
return nil, true
}
if rb[i] < 0x30 || rb[i] > 0x7E {
return rb, false
}
// → esc_dispatch
tw.log("unhandled ESC %s", string(rb[1:i+1]))
return rb, false
//return rb[i+1:], true
default:
// Note that Debian 12 has been seen to produce ESC<U+2026>
// and such due to some very blind string processing.
return rb, false
}
}
func (tw *terminalWriter) processRunes() bool {
rb := tw.runeBuffer
if len(rb) == 0 {
return false
}
switch rb[0] {
case '\a':
// Ding dong!
case '\b':
if tw.column > 0 {
tw.column--
}
case '\n', '\v':
tw.line++
// Forced ONLCR flag, because that's what most shell output expects.
fallthrough
case '\r':
tw.column = 0
case '\x1b':
var ok bool
if rb, ok = tw.processEscape(rb); rb == nil {
return false
} else if ok {
tw.runeBuffer = rb
return true
}
// Unsuccessful parses get printed for later inspection.
fallthrough
default:
tw.processPrint(rb[0])
}
tw.runeBuffer = rb[1:]
return true
}

100
terminal_test.go Normal file
View File

@@ -0,0 +1,100 @@
package main
import (
"slices"
"testing"
)
// This could be way more extensive, but we're not aiming for perfection.
var tests = []struct {
push, want string
}{
{
// Escaping and UTF-8.
"\x03\x1bž\bř",
"^C^[ř\n",
},
{
// Several kinds of sequences to be ignored.
"\x1bc\x1b[?7l\x1b[2J\x1b[0;1mSeaBIOS\rTea",
"TeaBIOS\n",
},
{
// New origin and absolute positioning.
"Line 1\n\x1bcWine B\nFine 3\x1b[1;6H2\x1b[HL\nL",
"Line 1\nLine 2\nLine 3\n",
},
{
// In-line positioning (without corner cases).
"A\x1b[CB\x1b[2C?\x1b[DC\x1b[2D\b->",
"A B->C\n",
},
{
// Up and down.
"\nB\x1bMA\v\vC" + "\x1b[4EG" + "\x1b[FF" + "\x1b[2FD" + "\x1b[EE",
" A\nB\nC\nD\nE\nF\nG\n",
},
{
// In-line erasing.
"1234\b\b\x1b[K\n5678\b\b\x1b[0K\n" + "abcd\b\b\x1b[1K\nefgh\x1b[2K",
"12\n56\n cd\n\n",
},
}
func TestTerminal(t *testing.T) {
for _, test := range tests {
tw := terminalWriter{}
if _, err := tw.Write([]byte(test.push)); err != nil {
t.Errorf("%#v: %s", test.push, err)
continue
}
have := string(tw.Serialize(0))
if have != test.want {
t.Errorf("%#v: %#v; want %#v", test.push, have, test.want)
}
}
}
func TestTerminalExploded(t *testing.T) {
Loop:
for _, test := range tests {
tw := terminalWriter{}
for _, b := range []byte(test.push) {
if _, err := tw.Write([]byte{b}); err != nil {
t.Errorf("%#v: %s", test.push, err)
continue Loop
}
}
have := string(tw.Serialize(0))
if have != test.want {
t.Errorf("%#v: %#v; want %#v", test.push, have, test.want)
}
}
}
func TestTerminalUpdateGroups(t *testing.T) {
tw := terminalWriter{}
collect := func() (have []int) {
for _, line := range tw.lines {
have = append(have, line.updateGroup)
}
return
}
// 0: A 0 0 0
// 1: B X 1 1 1
// 2: C Y 1 2 1 1
// 3: Z 2 3 2
// 4: 3 4
tw.Write([]byte("A\nB\nC\x1b[FX\nY\nZ"))
have, want := collect(), []int{0, 1, 1, 3}
if !slices.Equal(want, have) {
t.Errorf("update groups: %+v; want: %+v", have, want)
}
tw.Write([]byte("\x1b[F1\n2\n3"))
have, want = collect(), []int{0, 1, 1, 2, 4}
if !slices.Equal(want, have) {
t.Errorf("update groups: %+v; want: %+v", have, want)
}
}