Files
privatae-bastion/client-go/notify.go
T
Privatae 2043fa5e9e
ci / fuzz (push) Successful in 3m27s
ci / personal-data (push) Successful in 4s
ci / test (push) Successful in 3m39s
bastion 0.62.0
2026-11-02 21:31:58 +00:00

365 lines
14 KiB
Go

package main
// notify.go — the two places this daemon has to INTERRUPT the person at the
// keyboard, and the rules about when it is allowed to.
//
// WHAT IS HERE AND WHY IT IS NOT BEHIND A BUILD TAG. Only the *mechanism* of a
// Windows notification is Windows-shaped (notify_windows.go). Deciding whether a
// request has already been announced, how many are waiting, and what the words
// are is ordinary logic — and logic behind `//go:build windows` is logic the
// suite never runs, because the suite runs on Linux. tray.go exists for exactly
// this reason and says so; this file is the same split for notifications.
//
// THE DEFECT. A host asks to reach a folder, stepupNewRequest writes the ask
// into that pairing's stepup_requests.json (stepup.go:586), and then nothing
// happens. `pendingApprovals` (ui.go:192) will show it — to whoever opens the
// page. It is meant to be a prompt, and without this file it is a poll. A
// consent gate the user is never told about
// is not a consent gate, and the whole security story of step-up rests on it.
//
// THE HARD PART IS NOT SHOWING ONE — it is showing it ONCE. The tray re-reads
// the machine's state every trayRefresh = 5 s (tray_windows.go:52), so the same
// pending request is in front of this code twelve times a minute, for as long as
// 24 h (stepupRequestTTL, stepup.go:62). Announcing what it sees would be a
// notification every five seconds until the user gave up and turned
// notifications off for the app — which is worse than the silence it replaced,
// because it is silence the user chose and will not undo.
import (
"fmt"
"strings"
"time"
"unicode/utf16"
)
// uiNotice is one thing said to the person at this machine, in the shape every
// platform notification takes: a short title and a short body.
type uiNotice struct {
Title string
Body string
}
// The Windows notification area is not a string API. NOTIFYICONDATAW carries
// FIXED WCHAR arrays and the shell reads each to its first NUL, so a caption
// that does not fit is not shortened for you — it overruns the array. Sizes
// verified against mingw-w64's shellapi.h (`WCHAR szTip[128]`, `szInfo[256]`,
// `szInfoTitle[64]`) and against fyne.io/systray's transcription of the same
// struct (systray_windows.go:116-129), which is the one reference here that is
// known to work in the field. One unit of each is reserved for the NUL.
//
// These are UTF-16 CODE UNITS, not bytes and not runes — see clipUTF16.
const (
noticeTitleMax = 63
noticeBodyMax = 255
noticeTipMax = 127
)
// approvalNotifyMinGap is the floor between two notifications.
//
// Requests do not arrive on a schedule: a host setting itself up can propose
// several folders in a row, and each one is its own request id. Coalescing
// inside a single poll is free (they are all in one list), but three asks spread
// over three 5-second ticks would be three interruptions in fifteen seconds, and
// that is the shape of a notification people learn to dismiss without reading.
//
// 30 s is chosen, not inherited: the page a notification points at lists EVERY
// pending request on the machine, from every pairing (ui.go:164), so a second
// notification inside half a minute carries nothing the first one did not
// already carry. Anything new that lands inside the gap is HELD, never dropped —
// it goes out in the next notification, and until then it is already visible in
// the icon's tooltip and on the page.
const approvalNotifyMinGap = 30 * time.Second
// approvalForget is when a request id leaves this process's memory.
//
// It is the request's own lifetime and that is the whole derivation:
// requestsLoad deletes any request older than stepupRequestTTL on every read
// (stepup.go:562-573, stepupRequestTTL = 24h at stepup.go:62), so an id that has
// been out of the pending list for longer than that can never legitimately come
// back, and forgetting it cannot cause a second announcement.
//
// SHORTER WOULD BE A BUG, and this is the measurement behind that: the pending
// list goes empty for reasons that have nothing to do with the user answering
// anything. requestsLoad returns an EMPTY MAP on any read or unmarshal failure
// and reports no error (stepup.go:562-567 — `if err == nil` around both), and
// collectUIStatus returns with no approvals at all when loadPairings fails
// (ui.go:140-145). A Windows machine with a virus scanner holding the file open
// for a moment hits the first one. If the forget window were shorter than the
// blip, the id would come back looking brand new and be announced again.
const approvalForget = stepupRequestTTL
// approvalSeen is this process's memory of one request id.
type approvalSeen struct {
lastSeen time.Time
announced bool
}
// approvalNotifier is that memory, plus the gap timer. One per process, driven
// from the tray's existing poll — it holds no lock because it is stepped from a
// single goroutine (tray_windows.go's refresh).
type approvalNotifier struct {
seen map[string]approvalSeen
lastNotice time.Time
}
// approvalSink is the platform's half: what a notice actually DOES.
//
// An interface rather than a direct call into notify_windows.go, for one
// reason — it is the only way the DRIVE (below) gets tested on Linux. With a
// direct call, everything from "a second request arrived" to "the icon comes
// down" would be exercised only in a build nobody here can run.
type approvalSink interface {
// show puts the waiting icon up with this tooltip, and when notice is
// non-nil raises a notification through it.
show(tip string, notice *uiNotice)
// hide takes the waiting icon away: nothing is pending any more.
hide()
}
// poll is one tick: fold what is pending into the memory, then tell the sink.
func (n *approvalNotifier) poll(now time.Time, pending []uiApproval, out approvalSink) {
// step ALWAYS runs, including on an empty list — that is what ages the
// memory out, and skipping it would make forgetting depend on there
// happening to be traffic.
notice := n.step(now, pending)
if len(pending) == 0 {
out.hide()
return
}
out.show(approvalTip(len(pending)), notice)
}
// step folds one reading of the pending list into the memory and returns the
// notification to raise, or nil.
//
// NO GRACE WINDOW ON THE LIST ITSELF, and that is a decision rather than an
// omission. The obvious next move, once you know the list can go empty on a
// transient read failure (see approvalForget), is to keep treating it as pending
// for a few ticks. It was written and thrown away: it buys nothing the per-id
// memory does not already give — a re-appearing id is already known and already
// announced, so it cannot be announced twice — and it costs a tooltip that says
// "1 request is waiting" for half a minute AFTER the user answered the last one.
// A five-second flicker of an icon is a smaller lie than that.
func (n *approvalNotifier) step(now time.Time, pending []uiApproval) *uiNotice {
if n.seen == nil {
n.seen = map[string]approvalSeen{}
}
// Age out first, so an id that is pending right now is refreshed below and
// can never be dropped while it is still being asked about.
for id, e := range n.seen {
if now.Sub(e.lastSeen) > approvalForget {
delete(n.seen, id)
}
}
fresh := 0
for _, a := range pending {
// An id never seen reads back the zero value, whose announced is false —
// so a brand-new request and one that was held inside the gap take the
// same path, which is what makes "held, not dropped" true.
e := n.seen[a.ID]
e.lastSeen = now
n.seen[a.ID] = e
if !e.announced {
fresh++
}
}
if fresh == 0 {
return nil
}
// IsZero, not a subtraction: at process start lastNotice is the zero time,
// and the first pending request must interrupt immediately rather than wait
// out a gap that never started.
if !n.lastNotice.IsZero() && now.Sub(n.lastNotice) < approvalNotifyMinGap {
return nil // held, not dropped — they go out together next time
}
for _, a := range pending {
e := n.seen[a.ID]
e.announced = true
n.seen[a.ID] = e
}
n.lastNotice = now
notice := approvalNoticeFor(pending)
return &notice
}
// approvalNoticeFor is what the notification says.
//
// It counts EVERYTHING pending, not only what is new since the last one. The
// number is meant to answer "how much is waiting on me", and a machine with two
// already-announced requests plus one new one has three waiting — saying "1"
// there would be true about the event and wrong about the queue, which is the
// same mistake trayStateLine avoids for the anchor (tray.go:36).
func approvalNoticeFor(pending []uiApproval) uiNotice {
if len(pending) == 0 {
return uiNotice{}
}
if len(pending) == 1 {
a := pending[0]
var body string
switch {
case a.Unreadable:
// uiUnknownRequest (ui.go:234): this version does not know the kind
// being asked about, which is the ordinary state of an install
// mid-upgrade. The page turns that card red and demotes Approve; a
// notification that read like any other would undo that before the
// user ever got to the page.
body = "This machine cannot describe what is being asked. " +
"Click to see it — refusing is the safe answer."
case len(a.Lines) > 0:
body = a.Lines[0]
default:
body = "A host is asking this machine to allow something."
}
if a.Host != "" {
body += "\nFrom " + a.Host + ". Click to approve or deny it."
} else {
body += "\nClick to approve or deny it."
}
// The title is not clipped and does not need to be: both titles here are
// fixed text plus a count, so neither can reach noticeTitleMax — the
// mutation check proved it by deleting the clip and finding no test that
// could tell. TestEveryNoticeFitsTheWindowsFields asserts the property
// instead of the call, so a title that DOES grow is caught. Anything
// that slipped past both still cannot overrun the array: putUTF16 is the
// wall.
return uiNotice{
Title: "Privatae — a request is waiting",
Body: clipUTF16(body, noticeBodyMax),
}
}
hosts := distinctHosts(pending)
var from string
switch {
case len(hosts) == 0:
from = ""
case len(hosts) > 3:
// Past three, naming them all is a wall of text in a 255-unit field that
// nobody reads. The page names every one.
from = fmt.Sprintf(" from %d hosts", len(hosts))
default:
from = " from " + joinNames(hosts)
}
return uiNotice{
Title: fmt.Sprintf("Privatae — %d requests are waiting", len(pending)),
Body: clipUTF16(fmt.Sprintf("%d requests%s are waiting on you.\nClick to see what they ask.",
len(pending), from), noticeBodyMax),
}
}
// approvalTip is what the waiting icon says on hover. It is the one part of this
// that is true continuously rather than at a moment, so it carries the count and
// nothing else.
func approvalTip(n int) string {
if n == 1 {
return clipUTF16("Privatae — 1 request is waiting for you", noticeTipMax)
}
return clipUTF16(fmt.Sprintf("Privatae — %d requests are waiting for you", n), noticeTipMax)
}
// distinctHosts keeps the order the pairings were read in (collectUIStatus walks
// loadPairings and appends each pairing's approvals, ui.go:147-165), so the
// first host named is the first host configured rather than whichever one sorted
// first.
func distinctHosts(pending []uiApproval) []string {
var out []string
seen := map[string]bool{}
for _, a := range pending {
if a.Host == "" || seen[a.Host] {
continue
}
seen[a.Host] = true
out = append(out, a.Host)
}
return out
}
func joinNames(names []string) string {
switch len(names) {
case 0:
return ""
case 1:
return names[0]
case 2:
return names[0] + " and " + names[1]
}
return strings.Join(names[:len(names)-1], ", ") + " and " + names[len(names)-1]
}
// clipUTF16 shortens s to at most max UTF-16 code units — the unit the Windows
// arrays above are measured in, which is neither bytes nor runes.
//
// THE BUG IT EXISTS TO AVOID is a character outside the Basic Multilingual
// Plane. An emoji, or one of the rarer CJK ideographs, is ONE rune and TWO
// UTF-16 units (a surrogate pair). Counting in runes overruns the array;
// counting in units but cutting anywhere lands between the two halves of a pair,
// which is not a character at all and renders as a box or truncates the string
// at the shell's decoder. This is ordinary input, not an exotic case: the body
// of a notification is describeRequest's first line, which prints the host's
// requested path verbatim (stepup.go:363), and people name folders in their own
// language and put emoji in them.
//
// The ellipsis is U+2026, inside the BMP, so it costs exactly one unit.
func clipUTF16(s string, max int) string {
if max <= 0 {
return ""
}
if utf16Len(s) <= max {
return s
}
budget := max - 1 // room for the ellipsis
var b strings.Builder
n := 0
for _, r := range s {
w := 1
if r > 0xFFFF {
w = 2
}
if n+w > budget {
break
}
b.WriteRune(r)
n += w
}
return strings.TrimRight(b.String(), " ") + "…"
}
// putUTF16 copies s into one of those fixed WCHAR arrays, always leaving it
// NUL-terminated and never leaving half a surrogate pair at the end.
//
// It is untagged because nothing about it is Windows-specific except where the
// destination came from, and because it is the LAST line of defence: clipUTF16
// above is what is supposed to make the text fit, and this is what makes a bug
// in that a short caption instead of a buffer the shell reads past the end of.
// `copy` alone is not enough — it silently truncates and would drop the NUL.
func putUTF16(dst []uint16, s string) {
if len(dst) == 0 {
return
}
u := utf16.Encode([]rune(s))
if len(u) > len(dst)-1 {
u = u[:len(dst)-1]
// A high surrogate with its partner cut off is not a character. Drop it
// rather than hand the shell an unpaired one.
if n := len(u); n > 0 && u[n-1] >= 0xD800 && u[n-1] <= 0xDBFF {
u = u[:n-1]
}
}
n := copy(dst, u)
dst[n] = 0
}
// utf16Len is len(utf16.Encode([]rune(s))) without building either slice.
func utf16Len(s string) int {
n := 0
for _, r := range s {
if r > 0xFFFF {
n += 2
} else {
n++
}
}
return n
}