From 25fc28927df24b60e21e10477e9f97184b2bbc84 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 23 Aug 2026 13:06:37 +0200 Subject: [PATCH 1/4] fix(remote): refuse a redirect that drops TLS The scheme was only checked on the URL the user wrote. A server answering an https URL with a redirect to http was followed by the client without any further check, so both the HEAD probe and the download travelled in the clear, and a network attacker could substitute the Taskfile that is about to be executed. CheckRedirect now refuses an https to http hop. --insecure does not loosen it: requesting an http entrypoint is the user's decision, being sent to one is the server's. Setting CheckRedirect also replaces Go's default cap, so the ten-hop limit is kept explicitly. The three call sites turned almost every client error into a generic download failure, which would have hidden the reason; TaskfileNotSecureError is now passed through, with wording of its own for the redirect case since --insecure is not a way out of it. --- CHANGELOG.md | 8 +++ errors/errors_taskfile.go | 9 +++ taskfile/node_http.go | 27 ++++++- taskfile/node_http_test.go | 88 ++++++++++++++++++++++- taskfile/taskfile.go | 6 ++ website/src/next/docs/remote-taskfiles.md | 4 ++ 6 files changed, 138 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 413c8f7f12..e1b0778f94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +### 🐛 Fixes + +- Fixed a remote Taskfile served over `https` being downloaded in the clear when + the server redirects to `http`. The scheme was only checked on the URL you + wrote, not on the ones you were redirected to, so neither the detection + request nor the download was protected. Such a redirect is now refused, even + with `--insecure` (by @vmaerten). + ### 📦 Package API - Bumped the minimum Go version to 1.26. Task follows Go's two-latest support diff --git a/errors/errors_taskfile.go b/errors/errors_taskfile.go index 3b2b3795f8..fa7fab6146 100644 --- a/errors/errors_taskfile.go +++ b/errors/errors_taskfile.go @@ -102,9 +102,18 @@ func (err *TaskfileNotTrustedError) Code() int { // remote Taskfile over an insecure connection. type TaskfileNotSecureError struct { URI string + // Redirect reports that the insecure URI was reached through a redirect + // rather than requested, in which case --insecure does not allow it. + Redirect bool } func (err *TaskfileNotSecureError) Error() string { + if err.Redirect { + return fmt.Sprintf( + `task: Taskfile %q was redirected to over an insecure connection. Point the URL at the final location instead`, + filepath.ToSlash(err.URI), + ) + } return fmt.Sprintf( `task: Taskfile %q cannot be downloaded over an insecure connection. You can override this by using the --insecure flag`, filepath.ToSlash(err.URI), diff --git a/taskfile/node_http.go b/taskfile/node_http.go index e8cbecba2d..ce79fadb92 100644 --- a/taskfile/node_http.go +++ b/taskfile/node_http.go @@ -32,9 +32,12 @@ func buildHTTPClient(insecure bool, caCert, cert, certKey string) (*http.Client, return nil, fmt.Errorf("both --cert and --cert-key must be provided together") } - // If no TLS customization is needed, return the default client + // If no TLS customization is needed, copy the default client rather than + // hand it out: setting CheckRedirect on it would apply process-wide. if !insecure && caCert == "" && cert == "" { - return http.DefaultClient, nil + client := *http.DefaultClient + client.CheckRedirect = checkRedirect + return &client, nil } tlsConfig := &tls.Config{ @@ -67,9 +70,26 @@ func buildHTTPClient(insecure bool, caCert, cert, certKey string) (*http.Client, Transport: &http.Transport{ TLSClientConfig: tlsConfig, }, + CheckRedirect: checkRedirect, }, nil } +// checkRedirect refuses a redirect that would drop TLS. --insecure does not +// loosen it: an http:// entrypoint is the user's choice, a redirect is not. +func checkRedirect(req *http.Request, via []*http.Request) error { + // Setting CheckRedirect replaces the default cap, so it has to be kept. + if len(via) >= 10 { + return fmt.Errorf("stopped after 10 redirects") + } + if len(via) == 0 { + return nil + } + if via[len(via)-1].URL.Scheme == "https" && req.URL.Scheme == "http" { + return &errors.TaskfileNotSecureError{URI: req.URL.Redacted(), Redirect: true} + } + return nil +} + func NewHTTPNode( entrypoint string, dir string, @@ -120,6 +140,9 @@ func (node *HTTPNode) ReadContext(ctx context.Context) ([]byte, error) { if ctx.Err() != nil { return nil, err } + if notSecure, ok := errors.AsType[*errors.TaskfileNotSecureError](err); ok { + return nil, notSecure + } return nil, errors.TaskfileFetchFailedError{URI: node.Location()} } defer resp.Body.Close() diff --git a/taskfile/node_http_test.go b/taskfile/node_http_test.go index 359ec798bf..b49346f4ee 100644 --- a/taskfile/node_http_test.go +++ b/taskfile/node_http_test.go @@ -9,6 +9,7 @@ import ( "encoding/pem" "math/big" "net/http" + "net/http/httptest" "os" "path/filepath" "testing" @@ -16,6 +17,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/go-task/task/v3/errors" ) func TestHTTPNode_CacheKey(t *testing.T) { @@ -62,10 +65,14 @@ func TestHTTPNode_CacheKey(t *testing.T) { func TestBuildHTTPClient_Default(t *testing.T) { t.Parallel() - // When no TLS customization is needed, should return http.DefaultClient + // When no TLS customization is needed, should copy http.DefaultClient client, err := buildHTTPClient(false, "", "", "") require.NoError(t, err) - assert.Equal(t, http.DefaultClient, client) + assert.NotSame(t, http.DefaultClient, client) + assert.Equal(t, http.DefaultClient.Transport, client.Transport) + assert.NotNil(t, client.CheckRedirect) + // The shared client must keep following redirects as before. + assert.Nil(t, http.DefaultClient.CheckRedirect) } func TestBuildHTTPClient_Insecure(t *testing.T) { @@ -282,3 +289,80 @@ func generateTestCACert(t *testing.T) []byte { Bytes: certDER, }) } + +func TestCheckRedirect(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + from string + to string + wantErr bool + }{ + {name: "https to http is refused", from: "https://example.com", to: "http://example.com", wantErr: true}, + {name: "https to http on another host is refused", from: "https://example.com", to: "http://evil.test", wantErr: true}, + {name: "https to https is allowed", from: "https://example.com", to: "https://other.example.com"}, + {name: "http to https is allowed", from: "http://example.com", to: "https://example.com"}, + {name: "http to http is allowed, the entrypoint already opted in", from: "http://example.com", to: "http://other.example.com"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + via := []*http.Request{mustGet(t, tt.from)} + err := checkRedirect(mustGet(t, tt.to), via) + if !tt.wantErr { + require.NoError(t, err) + return + } + var notSecure *errors.TaskfileNotSecureError + require.ErrorAs(t, err, ¬Secure) + }) + } +} + +func TestCheckRedirectFirstRequest(t *testing.T) { + t.Parallel() + + require.NoError(t, checkRedirect(mustGet(t, "http://example.com"), nil)) +} + +func TestCheckRedirectStopsAfterTenHops(t *testing.T) { + t.Parallel() + + via := make([]*http.Request, 10) + for i := range via { + via[i] = mustGet(t, "https://example.com") + } + require.Error(t, checkRedirect(mustGet(t, "https://example.com"), via)) +} + +// The downgrade is refused even with --insecure, which here also makes the +// client accept the test server's self-signed certificate. +func TestBuildHTTPClientRefusesDowngradeWithInsecure(t *testing.T) { + t.Parallel() + + plain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer plain.Close() + + secure := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, plain.URL+"/Taskfile.yml", http.StatusFound) + })) + defer secure.Close() + + client, err := buildHTTPClient(true, "", "", "") + require.NoError(t, err) + + _, err = client.Do(mustGet(t, secure.URL+"/Taskfile.yml")) //nolint:bodyclose // the request never completes + var notSecure *errors.TaskfileNotSecureError + require.ErrorAs(t, err, ¬Secure) +} + +func mustGet(t *testing.T, rawURL string) *http.Request { + t.Helper() + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, rawURL, nil) + require.NoError(t, err) + return req +} diff --git a/taskfile/taskfile.go b/taskfile/taskfile.go index 4251a20528..5a8555d73a 100644 --- a/taskfile/taskfile.go +++ b/taskfile/taskfile.go @@ -51,6 +51,9 @@ func RemoteExists(ctx context.Context, u url.URL, client *http.Client) (*url.URL if ctx.Err() != nil { return nil, fmt.Errorf("checking remote file: %w", ctx.Err()) } + if notSecure, ok := errors.AsType[*errors.TaskfileNotSecureError](err); ok { + return nil, notSecure + } return nil, errors.TaskfileFetchFailedError{URI: u.Redacted()} } defer resp.Body.Close() @@ -80,6 +83,9 @@ func RemoteExists(ctx context.Context, u url.URL, client *http.Client) (*url.URL // Try the alternative URL resp, err = client.Do(req) if err != nil { + if notSecure, ok := errors.AsType[*errors.TaskfileNotSecureError](err); ok { + return nil, notSecure + } return nil, errors.TaskfileFetchFailedError{URI: u.Redacted()} } defer resp.Body.Close() diff --git a/website/src/next/docs/remote-taskfiles.md b/website/src/next/docs/remote-taskfiles.md index 4d54918e61..4899a3f94b 100644 --- a/website/src/next/docs/remote-taskfiles.md +++ b/website/src/next/docs/remote-taskfiles.md @@ -260,6 +260,10 @@ Taskfile that is downloaded via an unencrypted connection. Sources that are not protected by TLS are vulnerable to man-in-the-middle attacks and should be avoided unless you know what you are doing. +A server answering an `https` URL with a redirect to `http` is refused, even +with `--insecure`. Requesting an `http` entrypoint is your decision; being sent +to one is the server's. + #### Custom Certificates If your remote Taskfiles are hosted on a server that uses a custom CA From f1637f84b56c1875269a4240c160258e62cccacd Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 23 Aug 2026 13:18:05 +0200 Subject: [PATCH 2/4] refactor(remote): extract the fetch error translation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same policy — keep TaskfileNotSecureError, make everything else a generic download failure — was spelled out at each of the three call sites, where copies of a security rule tend to drift apart. --- taskfile/node_http.go | 5 +---- taskfile/taskfile.go | 19 +++++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/taskfile/node_http.go b/taskfile/node_http.go index ce79fadb92..cfcc96144a 100644 --- a/taskfile/node_http.go +++ b/taskfile/node_http.go @@ -140,10 +140,7 @@ func (node *HTTPNode) ReadContext(ctx context.Context) ([]byte, error) { if ctx.Err() != nil { return nil, err } - if notSecure, ok := errors.AsType[*errors.TaskfileNotSecureError](err); ok { - return nil, notSecure - } - return nil, errors.TaskfileFetchFailedError{URI: node.Location()} + return nil, taskfileFetchError(err, node.Location()) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { diff --git a/taskfile/taskfile.go b/taskfile/taskfile.go index 5a8555d73a..54d089458c 100644 --- a/taskfile/taskfile.go +++ b/taskfile/taskfile.go @@ -51,10 +51,7 @@ func RemoteExists(ctx context.Context, u url.URL, client *http.Client) (*url.URL if ctx.Err() != nil { return nil, fmt.Errorf("checking remote file: %w", ctx.Err()) } - if notSecure, ok := errors.AsType[*errors.TaskfileNotSecureError](err); ok { - return nil, notSecure - } - return nil, errors.TaskfileFetchFailedError{URI: u.Redacted()} + return nil, taskfileFetchError(err, u.Redacted()) } defer resp.Body.Close() @@ -83,10 +80,7 @@ func RemoteExists(ctx context.Context, u url.URL, client *http.Client) (*url.URL // Try the alternative URL resp, err = client.Do(req) if err != nil { - if notSecure, ok := errors.AsType[*errors.TaskfileNotSecureError](err); ok { - return nil, notSecure - } - return nil, errors.TaskfileFetchFailedError{URI: u.Redacted()} + return nil, taskfileFetchError(err, u.Redacted()) } defer resp.Body.Close() @@ -98,3 +92,12 @@ func RemoteExists(ctx context.Context, u url.URL, client *http.Client) (*url.URL return nil, errors.TaskfileNotFoundError{URI: u.Redacted(), Walk: false} } + +// taskfileFetchError preserves redirect-policy errors wrapped by http.Client. +// Other transport errors remain generic download failures. +func taskfileFetchError(err error, uri string) error { + if notSecure, ok := errors.AsType[*errors.TaskfileNotSecureError](err); ok { + return notSecure + } + return errors.TaskfileFetchFailedError{URI: uri} +} From dfc530e97ea3ebdaff6aaa246415254918c22f98 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sun, 23 Aug 2026 13:28:20 +0200 Subject: [PATCH 3/4] fix(remote): allow a downgrade redirect under --insecure Refusing it unconditionally was security theatre: --insecure also sets InsecureSkipVerify, so an attacker in position to intercept can already serve anything over the https leg with a self-signed certificate. It also broke an internal server that redirects and works today. --insecure now means one thing everywhere: the transport guarantees are waived. --- CHANGELOG.md | 9 ++-- errors/errors_taskfile.go | 2 +- taskfile/node_http.go | 33 +++++++----- taskfile/node_http_test.go | 64 ++++++++++++++++++----- website/src/next/docs/remote-taskfiles.md | 6 +-- 5 files changed, 77 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1b0778f94..2b75c250b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,10 @@ ### 🐛 Fixes -- Fixed a remote Taskfile served over `https` being downloaded in the clear when - the server redirects to `http`. The scheme was only checked on the URL you - wrote, not on the ones you were redirected to, so neither the detection - request nor the download was protected. Such a redirect is now refused, even - with `--insecure` (by @vmaerten). +- Fixed an `https` remote Taskfile being downloaded in the clear if the server + redirected to `http`, leaving the file that is about to be executed open to + tampering. Such a redirect now requires `--insecure`, like an `http` + entrypoint (by @vmaerten). ### 📦 Package API diff --git a/errors/errors_taskfile.go b/errors/errors_taskfile.go index fa7fab6146..725b865c0e 100644 --- a/errors/errors_taskfile.go +++ b/errors/errors_taskfile.go @@ -110,7 +110,7 @@ type TaskfileNotSecureError struct { func (err *TaskfileNotSecureError) Error() string { if err.Redirect { return fmt.Sprintf( - `task: Taskfile %q was redirected to over an insecure connection. Point the URL at the final location instead`, + `task: Taskfile %q was redirected to over an insecure connection. You can override this by using the --insecure flag`, filepath.ToSlash(err.URI), ) } diff --git a/taskfile/node_http.go b/taskfile/node_http.go index cfcc96144a..d96cf7e8d2 100644 --- a/taskfile/node_http.go +++ b/taskfile/node_http.go @@ -36,7 +36,7 @@ func buildHTTPClient(insecure bool, caCert, cert, certKey string) (*http.Client, // hand it out: setting CheckRedirect on it would apply process-wide. if !insecure && caCert == "" && cert == "" { client := *http.DefaultClient - client.CheckRedirect = checkRedirect + client.CheckRedirect = checkRedirect(insecure) return &client, nil } @@ -70,24 +70,29 @@ func buildHTTPClient(insecure bool, caCert, cert, certKey string) (*http.Client, Transport: &http.Transport{ TLSClientConfig: tlsConfig, }, - CheckRedirect: checkRedirect, + CheckRedirect: checkRedirect(insecure), }, nil } -// checkRedirect refuses a redirect that would drop TLS. --insecure does not -// loosen it: an http:// entrypoint is the user's choice, a redirect is not. -func checkRedirect(req *http.Request, via []*http.Request) error { - // Setting CheckRedirect replaces the default cap, so it has to be kept. - if len(via) >= 10 { - return fmt.Errorf("stopped after 10 redirects") - } - if len(via) == 0 { +// checkRedirect refuses a redirect that would drop TLS, unless --insecure was +// given: that flag also disables certificate verification, so refusing the +// plaintext hop would guard nothing an attacker could not walk around. +func checkRedirect(insecure bool) func(*http.Request, []*http.Request) error { + return func(req *http.Request, via []*http.Request) error { + // Setting CheckRedirect replaces the default cap, so it has to be kept. + if len(via) >= 10 { + return fmt.Errorf("stopped after 10 redirects") + } + if len(via) == 0 { + return nil + } + if !insecure && + via[len(via)-1].URL.Scheme == "https" && + req.URL.Scheme == "http" { + return &errors.TaskfileNotSecureError{URI: req.URL.Redacted(), Redirect: true} + } return nil } - if via[len(via)-1].URL.Scheme == "https" && req.URL.Scheme == "http" { - return &errors.TaskfileNotSecureError{URI: req.URL.Redacted(), Redirect: true} - } - return nil } func NewHTTPNode( diff --git a/taskfile/node_http_test.go b/taskfile/node_http_test.go index b49346f4ee..f1e4c553bb 100644 --- a/taskfile/node_http_test.go +++ b/taskfile/node_http_test.go @@ -12,6 +12,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "sync/atomic" "testing" "time" @@ -294,13 +295,15 @@ func TestCheckRedirect(t *testing.T) { t.Parallel() tests := []struct { - name string - from string - to string - wantErr bool + name string + from string + to string + insecure bool + wantErr bool }{ {name: "https to http is refused", from: "https://example.com", to: "http://example.com", wantErr: true}, {name: "https to http on another host is refused", from: "https://example.com", to: "http://evil.test", wantErr: true}, + {name: "https to http is allowed with insecure", from: "https://example.com", to: "http://example.com", insecure: true}, {name: "https to https is allowed", from: "https://example.com", to: "https://other.example.com"}, {name: "http to https is allowed", from: "http://example.com", to: "https://example.com"}, {name: "http to http is allowed, the entrypoint already opted in", from: "http://example.com", to: "http://other.example.com"}, @@ -310,7 +313,7 @@ func TestCheckRedirect(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() via := []*http.Request{mustGet(t, tt.from)} - err := checkRedirect(mustGet(t, tt.to), via) + err := checkRedirect(tt.insecure)(mustGet(t, tt.to), via) if !tt.wantErr { require.NoError(t, err) return @@ -324,7 +327,7 @@ func TestCheckRedirect(t *testing.T) { func TestCheckRedirectFirstRequest(t *testing.T) { t.Parallel() - require.NoError(t, checkRedirect(mustGet(t, "http://example.com"), nil)) + require.NoError(t, checkRedirect(false)(mustGet(t, "http://example.com"), nil)) } func TestCheckRedirectStopsAfterTenHops(t *testing.T) { @@ -334,30 +337,63 @@ func TestCheckRedirectStopsAfterTenHops(t *testing.T) { for i := range via { via[i] = mustGet(t, "https://example.com") } - require.Error(t, checkRedirect(mustGet(t, "https://example.com"), via)) + require.Error(t, checkRedirect(false)(mustGet(t, "https://example.com"), via)) } -// The downgrade is refused even with --insecure, which here also makes the -// client accept the test server's self-signed certificate. -func TestBuildHTTPClientRefusesDowngradeWithInsecure(t *testing.T) { - t.Parallel() +// downgradeServers returns a TLS server redirecting to a plaintext one, and a +// flag reporting whether the plaintext one was ever reached. +func downgradeServers(t *testing.T) (*httptest.Server, *atomic.Bool) { + t.Helper() + var plainReached atomic.Bool plain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + plainReached.Store(true) w.WriteHeader(http.StatusOK) })) - defer plain.Close() + t.Cleanup(plain.Close) secure := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, plain.URL+"/Taskfile.yml", http.StatusFound) })) - defer secure.Close() + t.Cleanup(secure.Close) - client, err := buildHTTPClient(true, "", "", "") + return secure, &plainReached +} + +func TestBuildHTTPClientRefusesDowngrade(t *testing.T) { + t.Parallel() + + secure, plainReached := downgradeServers(t) + + // The test server's certificate has to be trusted explicitly, so that the + // refusal is the redirect and not the handshake. + caCert := filepath.Join(t.TempDir(), "ca.crt") + require.NoError(t, os.WriteFile(caCert, pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", Bytes: secure.Certificate().Raw, + }), 0o600)) + + client, err := buildHTTPClient(false, caCert, "", "") require.NoError(t, err) _, err = client.Do(mustGet(t, secure.URL+"/Taskfile.yml")) //nolint:bodyclose // the request never completes var notSecure *errors.TaskfileNotSecureError require.ErrorAs(t, err, ¬Secure) + assert.False(t, plainReached.Load(), "the plaintext server must never be contacted") +} + +func TestBuildHTTPClientFollowsDowngradeWithInsecure(t *testing.T) { + t.Parallel() + + secure, plainReached := downgradeServers(t) + + client, err := buildHTTPClient(true, "", "", "") + require.NoError(t, err) + + resp, err := client.Do(mustGet(t, secure.URL+"/Taskfile.yml")) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.True(t, plainReached.Load()) } func mustGet(t *testing.T, rawURL string) *http.Request { diff --git a/website/src/next/docs/remote-taskfiles.md b/website/src/next/docs/remote-taskfiles.md index 4899a3f94b..a0ab10b5c1 100644 --- a/website/src/next/docs/remote-taskfiles.md +++ b/website/src/next/docs/remote-taskfiles.md @@ -260,9 +260,9 @@ Taskfile that is downloaded via an unencrypted connection. Sources that are not protected by TLS are vulnerable to man-in-the-middle attacks and should be avoided unless you know what you are doing. -A server answering an `https` URL with a redirect to `http` is refused, even -with `--insecure`. Requesting an `http` entrypoint is your decision; being sent -to one is the server's. +A server answering an `https` URL with a redirect to `http` is refused too, +unless you pass `--insecure` — the flag covers the whole download, not just the +URL you wrote. #### Custom Certificates From b6b8ca1cdd2943ad73bb7005b0c57e7985183b82 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Tue, 25 Aug 2026 13:20:10 +0200 Subject: [PATCH 4/4] chore: reword the redirect downgrade changelog entry --- CHANGELOG.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b75c250b7..05cafdfb43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,9 @@ ### 🐛 Fixes -- Fixed an `https` remote Taskfile being downloaded in the clear if the server - redirected to `http`, leaving the file that is about to be executed open to - tampering. Such a redirect now requires `--insecure`, like an `http` - entrypoint (by @vmaerten). +- Fixed an `https` remote Taskfile being downloaded over an unencrypted + connection when the server redirects to `http`. Such a redirect now requires + `--insecure`, like an `http` entrypoint (by @vmaerten). ### 📦 Package API