~stepbrobd/tailscale

01185e436fd39c2aa499b3c56bcb08d6c4dc7b84 — Brad Fitzpatrick a month ago 809a6eb
types/result, util/lineiter: add package for a result type, use it

This adds a new generic result type (motivated by golang/go#70084) to
try it out, and uses it in the new lineutil package (replacing the old
lineread package), changing that package to return iterators:
sometimes over []byte (when the input is all in memory), but sometimes
iterators over results of []byte, if errors might happen at runtime.

Updates #12912
Updates golang/go#70084

Change-Id: Iacdc1070e661b5fb163907b1e8b07ac7d51d3f83
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
M cmd/derper/depaware.txt => cmd/derper/depaware.txt +2 -1
@@ 140,6 140,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
        tailscale.com/types/persist                                  from tailscale.com/ipn
        tailscale.com/types/preftype                                 from tailscale.com/ipn
        tailscale.com/types/ptr                                      from tailscale.com/hostinfo+
        tailscale.com/types/result                                   from tailscale.com/util/lineiter
        tailscale.com/types/structs                                  from tailscale.com/ipn+
        tailscale.com/types/tkatype                                  from tailscale.com/client/tailscale+
        tailscale.com/types/views                                    from tailscale.com/ipn+


@@ 154,7 155,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
        tailscale.com/util/fastuuid                                  from tailscale.com/tsweb
     💣 tailscale.com/util/hashx                                     from tailscale.com/util/deephash
        tailscale.com/util/httpm                                     from tailscale.com/client/tailscale
        tailscale.com/util/lineread                                  from tailscale.com/hostinfo+
        tailscale.com/util/lineiter                                  from tailscale.com/hostinfo+
   L    tailscale.com/util/linuxfw                                   from tailscale.com/net/netns
        tailscale.com/util/mak                                       from tailscale.com/health+
        tailscale.com/util/multierr                                  from tailscale.com/health+

M cmd/k8s-operator/depaware.txt => cmd/k8s-operator/depaware.txt +2 -1
@@ 775,6 775,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
        tailscale.com/types/persist                                  from tailscale.com/control/controlclient+
        tailscale.com/types/preftype                                 from tailscale.com/ipn+
        tailscale.com/types/ptr                                      from tailscale.com/cmd/k8s-operator+
        tailscale.com/types/result                                   from tailscale.com/util/lineiter
        tailscale.com/types/structs                                  from tailscale.com/control/controlclient+
        tailscale.com/types/tkatype                                  from tailscale.com/client/tailscale+
        tailscale.com/types/views                                    from tailscale.com/appc+


@@ 792,7 793,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
     💣 tailscale.com/util/hashx                                     from tailscale.com/util/deephash
        tailscale.com/util/httphdr                                   from tailscale.com/ipn/ipnlocal+
        tailscale.com/util/httpm                                     from tailscale.com/client/tailscale+
        tailscale.com/util/lineread                                  from tailscale.com/hostinfo+
        tailscale.com/util/lineiter                                  from tailscale.com/hostinfo+
   L    tailscale.com/util/linuxfw                                   from tailscale.com/net/netns+
        tailscale.com/util/mak                                       from tailscale.com/appc+
        tailscale.com/util/multierr                                  from tailscale.com/control/controlclient+

M cmd/stund/depaware.txt => cmd/stund/depaware.txt +2 -1
@@ 67,6 67,7 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar
        tailscale.com/types/logger                                   from tailscale.com/tsweb
        tailscale.com/types/opt                                      from tailscale.com/envknob+
        tailscale.com/types/ptr                                      from tailscale.com/tailcfg+
        tailscale.com/types/result                                   from tailscale.com/util/lineiter
        tailscale.com/types/structs                                  from tailscale.com/tailcfg+
        tailscale.com/types/tkatype                                  from tailscale.com/tailcfg+
        tailscale.com/types/views                                    from tailscale.com/net/tsaddr+


@@ 74,7 75,7 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar
   L 💣 tailscale.com/util/dirwalk                                   from tailscale.com/metrics
        tailscale.com/util/dnsname                                   from tailscale.com/tailcfg
        tailscale.com/util/fastuuid                                  from tailscale.com/tsweb
        tailscale.com/util/lineread                                  from tailscale.com/version/distro
        tailscale.com/util/lineiter                                  from tailscale.com/version/distro
        tailscale.com/util/nocasemaps                                from tailscale.com/types/ipproto
        tailscale.com/util/slicesx                                   from tailscale.com/tailcfg
        tailscale.com/util/vizerror                                  from tailscale.com/tailcfg+

M cmd/tailscale/depaware.txt => cmd/tailscale/depaware.txt +2 -1
@@ 148,6 148,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
        tailscale.com/types/persist                                  from tailscale.com/ipn
        tailscale.com/types/preftype                                 from tailscale.com/cmd/tailscale/cli+
        tailscale.com/types/ptr                                      from tailscale.com/hostinfo+
        tailscale.com/types/result                                   from tailscale.com/util/lineiter
        tailscale.com/types/structs                                  from tailscale.com/ipn+
        tailscale.com/types/tkatype                                  from tailscale.com/types/key+
        tailscale.com/types/views                                    from tailscale.com/tailcfg+


@@ 162,7 163,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
        tailscale.com/util/groupmember                               from tailscale.com/client/web
     💣 tailscale.com/util/hashx                                     from tailscale.com/util/deephash
        tailscale.com/util/httpm                                     from tailscale.com/client/tailscale+
        tailscale.com/util/lineread                                  from tailscale.com/hostinfo+
        tailscale.com/util/lineiter                                  from tailscale.com/hostinfo+
   L    tailscale.com/util/linuxfw                                   from tailscale.com/net/netns
        tailscale.com/util/mak                                       from tailscale.com/cmd/tailscale/cli+
        tailscale.com/util/multierr                                  from tailscale.com/control/controlhttp+

M cmd/tailscaled/depaware.txt => cmd/tailscaled/depaware.txt +2 -1
@@ 364,6 364,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
        tailscale.com/types/persist                                  from tailscale.com/control/controlclient+
        tailscale.com/types/preftype                                 from tailscale.com/ipn+
        tailscale.com/types/ptr                                      from tailscale.com/control/controlclient+
        tailscale.com/types/result                                   from tailscale.com/util/lineiter
        tailscale.com/types/structs                                  from tailscale.com/control/controlclient+
        tailscale.com/types/tkatype                                  from tailscale.com/tka+
        tailscale.com/types/views                                    from tailscale.com/ipn/ipnlocal+


@@ 381,7 382,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
     💣 tailscale.com/util/hashx                                     from tailscale.com/util/deephash
        tailscale.com/util/httphdr                                   from tailscale.com/ipn/ipnlocal+
        tailscale.com/util/httpm                                     from tailscale.com/client/tailscale+
        tailscale.com/util/lineread                                  from tailscale.com/hostinfo+
        tailscale.com/util/lineiter                                  from tailscale.com/hostinfo+
   L    tailscale.com/util/linuxfw                                   from tailscale.com/net/netns+
        tailscale.com/util/mak                                       from tailscale.com/control/controlclient+
        tailscale.com/util/multierr                                  from tailscale.com/cmd/tailscaled+

M hostinfo/hostinfo.go => hostinfo/hostinfo.go +12 -12
@@ 25,7 25,7 @@ import (
	"tailscale.com/types/ptr"
	"tailscale.com/util/cloudenv"
	"tailscale.com/util/dnsname"
	"tailscale.com/util/lineread"
	"tailscale.com/util/lineiter"
	"tailscale.com/version"
	"tailscale.com/version/distro"
)


@@ 231,12 231,12 @@ func desktop() (ret opt.Bool) {
	}

	seenDesktop := false
	lineread.File("/proc/net/unix", func(line []byte) error {
	for lr := range lineiter.File("/proc/net/unix") {
		line, _ := lr.Value()
		seenDesktop = seenDesktop || mem.Contains(mem.B(line), mem.S(" @/tmp/dbus-"))
		seenDesktop = seenDesktop || mem.Contains(mem.B(line), mem.S(".X11-unix"))
		seenDesktop = seenDesktop || mem.Contains(mem.B(line), mem.S("/wayland-1"))
		return nil
	})
	}
	ret.Set(seenDesktop)

	// Only cache after a minute - compositors might not have started yet.


@@ 305,21 305,21 @@ func inContainer() opt.Bool {
		ret.Set(true)
		return ret
	}
	lineread.File("/proc/1/cgroup", func(line []byte) error {
	for lr := range lineiter.File("/proc/1/cgroup") {
		line, _ := lr.Value()
		if mem.Contains(mem.B(line), mem.S("/docker/")) ||
			mem.Contains(mem.B(line), mem.S("/lxc/")) {
			ret.Set(true)
			return io.EOF // arbitrary non-nil error to stop loop
			break
		}
		return nil
	})
	lineread.File("/proc/mounts", func(line []byte) error {
	}
	for lr := range lineiter.File("/proc/mounts") {
		line, _ := lr.Value()
		if mem.Contains(mem.B(line), mem.S("lxcfs /proc/cpuinfo fuse.lxcfs")) {
			ret.Set(true)
			return io.EOF
			break
		}
		return nil
	})
	}
	return ret
}


M hostinfo/hostinfo_linux.go => hostinfo/hostinfo_linux.go +8 -5
@@ 12,7 12,7 @@ import (

	"golang.org/x/sys/unix"
	"tailscale.com/types/ptr"
	"tailscale.com/util/lineread"
	"tailscale.com/util/lineiter"
	"tailscale.com/version/distro"
)



@@ 106,15 106,18 @@ func linuxVersionMeta() (meta versionMeta) {
	}

	m := map[string]string{}
	lineread.File(propFile, func(line []byte) error {
	for lr := range lineiter.File(propFile) {
		line, err := lr.Value()
		if err != nil {
			break
		}
		eq := bytes.IndexByte(line, '=')
		if eq == -1 {
			return nil
			continue
		}
		k, v := string(line[:eq]), strings.Trim(string(line[eq+1:]), `"'`)
		m[k] = v
		return nil
	})
	}

	if v := m["VERSION_CODENAME"]; v != "" {
		meta.DistroCodeName = v

M ipn/ipnlocal/ssh.go => ipn/ipnlocal/ssh.go +12 -10
@@ 27,7 27,7 @@ import (
	"github.com/tailscale/golang-x-crypto/ssh"
	"go4.org/mem"
	"tailscale.com/tailcfg"
	"tailscale.com/util/lineread"
	"tailscale.com/util/lineiter"
	"tailscale.com/util/mak"
)



@@ 80,30 80,32 @@ func (b *LocalBackend) getSSHUsernames(req *tailcfg.C2NSSHUsernamesRequest) (*ta
		if err != nil {
			return nil, err
		}
		lineread.Reader(bytes.NewReader(out), func(line []byte) error {
		for line := range lineiter.Bytes(out) {
			line = bytes.TrimSpace(line)
			if len(line) == 0 || line[0] == '_' {
				return nil
				continue
			}
			add(string(line))
			return nil
		})
		}
	default:
		lineread.File("/etc/passwd", func(line []byte) error {
		for lr := range lineiter.File("/etc/passwd") {
			line, err := lr.Value()
			if err != nil {
				break
			}
			line = bytes.TrimSpace(line)
			if len(line) == 0 || line[0] == '#' || line[0] == '_' {
				return nil
				continue
			}
			if mem.HasSuffix(mem.B(line), mem.S("/nologin")) ||
				mem.HasSuffix(mem.B(line), mem.S("/false")) {
				return nil
				continue
			}
			colon := bytes.IndexByte(line, ':')
			if colon != -1 {
				add(string(line[:colon]))
			}
			return nil
		})
		}
	}
	return res, nil
}

M net/netmon/interfaces_android.go => net/netmon/interfaces_android.go +23 -28
@@ 5,7 5,6 @@ package netmon

import (
	"bytes"
	"errors"
	"log"
	"net/netip"
	"os/exec"


@@ 15,7 14,7 @@ import (
	"golang.org/x/sys/unix"
	"tailscale.com/net/netaddr"
	"tailscale.com/syncs"
	"tailscale.com/util/lineread"
	"tailscale.com/util/lineiter"
)

var (


@@ 34,11 33,6 @@ func init() {

var procNetRouteErr atomic.Bool

// errStopReading is a sentinel error value used internally by
// lineread.File callers to stop reading. It doesn't escape to
// callers/users.
var errStopReading = errors.New("stop reading")

/*
Parse 10.0.0.1 out of:



@@ 54,44 48,42 @@ func likelyHomeRouterIPAndroid() (ret netip.Addr, myIP netip.Addr, ok bool) {
	}
	lineNum := 0
	var f []mem.RO
	err := lineread.File(procNetRoutePath, func(line []byte) error {
	for lr := range lineiter.File(procNetRoutePath) {
		line, err := lr.Value()
		if err != nil {
			procNetRouteErr.Store(true)
			return likelyHomeRouterIP()
		}

		lineNum++
		if lineNum == 1 {
			// Skip header line.
			return nil
			continue
		}
		if lineNum > maxProcNetRouteRead {
			return errStopReading
			break
		}
		f = mem.AppendFields(f[:0], mem.B(line))
		if len(f) < 4 {
			return nil
			continue
		}
		gwHex, flagsHex := f[2], f[3]
		flags, err := mem.ParseUint(flagsHex, 16, 16)
		if err != nil {
			return nil // ignore error, skip line and keep going
			continue // ignore error, skip line and keep going
		}
		if flags&(unix.RTF_UP|unix.RTF_GATEWAY) != unix.RTF_UP|unix.RTF_GATEWAY {
			return nil
			continue
		}
		ipu32, err := mem.ParseUint(gwHex, 16, 32)
		if err != nil {
			return nil // ignore error, skip line and keep going
			continue // ignore error, skip line and keep going
		}
		ip := netaddr.IPv4(byte(ipu32), byte(ipu32>>8), byte(ipu32>>16), byte(ipu32>>24))
		if ip.IsPrivate() {
			ret = ip
			return errStopReading
			break
		}
		return nil
	})
	if errors.Is(err, errStopReading) {
		err = nil
	}
	if err != nil {
		procNetRouteErr.Store(true)
		return likelyHomeRouterIP()
	}
	if ret.IsValid() {
		// Try to get the local IP of the interface associated with


@@ 144,23 136,26 @@ func likelyHomeRouterIPHelper() (ret netip.Addr, _ netip.Addr, ok bool) {
		return
	}
	// Search for line like "default via 10.0.2.2 dev radio0 table 1016 proto static mtu 1500 "
	lineread.Reader(out, func(line []byte) error {
	for lr := range lineiter.Reader(out) {
		line, err := lr.Value()
		if err != nil {
			break
		}
		const pfx = "default via "
		if !mem.HasPrefix(mem.B(line), mem.S(pfx)) {
			return nil
			continue
		}
		line = line[len(pfx):]
		sp := bytes.IndexByte(line, ' ')
		if sp == -1 {
			return nil
			continue
		}
		ipb := line[:sp]
		if ip, err := netip.ParseAddr(string(ipb)); err == nil && ip.Is4() {
			ret = ip
			log.Printf("interfaces: found Android default route %v", ip)
		}
		return nil
	})
	}
	cmd.Process.Kill()
	cmd.Wait()
	return ret, netip.Addr{}, ret.IsValid()

M net/netmon/interfaces_darwin_test.go => net/netmon/interfaces_darwin_test.go +12 -12
@@ 4,14 4,13 @@
package netmon

import (
	"errors"
	"io"
	"net/netip"
	"os/exec"
	"testing"

	"go4.org/mem"
	"tailscale.com/util/lineread"
	"tailscale.com/util/lineiter"
	"tailscale.com/version"
)



@@ 73,31 72,34 @@ func likelyHomeRouterIPDarwinExec() (ret netip.Addr, netif string, ok bool) {
	defer io.Copy(io.Discard, stdout) // clear the pipe to prevent hangs

	var f []mem.RO
	lineread.Reader(stdout, func(lineb []byte) error {
	for lr := range lineiter.Reader(stdout) {
		lineb, err := lr.Value()
		if err != nil {
			break
		}
		line := mem.B(lineb)
		if !mem.Contains(line, mem.S("default")) {
			return nil
			continue
		}
		f = mem.AppendFields(f[:0], line)
		if len(f) < 4 || !f[0].EqualString("default") {
			return nil
			continue
		}
		ipm, flagsm, netifm := f[1], f[2], f[3]
		if !mem.Contains(flagsm, mem.S("G")) {
			return nil
			continue
		}
		if mem.Contains(flagsm, mem.S("I")) {
			return nil
			continue
		}
		ip, err := netip.ParseAddr(string(mem.Append(nil, ipm)))
		if err == nil && ip.IsPrivate() {
			ret = ip
			netif = netifm.StringCopy()
			// We've found what we're looking for.
			return errStopReadingNetstatTable
			break
		}
		return nil
	})
	}
	return ret, netif, ret.IsValid()
}



@@ 110,5 112,3 @@ func TestFetchRoutingTable(t *testing.T) {
		}
	}
}

var errStopReadingNetstatTable = errors.New("found private gateway")

M net/netmon/interfaces_linux.go => net/netmon/interfaces_linux.go +15 -22
@@ 23,7 23,7 @@ import (
	"go4.org/mem"
	"golang.org/x/sys/unix"
	"tailscale.com/net/netaddr"
	"tailscale.com/util/lineread"
	"tailscale.com/util/lineiter"
)

func init() {


@@ 32,11 32,6 @@ func init() {

var procNetRouteErr atomic.Bool

// errStopReading is a sentinel error value used internally by
// lineread.File callers to stop reading. It doesn't escape to
// callers/users.
var errStopReading = errors.New("stop reading")

/*
Parse 10.0.0.1 out of:



@@ 52,44 47,42 @@ func likelyHomeRouterIPLinux() (ret netip.Addr, myIP netip.Addr, ok bool) {
	}
	lineNum := 0
	var f []mem.RO
	err := lineread.File(procNetRoutePath, func(line []byte) error {
	for lr := range lineiter.File(procNetRoutePath) {
		line, err := lr.Value()
		if err != nil {
			procNetRouteErr.Store(true)
			log.Printf("interfaces: failed to read /proc/net/route: %v", err)
			return ret, myIP, false
		}
		lineNum++
		if lineNum == 1 {
			// Skip header line.
			return nil
			continue
		}
		if lineNum > maxProcNetRouteRead {
			return errStopReading
			break
		}
		f = mem.AppendFields(f[:0], mem.B(line))
		if len(f) < 4 {
			return nil
			continue
		}
		gwHex, flagsHex := f[2], f[3]
		flags, err := mem.ParseUint(flagsHex, 16, 16)
		if err != nil {
			return nil // ignore error, skip line and keep going
			continue // ignore error, skip line and keep going
		}
		if flags&(unix.RTF_UP|unix.RTF_GATEWAY) != unix.RTF_UP|unix.RTF_GATEWAY {
			return nil
			continue
		}
		ipu32, err := mem.ParseUint(gwHex, 16, 32)
		if err != nil {
			return nil // ignore error, skip line and keep going
			continue // ignore error, skip line and keep going
		}
		ip := netaddr.IPv4(byte(ipu32), byte(ipu32>>8), byte(ipu32>>16), byte(ipu32>>24))
		if ip.IsPrivate() {
			ret = ip
			return errStopReading
			break
		}
		return nil
	})
	if errors.Is(err, errStopReading) {
		err = nil
	}
	if err != nil {
		procNetRouteErr.Store(true)
		log.Printf("interfaces: failed to read /proc/net/route: %v", err)
	}
	if ret.IsValid() {
		// Try to get the local IP of the interface associated with

M net/netmon/netmon_linux_test.go => net/netmon/netmon_linux_test.go +2 -0
@@ 1,6 1,8 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause

//go:build linux && !android

package netmon

import (

M net/tshttpproxy/tshttpproxy_synology.go => net/tshttpproxy/tshttpproxy_synology.go +8 -7
@@ 17,7 17,7 @@ import (
	"sync"
	"time"

	"tailscale.com/util/lineread"
	"tailscale.com/util/lineiter"
)

// These vars are overridden for tests.


@@ 76,21 76,22 @@ func synologyProxiesFromConfig() (*url.URL, *url.URL, error) {
func parseSynologyConfig(r io.Reader) (*url.URL, *url.URL, error) {
	cfg := map[string]string{}

	if err := lineread.Reader(r, func(line []byte) error {
	for lr := range lineiter.Reader(r) {
		line, err := lr.Value()
		if err != nil {
			return nil, nil, err
		}
		// accept and skip over empty lines
		line = bytes.TrimSpace(line)
		if len(line) == 0 {
			return nil
			continue
		}

		key, value, ok := strings.Cut(string(line), "=")
		if !ok {
			return fmt.Errorf("missing \"=\" in proxy.conf line: %q", line)
			return nil, nil, fmt.Errorf("missing \"=\" in proxy.conf line: %q", line)
		}
		cfg[string(key)] = string(value)
		return nil
	}); err != nil {
		return nil, nil, err
	}

	if cfg["proxy_enabled"] != "yes" {

M ssh/tailssh/tailssh_test.go => ssh/tailssh/tailssh_test.go +5 -8
@@ 48,7 48,7 @@ import (
	"tailscale.com/types/netmap"
	"tailscale.com/types/ptr"
	"tailscale.com/util/cibuild"
	"tailscale.com/util/lineread"
	"tailscale.com/util/lineiter"
	"tailscale.com/util/must"
	"tailscale.com/version/distro"
	"tailscale.com/wgengine"


@@ 1123,14 1123,11 @@ func TestSSH(t *testing.T) {

func parseEnv(out []byte) map[string]string {
	e := map[string]string{}
	lineread.Reader(bytes.NewReader(out), func(line []byte) error {
		i := bytes.IndexByte(line, '=')
		if i == -1 {
			return nil
	for line := range lineiter.Bytes(out) {
		if i := bytes.IndexByte(line, '='); i != -1 {
			e[string(line[:i])] = string(line[i+1:])
		}
		e[string(line[:i])] = string(line[i+1:])
		return nil
	})
	}
	return e
}


M ssh/tailssh/user.go => ssh/tailssh/user.go +9 -9
@@ 6,7 6,6 @@
package tailssh

import (
	"io"
	"os"
	"os/exec"
	"os/user"


@@ 18,7 17,7 @@ import (
	"go4.org/mem"
	"tailscale.com/envknob"
	"tailscale.com/hostinfo"
	"tailscale.com/util/lineread"
	"tailscale.com/util/lineiter"
	"tailscale.com/util/osuser"
	"tailscale.com/version/distro"
)


@@ 110,15 109,16 @@ func defaultPathForUser(u *user.User) string {
}

func defaultPathForUserOnNixOS(u *user.User) string {
	var path string
	lineread.File("/etc/pam/environment", func(lineb []byte) error {
	for lr := range lineiter.File("/etc/pam/environment") {
		lineb, err := lr.Value()
		if err != nil {
			return ""
		}
		if v := pathFromPAMEnvLine(lineb, u); v != "" {
			path = v
			return io.EOF // stop iteration
			return v
		}
		return nil
	})
	return path
	}
	return ""
}

func pathFromPAMEnvLine(line []byte, u *user.User) (path string) {

A types/result/result.go => types/result/result.go +49 -0
@@ 0,0 1,49 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause

// Package result contains the Of result type, which is
// either a value or an error.
package result

// Of is either a T value or an error.
//
// Think of it like Rust or Swift's result types.
// It's named "Of" because the fully qualified name
// for callers reads result.Of[T].
type Of[T any] struct {
	v   T // valid if Err is nil; invalid if Err is non-nil
	err error
}

// Value returns a new result with value v,
// without an error.
func Value[T any](v T) Of[T] {
	return Of[T]{v: v}
}

// Error returns a new result with error err.
// If err is nil, the returned result is equivalent
// to calling Value with T's zero value.
func Error[T any](err error) Of[T] {
	return Of[T]{err: err}
}

// MustValue returns r's result value.
// It panics if r.Err returns non-nil.
func (r Of[T]) MustValue() T {
	if r.err != nil {
		panic(r.err)
	}
	return r.v
}

// Value returns r's result value and error.
func (r Of[T]) Value() (T, error) {
	return r.v, r.err
}

// Err returns r's error, if any.
// When r.Err returns nil, it's safe to call r.MustValue without it panicking.
func (r Of[T]) Err() error {
	return r.err
}

A util/lineiter/lineiter.go => util/lineiter/lineiter.go +72 -0
@@ 0,0 1,72 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause

// Package lineiter iterates over lines in things.
package lineiter

import (
	"bufio"
	"bytes"
	"io"
	"iter"
	"os"

	"tailscale.com/types/result"
)

// File returns an iterator that reads lines from the named file.
//
// The returned substrings don't include the trailing newline.
// Lines may be empty.
func File(name string) iter.Seq[result.Of[[]byte]] {
	f, err := os.Open(name)
	return reader(f, f, err)
}

// Bytes returns an iterator over the lines in bs.
// The returned substrings don't include the trailing newline.
// Lines may be empty.
func Bytes(bs []byte) iter.Seq[[]byte] {
	return func(yield func([]byte) bool) {
		for len(bs) > 0 {
			i := bytes.IndexByte(bs, '\n')
			if i < 0 {
				yield(bs)
				return
			}
			if !yield(bs[:i]) {
				return
			}
			bs = bs[i+1:]
		}
	}
}

// Reader returns an iterator over the lines in r.
//
// The returned substrings don't include the trailing newline.
// Lines may be empty.
func Reader(r io.Reader) iter.Seq[result.Of[[]byte]] {
	return reader(r, nil, nil)
}

func reader(r io.Reader, c io.Closer, err error) iter.Seq[result.Of[[]byte]] {
	return func(yield func(result.Of[[]byte]) bool) {
		if err != nil {
			yield(result.Error[[]byte](err))
			return
		}
		if c != nil {
			defer c.Close()
		}
		bs := bufio.NewScanner(r)
		for bs.Scan() {
			if !yield(result.Value(bs.Bytes())) {
				return
			}
		}
		if err := bs.Err(); err != nil {
			yield(result.Error[[]byte](err))
		}
	}
}

A util/lineiter/lineiter_test.go => util/lineiter/lineiter_test.go +32 -0
@@ 0,0 1,32 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause

package lineiter

import (
	"slices"
	"strings"
	"testing"
)

func TestBytesLines(t *testing.T) {
	var got []string
	for line := range Bytes([]byte("foo\n\nbar\nbaz")) {
		got = append(got, string(line))
	}
	want := []string{"foo", "", "bar", "baz"}
	if !slices.Equal(got, want) {
		t.Errorf("got %q; want %q", got, want)
	}
}

func TestReader(t *testing.T) {
	var got []string
	for line := range Reader(strings.NewReader("foo\n\nbar\nbaz")) {
		got = append(got, string(line.MustValue()))
	}
	want := []string{"foo", "", "bar", "baz"}
	if !slices.Equal(got, want) {
		t.Errorf("got %q; want %q", got, want)
	}
}

M util/pidowner/pidowner_linux.go => util/pidowner/pidowner_linux.go +10 -10
@@ 8,26 8,26 @@ import (
	"os"
	"strings"

	"tailscale.com/util/lineread"
	"tailscale.com/util/lineiter"
)

func ownerOfPID(pid int) (userID string, err error) {
	file := fmt.Sprintf("/proc/%d/status", pid)
	err = lineread.File(file, func(line []byte) error {
	for lr := range lineiter.File(file) {
		line, err := lr.Value()
		if err != nil {
			if os.IsNotExist(err) {
				return "", ErrProcessNotFound
			}
			return "", err
		}
		if len(line) < 4 || string(line[:4]) != "Uid:" {
			return nil
			continue
		}
		f := strings.Fields(string(line))
		if len(f) >= 2 {
			userID = f[1] // real userid
		}
		return nil
	})
	if os.IsNotExist(err) {
		return "", ErrProcessNotFound
	}
	if err != nil {
		return
	}
	if userID == "" {
		return "", fmt.Errorf("missing Uid line in %s", file)

M version/distro/distro.go => version/distro/distro.go +10 -10
@@ 6,13 6,12 @@ package distro

import (
	"bytes"
	"io"
	"os"
	"runtime"
	"strconv"

	"tailscale.com/types/lazy"
	"tailscale.com/util/lineread"
	"tailscale.com/util/lineiter"
)

type Distro string


@@ 132,18 131,19 @@ func DSMVersion() int {
			return v
		}
		// But when run from the command line, we have to read it from the file:
		lineread.File("/etc/VERSION", func(line []byte) error {
		for lr := range lineiter.File("/etc/VERSION") {
			line, err := lr.Value()
			if err != nil {
				break // but otherwise ignore
			}
			line = bytes.TrimSpace(line)
			if string(line) == `majorversion="7"` {
				v = 7
				return io.EOF
				return 7
			}
			if string(line) == `majorversion="6"` {
				v = 6
				return io.EOF
				return 6
			}
			return nil
		})
		return v
		}
		return 0
	})
}