Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05c0396f6b | ||
|
|
c36677b9e5 |
@@ -0,0 +1,52 @@
|
|||||||
|
# mesh-reads
|
||||||
|
|
||||||
|
A node's files and accounts, read through the mesh (novox/hq ADR 0310, extending ADR 0268 §5; hq to-be 41
|
||||||
|
WP8; issue 418). It holds the node seat `node-reads` on every node, the control-node included, so reading a
|
||||||
|
file, searching one, listing a directory, the owner and mode of each component of a path and looking up an
|
||||||
|
account or a group never go through the login shell's `execute`, and an approval is asked for an act, never
|
||||||
|
for a read.
|
||||||
|
|
||||||
|
## Who it reads as
|
||||||
|
|
||||||
|
The account `mesh-reads`, which this module makes on every node (`runs-as`, ADR 0259 §8): no login shell, a
|
||||||
|
home of its own under `/var/lib`, and no group beside its own. The tool runner serves it in a runtime of its
|
||||||
|
own, on its own bus credential, never the machine's runtime, which runs as the operator's account. So the
|
||||||
|
node's file modes bound what it sees: a file only the operator, root or a module's own account may read is
|
||||||
|
refused by its mode, and the answer says so. It refuses every read when it finds itself running as root.
|
||||||
|
|
||||||
|
## What it refuses by name
|
||||||
|
|
||||||
|
Before any read, whatever the file modes would allow (ADR 0310 §5):
|
||||||
|
|
||||||
|
- **The places the mesh keeps a secret on this node**, which the controller composes from the manifests
|
||||||
|
and gives it as `MESH_SECRET_PLACES`: the directory of every module's own secret (its bus credential
|
||||||
|
among them), every credential file and grant directory, the vault's directories, and where the
|
||||||
|
operator-sealed secrets are kept. Without that list the module does not start.
|
||||||
|
- **The known credential files of a machine's programs** (`refuse.go`): an account's `.ssh`, `.gnupg`,
|
||||||
|
`.aws`, `.kube`; the agent's `.credentials.json`, `.netrc`, `.pgpass`, `.git-credentials`; private key
|
||||||
|
names (`id_ed25519`, `*.key`, `ssh_host_*_key`); `/etc/shadow`; `/etc/ssl/private`, `/etc/wireguard`
|
||||||
|
and the network manager's connections.
|
||||||
|
|
||||||
|
A link is judged by where it leads as well as by its name. `stat` and `readlink` say a secret place's owner
|
||||||
|
and mode, and mark it, because those are not its content; `list`, `read` and `search` refuse it.
|
||||||
|
|
||||||
|
## The content rule
|
||||||
|
|
||||||
|
Every answer passes the content rule's secret class, from `mesh-sdk`'s `outward` package (ADR 0310 §4): a
|
||||||
|
line of a file or a matching line shaped like a secret is withheld and its number said, as is a link target
|
||||||
|
or an account's words. A path and an entry's name are not judged: they are what the caller named or what the
|
||||||
|
file system calls a thing, and the rule would take a temporary directory's or a container layer's name for a
|
||||||
|
token.
|
||||||
|
|
||||||
|
## Its verbs and their bounds
|
||||||
|
|
||||||
|
| verb | replaces | bounds |
|
||||||
|
|---|---|---|
|
||||||
|
| `stat` | `stat`, `ls -ld`, `namei -l`, `findmnt -T` | 200 paths, 64 components each; links followed only with `follow` |
|
||||||
|
| `readlink` | `readlink`, `readlink -f`, `realpath` | one path |
|
||||||
|
| `list` | `ls`, `ls -la` | 2000 entries, cut and said |
|
||||||
|
| `read` | `cat`, `head`, `tail`, `less` | 64 KiB, or up to 256 KiB when asked; cut at a line and said; text only |
|
||||||
|
| `search` | `grep`, `grep -r`, `grep -F` | a fixed string; 100 matches (500 asked), 4 deep (8 asked), 5000 files, 4 MiB a file, 20 seconds; the bound met is said |
|
||||||
|
| `account` | `getent passwd`, `getent group`, `id`, `groups` | 2000 accounts and groups; never a password field |
|
||||||
|
|
||||||
|
No answer is larger than 1 MiB. Nothing here writes a file.
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
// mesh-reads (novox/hq ADR 0310, to-be 41 WP8): the holder of node-reads. A node's files and accounts read
|
||||||
|
// through the mesh — the owner and mode of each component of a path, a link, a directory, a text file, a search
|
||||||
|
// by fixed string, an account or a group — so these reads never go through the login shell's execute.
|
||||||
|
//
|
||||||
|
// It runs as an account of its own, mesh-reads, served by the tool runner in a runtime of its own (ADR 0259
|
||||||
|
// §8): in no group that holds a secret, with no login shell and no home of the operator's, so the node's file
|
||||||
|
// modes bound what it sees. On top, it refuses by name the places a secret is kept (refuse.go) and withholds
|
||||||
|
// any piece of an answer shaped like a secret (mesh-sdk's outward). Read-only: nothing here writes a file.
|
||||||
|
//
|
||||||
|
// stdout is the MCP channel; what this module says, it says on stderr.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Seat is the role this module holds.
|
||||||
|
const Seat = "node-reads"
|
||||||
|
|
||||||
|
// PlacesWord is where the controller names this node's secret places (novox/hq ADR 0310 §5).
|
||||||
|
const PlacesWord = "MESH_SECRET_PLACES"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
places, err := placesFrom(os.Getenv(PlacesWord))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "[mesh-reads] %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if err := stdio.Serve("", tools(newReader(places))); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "[mesh-reads] %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// placesFrom reads the controller's list. Absent is refused: without it, a module's own-secret directory
|
||||||
|
// would be read wherever the account's modes allow, and a refusal the decision promises would be silent.
|
||||||
|
func placesFrom(raw string) ([]string, error) {
|
||||||
|
if strings.TrimSpace(raw) == "" {
|
||||||
|
return nil, fmt.Errorf("%s is not given, so this node's secret places are unknown and nothing would be "+
|
||||||
|
"refused by them (novox/hq ADR 0310 §5): a controller that knows node-reads composes it", PlacesWord)
|
||||||
|
}
|
||||||
|
var places []string
|
||||||
|
if err := json.Unmarshal([]byte(raw), &places); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s is not a list of paths: %v", PlacesWord, err)
|
||||||
|
}
|
||||||
|
return places, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func str(description string) map[string]any {
|
||||||
|
return map[string]any{"type": "string", "description": description}
|
||||||
|
}
|
||||||
|
|
||||||
|
func arg(a map[string]any, k string) string {
|
||||||
|
switch v := a[k].(type) {
|
||||||
|
case string:
|
||||||
|
return strings.TrimSpace(v)
|
||||||
|
case float64:
|
||||||
|
return strconv.FormatFloat(v, 'f', -1, 64)
|
||||||
|
case bool:
|
||||||
|
return strconv.FormatBool(v)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func number(a map[string]any, k string) (int, error) {
|
||||||
|
s := arg(a, k)
|
||||||
|
if s == "" {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(s)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("%s is a whole number, and %q was given", k, s)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pathsArg is the paths given, one per line — or, from a caller that sends a list, the list.
|
||||||
|
func pathsArg(a map[string]any) []string {
|
||||||
|
var raw []string
|
||||||
|
switch v := a["paths"].(type) {
|
||||||
|
case string:
|
||||||
|
raw = strings.Split(v, "\n")
|
||||||
|
case []any:
|
||||||
|
for _, p := range v {
|
||||||
|
if s, ok := p.(string); ok {
|
||||||
|
raw = append(raw, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, p := range raw {
|
||||||
|
if p = strings.TrimSpace(p); p != "" {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// bounded is the last bound (novox/hq ADR 0310): whatever a verb made, no answer is larger than MaxAnswer.
|
||||||
|
func bounded(answer any, err error) (any, error) {
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(answer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(body) > MaxAnswer {
|
||||||
|
return nil, fmt.Errorf("the answer is %d bytes, and an answer of node-reads is at most %d: ask for less", len(body), MaxAnswer)
|
||||||
|
}
|
||||||
|
return answer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// verb is one of the seat's verbs: listed as `<seat>.<verb>`, so the runtime serves it on the seat's subject.
|
||||||
|
// The descriptions are the controller's (internal/catalogue/reads.go), said again here for a caller of the
|
||||||
|
// module's own address.
|
||||||
|
func verb(name, description string, input map[string]any, run func(a map[string]any) (any, error)) stdio.Tool {
|
||||||
|
return stdio.Tool{Name: Seat + "." + name, Description: description, Input: input,
|
||||||
|
Run: func(a map[string]any) (any, error) { return bounded(run(a)) }}
|
||||||
|
}
|
||||||
|
|
||||||
|
func object(props map[string]any, required ...string) map[string]any {
|
||||||
|
if required == nil {
|
||||||
|
required = []string{}
|
||||||
|
}
|
||||||
|
return map[string]any{"type": "object", "properties": props, "required": required}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tools(r Reader) []stdio.Tool {
|
||||||
|
return []stdio.Tool{
|
||||||
|
verb("stat", "Each component of one path or several, from / down: its type, owner and group (name and "+
|
||||||
|
"number), mode, whether it is a link and to what, and whether it is a mount point and of which file "+
|
||||||
|
"system. A link is not followed unless follow is \"true\". Read as the account mesh-reads, which holds no "+
|
||||||
|
"secret: a component it may not reach says so. At most 200 paths in one call.",
|
||||||
|
object(map[string]any{"paths": str("one absolute path, or several separated by line breaks"),
|
||||||
|
"follow": str("\"true\": also say where the path leads once every link is followed")}, "paths"),
|
||||||
|
func(a map[string]any) (any, error) { return r.Stat(pathsArg(a), arg(a, "follow") == "true") }),
|
||||||
|
verb("readlink", "What one link points to, as written, and where the path leads once every link is "+
|
||||||
|
"followed, and whether that exists.",
|
||||||
|
object(map[string]any{"path": str("an absolute path")}, "path"),
|
||||||
|
func(a map[string]any) (any, error) { return r.Readlink(arg(a, "path")) }),
|
||||||
|
verb("list", "Every entry of one directory with its type, owner, group, mode, size and, for a link, its "+
|
||||||
|
"target. At most 2000 entries; a longer directory is cut and says so. A place a secret is kept is "+
|
||||||
|
"refused by name.",
|
||||||
|
object(map[string]any{"path": str("an absolute path to a directory")}, "path"),
|
||||||
|
func(a map[string]any) (any, error) { return r.List(arg(a, "path")) }),
|
||||||
|
verb("read", "One text file, or a part of it by line: at most 256 KiB in one answer (64 KiB unless more "+
|
||||||
|
"is asked), cut at a line and said so. A negative from counts from the end, as tail does. A binary file "+
|
||||||
|
"is refused; a place a secret is kept, or a known credential file, is refused by name; a line shaped "+
|
||||||
|
"like a secret is withheld and its number said. /proc and /sys are read as far as mesh-reads may.",
|
||||||
|
object(map[string]any{"path": str("an absolute path to a file"),
|
||||||
|
"from": str("the first line to answer, counted from 1; negative: that many lines from the end (optional)"),
|
||||||
|
"lines": str("how many lines to answer (optional)"),
|
||||||
|
"bytes": str("the most bytes to answer, up to 262144 (optional, 65536 unless given)")}, "path"),
|
||||||
|
func(a map[string]any) (any, error) {
|
||||||
|
var ask ReadAsk
|
||||||
|
var err error
|
||||||
|
if ask.From, err = number(a, "from"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if ask.Lines, err = number(a, "lines"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if ask.Bytes, err = number(a, "bytes"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return r.Read(arg(a, "path"), ask)
|
||||||
|
}),
|
||||||
|
verb("search", "Find a fixed string (never a pattern) in one file or under one directory: every matching "+
|
||||||
|
"line with its file and number. Bounded: at most 500 matches (100 unless more is asked), 8 directories "+
|
||||||
|
"deep (4 unless more is asked), 5000 files, 4 MiB per file and 20 seconds; links are not followed and "+
|
||||||
|
"binary files are skipped. A place a secret is kept is skipped and named; a matching line shaped like "+
|
||||||
|
"a secret is withheld. The answer says which bound it met.",
|
||||||
|
object(map[string]any{"text": str("the fixed string to find"),
|
||||||
|
"path": str("an absolute path to a file or a directory"),
|
||||||
|
"depth": str("how many directories deep, up to 8 (optional)"),
|
||||||
|
"matches": str("the most matches to answer, up to 500 (optional)"),
|
||||||
|
"case": str("\"false\": match whatever the case (optional)")}, "text", "path"),
|
||||||
|
func(a map[string]any) (any, error) {
|
||||||
|
text, _ := a["text"].(string) // as given: a space at either end is part of what is found
|
||||||
|
ask := SearchAsk{Text: text, Path: arg(a, "path"), CaseInsensitive: arg(a, "case") == "false"}
|
||||||
|
var err error
|
||||||
|
if ask.Depth, err = number(a, "depth"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if ask.Matches, err = number(a, "matches"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return r.Search(ask)
|
||||||
|
}),
|
||||||
|
verb("account", "One account or group by name or number, as this node's account files say: an account's "+
|
||||||
|
"number, group, home, shell and every group it is in; a group's number and members. With neither, every "+
|
||||||
|
"account and group. Never a password field.",
|
||||||
|
object(map[string]any{"name": str("an account's name or number (optional)"),
|
||||||
|
"group": str("a group's name or number (optional)")}),
|
||||||
|
func(a map[string]any) (any, error) { return r.Account(arg(a, "name"), arg(a, "group")) }),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ADR 0310 §3: the account the reads run as is the module's own, made by the mesh, with no login shell, no
|
||||||
|
// home of the operator's and no group beside its own, and the module runs as it.
|
||||||
|
func TestTheAccountHoldsNoSecretsGroupAndNoLogin(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile("../../module.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var m struct {
|
||||||
|
RunsAs string `json:"runs-as"`
|
||||||
|
SecretsOwner string `json:"secrets-owner"`
|
||||||
|
Claims []map[string]any `json:"claims"`
|
||||||
|
Resources []map[string]any `json:"resources"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if m.RunsAs != "mesh-reads" || m.SecretsOwner != "mesh-reads" {
|
||||||
|
t.Errorf("runs as %q, secrets owned by %q", m.RunsAs, m.SecretsOwner)
|
||||||
|
}
|
||||||
|
if len(m.Claims) != 1 || m.Claims[0]["name"] != Seat {
|
||||||
|
t.Errorf("claims %v", m.Claims)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if r["type"] != "user" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
if r["name"] != m.RunsAs {
|
||||||
|
t.Errorf("a user other than its own: %v", r)
|
||||||
|
}
|
||||||
|
if r["shell"] != "/usr/bin/nologin" {
|
||||||
|
t.Errorf("the account has a login shell: %v", r["shell"])
|
||||||
|
}
|
||||||
|
if home, _ := r["home"].(string); !strings.HasPrefix(home, "/var/lib/") {
|
||||||
|
t.Errorf("the account's home is %q, not one of the mesh's", home)
|
||||||
|
}
|
||||||
|
if _, grouped := r["groups"]; grouped {
|
||||||
|
t.Errorf("the account is put in groups: %v", r["groups"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("the module makes no account of its own")
|
||||||
|
}
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if r["type"] != "user" && r["type"] != "directory" {
|
||||||
|
t.Errorf("a read-only holder declares a %v: %v", r["type"], r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,926 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The bounds (novox/hq ADR 0310, Consequences: set by the build and stated in each verb's description). Every
|
||||||
|
// answer stays under MaxAnswer, which the tools check once more after an answer is made.
|
||||||
|
const (
|
||||||
|
MaxAnswer = 1 << 20 // no answer of this module is larger, whatever the bounds below allow
|
||||||
|
MaxPaths = 200 // paths one stat answers
|
||||||
|
MaxComponents = 64 // components of one path
|
||||||
|
MaxEntries = 2000 // entries one list answers
|
||||||
|
DefaultReadSize = 64 << 10 // bytes a read answers unless more is asked
|
||||||
|
MaxReadSize = 256 << 10 // bytes a read answers at most
|
||||||
|
MaxScan = 64 << 20 // bytes a read passes over to reach the lines asked
|
||||||
|
DefaultMatches = 100
|
||||||
|
MaxMatches = 500
|
||||||
|
DefaultDepth = 4
|
||||||
|
MaxDepth = 8
|
||||||
|
MaxFiles = 5000 // files one search opens
|
||||||
|
MaxFileScan = 4 << 20 // bytes one search reads of one file
|
||||||
|
MaxLine = 300 // characters of one matching line answered
|
||||||
|
SearchTime = 20 * time.Second
|
||||||
|
MaxAccounts = 2000
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reader reads one node's files and accounts as the account it runs as.
|
||||||
|
type Reader struct {
|
||||||
|
Refuser
|
||||||
|
// Passwd, Group and MountInfo are the files accounts, groups and mount points are read from.
|
||||||
|
Passwd, Group, MountInfo string
|
||||||
|
// Euid is the account this runs as; never root (ADR 0310 §3).
|
||||||
|
Euid func() int
|
||||||
|
// Now is the clock a search is bounded by.
|
||||||
|
Now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newReader(places []string) Reader {
|
||||||
|
return Reader{Refuser: Refuser{Places: places}, Passwd: "/etc/passwd", Group: "/etc/group",
|
||||||
|
MountInfo: "/proc/self/mountinfo", Euid: os.Geteuid, Now: time.Now}
|
||||||
|
}
|
||||||
|
|
||||||
|
// asItsOwn refuses every read when this runs as root: what it may see is bounded by its account's file
|
||||||
|
// modes, and root's are none (novox/hq ADR 0310 §3). Loud, never a silent widening.
|
||||||
|
func (r Reader) asItsOwn() error {
|
||||||
|
if r.Euid() == 0 {
|
||||||
|
return errors.New("mesh-reads runs as root here, and its reads are bounded by an account of its own that " +
|
||||||
|
"holds no secret (novox/hq ADR 0310 §3): it answers nothing until it runs as mesh-reads")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func absolute(path string) (string, error) {
|
||||||
|
path = strings.TrimSpace(path)
|
||||||
|
if !strings.HasPrefix(path, "/") {
|
||||||
|
return "", fmt.Errorf("%q is not an absolute path: name it from /", path)
|
||||||
|
}
|
||||||
|
return filepath.Clean(path), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// why turns a file error into the words an answer says: what stopped the read, never a Go error's text.
|
||||||
|
func why(err error) string {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, fs.ErrNotExist):
|
||||||
|
return "it does not exist"
|
||||||
|
case errors.Is(err, fs.ErrPermission):
|
||||||
|
return "the account mesh-reads may not reach it (its file modes refuse it)"
|
||||||
|
case errors.Is(err, syscall.ELOOP):
|
||||||
|
return "its links loop"
|
||||||
|
case errors.Is(err, syscall.ENOTDIR):
|
||||||
|
return "a component of it is not a directory"
|
||||||
|
}
|
||||||
|
var pe *fs.PathError
|
||||||
|
if errors.As(err, &pe) {
|
||||||
|
return pe.Err.Error()
|
||||||
|
}
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- stat ----
|
||||||
|
|
||||||
|
// Component is one component of a path as lstat sees it.
|
||||||
|
type Component struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Owner string `json:"owner,omitempty"`
|
||||||
|
UID *int `json:"uid,omitempty"`
|
||||||
|
Group string `json:"group,omitempty"`
|
||||||
|
GID *int `json:"gid,omitempty"`
|
||||||
|
Mode string `json:"mode,omitempty"` // octal, with the setuid, setgid and sticky bits
|
||||||
|
Perms string `json:"perms,omitempty"` // as ls shows them
|
||||||
|
Size *int64 `json:"size,omitempty"`
|
||||||
|
Link string `json:"link,omitempty"` // what a link points to, as written
|
||||||
|
Mount *Mount `json:"mount,omitempty"`
|
||||||
|
Secret string `json:"secret-place,omitempty"` // why a read under it is refused
|
||||||
|
Unreachd string `json:"unreachable,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mount is a mount point: its file system and source.
|
||||||
|
type Mount struct {
|
||||||
|
FS string `json:"fs"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PathStat is one path's components, from / down, and where it leads once every link is followed.
|
||||||
|
type PathStat struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Components []Component `json:"components"`
|
||||||
|
LeadsTo string `json:"leads-to,omitempty"`
|
||||||
|
Leads *Component `json:"there,omitempty"`
|
||||||
|
Stopped string `json:"stopped,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatAnswer is what stat answers.
|
||||||
|
type StatAnswer struct {
|
||||||
|
Paths []PathStat `json:"paths"`
|
||||||
|
Withheld []Withheld `json:"withheld,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Reader) Stat(paths []string, follow bool) (StatAnswer, error) {
|
||||||
|
if err := r.asItsOwn(); err != nil {
|
||||||
|
return StatAnswer{}, err
|
||||||
|
}
|
||||||
|
if len(paths) == 0 {
|
||||||
|
return StatAnswer{}, errors.New("name a path to stat")
|
||||||
|
}
|
||||||
|
if len(paths) > MaxPaths {
|
||||||
|
return StatAnswer{}, fmt.Errorf("%d paths asked, and one stat answers at most %d: ask in parts", len(paths), MaxPaths)
|
||||||
|
}
|
||||||
|
names := r.names()
|
||||||
|
mounts := r.mounts()
|
||||||
|
var out StatAnswer
|
||||||
|
for _, raw := range paths {
|
||||||
|
path, err := absolute(raw)
|
||||||
|
if err != nil {
|
||||||
|
return StatAnswer{}, err
|
||||||
|
}
|
||||||
|
ps := PathStat{Path: path}
|
||||||
|
walk := []string{"/"}
|
||||||
|
acc := ""
|
||||||
|
for _, part := range strings.Split(strings.TrimPrefix(path, "/"), "/") {
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
acc += "/" + part
|
||||||
|
walk = append(walk, acc)
|
||||||
|
}
|
||||||
|
if len(walk) > MaxComponents {
|
||||||
|
return StatAnswer{}, fmt.Errorf("%s has %d components, and a stat answers at most %d", path, len(walk), MaxComponents)
|
||||||
|
}
|
||||||
|
for _, p := range walk {
|
||||||
|
c := r.component(p, names, mounts)
|
||||||
|
ps.Components = append(ps.Components, c)
|
||||||
|
if c.Unreachd != "" {
|
||||||
|
ps.Stopped = c.Path + ": " + c.Unreachd
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if follow && ps.Stopped == "" {
|
||||||
|
if leads, err := filepath.EvalSymlinks(path); err != nil {
|
||||||
|
ps.Stopped = "following its links: " + why(err)
|
||||||
|
} else {
|
||||||
|
ps.LeadsTo = leads
|
||||||
|
if leads != path {
|
||||||
|
c := r.component(leads, names, mounts)
|
||||||
|
ps.Leads = &c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.Paths = append(out.Paths, ps)
|
||||||
|
}
|
||||||
|
out.Withheld = cleanStat(&out)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Reader) component(p string, names accounts, mounts map[string]Mount) Component {
|
||||||
|
c := Component{Path: p}
|
||||||
|
info, err := os.Lstat(p)
|
||||||
|
if err != nil {
|
||||||
|
c.Unreachd = why(err)
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
c.Type = typeOf(info.Mode())
|
||||||
|
c.Perms = info.Mode().String()
|
||||||
|
if st, ok := info.Sys().(*syscall.Stat_t); ok {
|
||||||
|
uid, gid := int(st.Uid), int(st.Gid)
|
||||||
|
c.UID, c.GID = &uid, &gid
|
||||||
|
c.Owner, c.Group = names.user(uid), names.group(gid)
|
||||||
|
c.Mode = fmt.Sprintf("%04o", st.Mode&07777)
|
||||||
|
}
|
||||||
|
if info.Mode().IsRegular() {
|
||||||
|
size := info.Size()
|
||||||
|
c.Size = &size
|
||||||
|
}
|
||||||
|
if info.Mode()&fs.ModeSymlink != 0 {
|
||||||
|
if target, err := os.Readlink(p); err == nil {
|
||||||
|
c.Link = target
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m, ok := mounts[p]; ok {
|
||||||
|
c.Mount = &m
|
||||||
|
}
|
||||||
|
if why := r.Refused(p); why != "" {
|
||||||
|
c.Secret = why
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func typeOf(m fs.FileMode) string {
|
||||||
|
switch {
|
||||||
|
case m.IsDir():
|
||||||
|
return "directory"
|
||||||
|
case m&fs.ModeSymlink != 0:
|
||||||
|
return "link"
|
||||||
|
case m.IsRegular():
|
||||||
|
return "file"
|
||||||
|
case m&fs.ModeNamedPipe != 0:
|
||||||
|
return "fifo"
|
||||||
|
case m&fs.ModeSocket != 0:
|
||||||
|
return "socket"
|
||||||
|
case m&fs.ModeCharDevice != 0:
|
||||||
|
return "character device"
|
||||||
|
case m&fs.ModeDevice != 0:
|
||||||
|
return "block device"
|
||||||
|
}
|
||||||
|
return "other"
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanStat(a *StatAnswer) []Withheld {
|
||||||
|
var w []Withheld
|
||||||
|
cleanC := func(c *Component) {
|
||||||
|
for _, f := range []*string{&c.Link} {
|
||||||
|
if s, what := clean(*f); what != "" {
|
||||||
|
w = append(w, Withheld{At: "a link's target", Class: what})
|
||||||
|
*f = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.Mount != nil {
|
||||||
|
if s, what := clean(c.Mount.Source); what != "" {
|
||||||
|
w = append(w, Withheld{At: "a mount's source", Class: what})
|
||||||
|
c.Mount.Source = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range a.Paths {
|
||||||
|
p := &a.Paths[i]
|
||||||
|
for j := range p.Components {
|
||||||
|
cleanC(&p.Components[j])
|
||||||
|
}
|
||||||
|
if p.Leads != nil {
|
||||||
|
cleanC(p.Leads)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- readlink ----
|
||||||
|
|
||||||
|
// LinkAnswer is what readlink answers.
|
||||||
|
type LinkAnswer struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Link string `json:"link,omitempty"` // as written; empty when the path is not a link
|
||||||
|
IsLink bool `json:"is-link"`
|
||||||
|
LeadsTo string `json:"leads-to,omitempty"`
|
||||||
|
Exists bool `json:"exists"`
|
||||||
|
Stopped string `json:"stopped,omitempty"`
|
||||||
|
Withheld []Withheld `json:"withheld,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Reader) Readlink(raw string) (LinkAnswer, error) {
|
||||||
|
if err := r.asItsOwn(); err != nil {
|
||||||
|
return LinkAnswer{}, err
|
||||||
|
}
|
||||||
|
path, err := absolute(raw)
|
||||||
|
if err != nil {
|
||||||
|
return LinkAnswer{}, err
|
||||||
|
}
|
||||||
|
a := LinkAnswer{Path: path}
|
||||||
|
info, err := os.Lstat(path)
|
||||||
|
if err != nil {
|
||||||
|
a.Stopped = why(err)
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
if info.Mode()&fs.ModeSymlink != 0 {
|
||||||
|
a.IsLink = true
|
||||||
|
if a.Link, err = os.Readlink(path); err != nil {
|
||||||
|
a.Stopped = why(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if leads, err := filepath.EvalSymlinks(path); err == nil {
|
||||||
|
a.LeadsTo, a.Exists = leads, true
|
||||||
|
} else if a.Stopped == "" {
|
||||||
|
a.Stopped = "following its links: " + why(err)
|
||||||
|
}
|
||||||
|
for _, f := range []*string{&a.Link} {
|
||||||
|
if s, what := clean(*f); what != "" {
|
||||||
|
a.Withheld = append(a.Withheld, Withheld{At: "a link's target", Class: what})
|
||||||
|
*f = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- list ----
|
||||||
|
|
||||||
|
// Entry is one entry of a directory.
|
||||||
|
type Entry struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Owner string `json:"owner,omitempty"`
|
||||||
|
Group string `json:"group,omitempty"`
|
||||||
|
Mode string `json:"mode,omitempty"`
|
||||||
|
Size *int64 `json:"size,omitempty"`
|
||||||
|
Link string `json:"link,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAnswer is what list answers.
|
||||||
|
type ListAnswer struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Entries []Entry `json:"entries"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
Cut string `json:"cut,omitempty"`
|
||||||
|
Withheld []Withheld `json:"withheld,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// refusedRead is a refusal by name, before any read: the path asked and where it leads are both judged, so a
|
||||||
|
// link to a secret place is refused as the place is.
|
||||||
|
func (r Reader) refusedRead(path string) error {
|
||||||
|
if why := r.Refused(path); why != "" {
|
||||||
|
return fmt.Errorf("%s is refused by name: %s (novox/hq ADR 0310 §5)", path, why)
|
||||||
|
}
|
||||||
|
if leads, err := filepath.EvalSymlinks(path); err == nil && leads != path {
|
||||||
|
if why := r.Refused(leads); why != "" {
|
||||||
|
return fmt.Errorf("%s leads to %s, which is refused by name: %s (novox/hq ADR 0310 §5)", path, leads, why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Reader) List(raw string) (ListAnswer, error) {
|
||||||
|
if err := r.asItsOwn(); err != nil {
|
||||||
|
return ListAnswer{}, err
|
||||||
|
}
|
||||||
|
path, err := absolute(raw)
|
||||||
|
if err != nil {
|
||||||
|
return ListAnswer{}, err
|
||||||
|
}
|
||||||
|
if err := r.refusedRead(path); err != nil {
|
||||||
|
return ListAnswer{}, err
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(path)
|
||||||
|
if err != nil {
|
||||||
|
return ListAnswer{}, fmt.Errorf("%s cannot be listed: %s", path, why(err))
|
||||||
|
}
|
||||||
|
names := r.names()
|
||||||
|
a := ListAnswer{Path: path, Count: len(entries), Entries: []Entry{}}
|
||||||
|
if len(entries) > MaxEntries {
|
||||||
|
a.Cut = fmt.Sprintf("cut at %d of %d entries (the bound of one list), in name order", MaxEntries, len(entries))
|
||||||
|
entries = entries[:MaxEntries]
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
en := Entry{Name: e.Name(), Type: typeOf(e.Type())}
|
||||||
|
if info, err := e.Info(); err == nil {
|
||||||
|
en.Type = typeOf(info.Mode())
|
||||||
|
if st, ok := info.Sys().(*syscall.Stat_t); ok {
|
||||||
|
en.Owner, en.Group = names.user(int(st.Uid)), names.group(int(st.Gid))
|
||||||
|
en.Mode = fmt.Sprintf("%04o", st.Mode&07777)
|
||||||
|
}
|
||||||
|
if info.Mode().IsRegular() {
|
||||||
|
size := info.Size()
|
||||||
|
en.Size = &size
|
||||||
|
}
|
||||||
|
if info.Mode()&fs.ModeSymlink != 0 {
|
||||||
|
en.Link, _ = os.Readlink(filepath.Join(path, e.Name()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, f := range []*string{&en.Link} {
|
||||||
|
if s, what := clean(*f); what != "" {
|
||||||
|
a.Withheld = append(a.Withheld, Withheld{At: "a link's target", Class: what})
|
||||||
|
*f = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.Entries = append(a.Entries, en)
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- read ----
|
||||||
|
|
||||||
|
// ReadAnswer is what read answers.
|
||||||
|
type ReadAnswer struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Size int64 `json:"size"` // as the file says; 0 for most of /proc and /sys
|
||||||
|
From int `json:"from"` // the first line answered, counted from 1
|
||||||
|
Lines int `json:"lines"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Cut string `json:"cut,omitempty"`
|
||||||
|
Withheld []int `json:"withheld-lines,omitempty"`
|
||||||
|
Why string `json:"withheld-because,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadAsk is what a read asks: the lines from From (1 is the first; a negative From counts from the end, as
|
||||||
|
// tail does), at most Lines of them (0: as many as fit), and at most Bytes bytes.
|
||||||
|
type ReadAsk struct {
|
||||||
|
From, Lines, Bytes int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Reader) Read(raw string, ask ReadAsk) (ReadAnswer, error) {
|
||||||
|
if err := r.asItsOwn(); err != nil {
|
||||||
|
return ReadAnswer{}, err
|
||||||
|
}
|
||||||
|
path, err := absolute(raw)
|
||||||
|
if err != nil {
|
||||||
|
return ReadAnswer{}, err
|
||||||
|
}
|
||||||
|
if err := r.refusedRead(path); err != nil {
|
||||||
|
return ReadAnswer{}, err
|
||||||
|
}
|
||||||
|
bound := ask.Bytes
|
||||||
|
switch {
|
||||||
|
case bound <= 0:
|
||||||
|
bound = DefaultReadSize
|
||||||
|
case bound > MaxReadSize:
|
||||||
|
return ReadAnswer{}, fmt.Errorf("%d bytes asked, and one read answers at most %d", bound, MaxReadSize)
|
||||||
|
}
|
||||||
|
if ask.Lines < 0 {
|
||||||
|
return ReadAnswer{}, errors.New("lines is a count: name how many, or none for as many as fit")
|
||||||
|
}
|
||||||
|
// Non-blocking, so a fifo answers rather than waits; and judged by what was opened, not by its name.
|
||||||
|
f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0)
|
||||||
|
if err != nil {
|
||||||
|
return ReadAnswer{}, fmt.Errorf("%s cannot be read: %s", path, why(err))
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
info, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return ReadAnswer{}, fmt.Errorf("%s cannot be read: %s", path, why(err))
|
||||||
|
}
|
||||||
|
if !info.Mode().IsRegular() {
|
||||||
|
return ReadAnswer{}, fmt.Errorf("%s is a %s, not a file: list a directory, stat anything else", path, typeOf(info.Mode()))
|
||||||
|
}
|
||||||
|
a := ReadAnswer{Path: path, Size: info.Size()}
|
||||||
|
lines, cut, err := pick(io.LimitReader(f, MaxScan), ask, bound)
|
||||||
|
if err != nil {
|
||||||
|
return ReadAnswer{}, fmt.Errorf("%s: %v", path, err)
|
||||||
|
}
|
||||||
|
a.Cut = cut
|
||||||
|
if len(lines) > 0 {
|
||||||
|
a.From = lines[0].n
|
||||||
|
}
|
||||||
|
var text strings.Builder
|
||||||
|
for _, l := range lines {
|
||||||
|
s, what := clean(l.text)
|
||||||
|
if what != "" {
|
||||||
|
a.Withheld = append(a.Withheld, l.n)
|
||||||
|
a.Why = "a line shaped like a secret is withheld by the content rule (novox/hq ADR 0310 §4)"
|
||||||
|
}
|
||||||
|
text.WriteString(s)
|
||||||
|
if l.newline {
|
||||||
|
text.WriteByte('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.Text, a.Lines = text.String(), len(lines)
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type line struct {
|
||||||
|
n int
|
||||||
|
text string
|
||||||
|
newline bool
|
||||||
|
size int
|
||||||
|
}
|
||||||
|
|
||||||
|
// pick reads lines and keeps the ones asked, within bound bytes. A NUL byte is a binary file, refused.
|
||||||
|
func pick(in io.Reader, ask ReadAsk, bound int) ([]line, string, error) {
|
||||||
|
br := bufio.NewReaderSize(in, 64<<10)
|
||||||
|
var kept []line
|
||||||
|
size, n := 0, 0
|
||||||
|
from := ask.From
|
||||||
|
if from == 0 {
|
||||||
|
from = 1
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
raw, err := br.ReadString('\n')
|
||||||
|
if raw == "" && err != nil {
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
n++
|
||||||
|
if strings.IndexByte(raw, 0) >= 0 {
|
||||||
|
return nil, "", errors.New("it is a binary file, and read answers text")
|
||||||
|
}
|
||||||
|
l := line{n: n, text: strings.TrimSuffix(raw, "\n"), newline: strings.HasSuffix(raw, "\n"), size: len(raw)}
|
||||||
|
if from > 0 {
|
||||||
|
if n < from {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ask.Lines > 0 && len(kept) == ask.Lines {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if size+len(raw) > bound {
|
||||||
|
return kept, fmt.Sprintf("cut at line %d: the answer holds at most %d bytes", n, bound), nil
|
||||||
|
}
|
||||||
|
kept = append(kept, l)
|
||||||
|
size += len(raw)
|
||||||
|
} else {
|
||||||
|
// From the end, as tail does: the last -from lines, kept as they pass.
|
||||||
|
kept = append(kept, l)
|
||||||
|
size += len(raw)
|
||||||
|
for len(kept) > -from || size > bound {
|
||||||
|
size -= kept[0].size
|
||||||
|
kept = kept[1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if from > 0 && n < from && n > 0 {
|
||||||
|
return nil, "", fmt.Errorf("it has %d lines, and line %d was asked", n, from)
|
||||||
|
}
|
||||||
|
if from < 0 && ask.Lines > 0 && len(kept) > ask.Lines {
|
||||||
|
kept = kept[:ask.Lines]
|
||||||
|
}
|
||||||
|
return kept, "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- search ----
|
||||||
|
|
||||||
|
// Match is one matching line.
|
||||||
|
type Match struct {
|
||||||
|
File string `json:"file"`
|
||||||
|
Line int `json:"line"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchAnswer is what search answers.
|
||||||
|
type SearchAnswer struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Matches []Match `json:"matches"`
|
||||||
|
Files int `json:"files-read"`
|
||||||
|
Bound string `json:"bound-met,omitempty"`
|
||||||
|
Skipped []string `json:"refused-by-name,omitempty"`
|
||||||
|
Unreadable int `json:"unreadable,omitempty"`
|
||||||
|
Binary int `json:"binary-skipped,omitempty"`
|
||||||
|
Withheld []Withheld `json:"withheld,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchAsk is what a search asks.
|
||||||
|
type SearchAsk struct {
|
||||||
|
Text, Path string
|
||||||
|
Depth, Matches int
|
||||||
|
CaseInsensitive bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var errBound = errors.New("bound met")
|
||||||
|
|
||||||
|
func (r Reader) Search(ask SearchAsk) (SearchAnswer, error) {
|
||||||
|
if err := r.asItsOwn(); err != nil {
|
||||||
|
return SearchAnswer{}, err
|
||||||
|
}
|
||||||
|
if ask.Text == "" {
|
||||||
|
return SearchAnswer{}, errors.New("name the text to find")
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(ask.Text, "\n\x00") {
|
||||||
|
return SearchAnswer{}, errors.New("the text is found within one line: it may not hold a line break")
|
||||||
|
}
|
||||||
|
root, err := absolute(ask.Path)
|
||||||
|
if err != nil {
|
||||||
|
return SearchAnswer{}, err
|
||||||
|
}
|
||||||
|
if err := r.refusedRead(root); err != nil {
|
||||||
|
return SearchAnswer{}, err
|
||||||
|
}
|
||||||
|
depth, matches := ask.Depth, ask.Matches
|
||||||
|
switch {
|
||||||
|
case depth == 0:
|
||||||
|
depth = DefaultDepth
|
||||||
|
case depth < 0 || depth > MaxDepth:
|
||||||
|
return SearchAnswer{}, fmt.Errorf("depth %d asked; a search goes at most %d directories deep", depth, MaxDepth)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case matches == 0:
|
||||||
|
matches = DefaultMatches
|
||||||
|
case matches < 0 || matches > MaxMatches:
|
||||||
|
return SearchAnswer{}, fmt.Errorf("%d matches asked; a search answers at most %d", matches, MaxMatches)
|
||||||
|
}
|
||||||
|
needle := ask.Text
|
||||||
|
if ask.CaseInsensitive {
|
||||||
|
needle = strings.ToLower(needle)
|
||||||
|
}
|
||||||
|
a := SearchAnswer{Text: ask.Text, Path: root, Matches: []Match{}}
|
||||||
|
deadline := r.Now().Add(SearchTime)
|
||||||
|
base := strings.Count(root, "/")
|
||||||
|
if root == "/" {
|
||||||
|
base = 0
|
||||||
|
}
|
||||||
|
err = filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
|
||||||
|
if r.Now().After(deadline) {
|
||||||
|
a.Bound = fmt.Sprintf("time: a search runs at most %s", SearchTime)
|
||||||
|
return errBound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
a.Unreadable++
|
||||||
|
if d != nil && d.IsDir() {
|
||||||
|
return fs.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if p != root {
|
||||||
|
if why := r.Refused(p); why != "" {
|
||||||
|
a.Skipped = append(a.Skipped, p)
|
||||||
|
if d.IsDir() {
|
||||||
|
return fs.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
if p != root && strings.Count(p, "/")-base > depth {
|
||||||
|
return fs.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !d.Type().IsRegular() {
|
||||||
|
return nil // links are not followed; a device, a fifo or a socket is not text
|
||||||
|
}
|
||||||
|
if a.Files >= MaxFiles {
|
||||||
|
a.Bound = fmt.Sprintf("files: a search reads at most %d files", MaxFiles)
|
||||||
|
return errBound
|
||||||
|
}
|
||||||
|
a.Files++
|
||||||
|
return r.searchFile(p, needle, ask.CaseInsensitive, matches, &a)
|
||||||
|
})
|
||||||
|
if err != nil && !errors.Is(err, errBound) {
|
||||||
|
return SearchAnswer{}, fmt.Errorf("%s cannot be searched: %s", root, why(err))
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Reader) searchFile(p, needle string, fold bool, bound int, a *SearchAnswer) error {
|
||||||
|
f, err := os.OpenFile(p, os.O_RDONLY|syscall.O_NONBLOCK, 0)
|
||||||
|
if err != nil {
|
||||||
|
a.Unreadable++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
br := bufio.NewReaderSize(io.LimitReader(f, MaxFileScan), 64<<10)
|
||||||
|
if head, _ := br.Peek(8 << 10); bytes.IndexByte(head, 0) >= 0 {
|
||||||
|
a.Binary++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
for {
|
||||||
|
raw, err := br.ReadString('\n')
|
||||||
|
if raw == "" && err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
n++
|
||||||
|
hay := raw
|
||||||
|
if fold {
|
||||||
|
hay = strings.ToLower(hay)
|
||||||
|
}
|
||||||
|
if strings.Contains(hay, needle) {
|
||||||
|
if len(a.Matches) >= bound {
|
||||||
|
a.Bound = fmt.Sprintf("matches: %d answered, as asked or the default", bound)
|
||||||
|
return errBound
|
||||||
|
}
|
||||||
|
text := strings.TrimRight(raw, "\r\n")
|
||||||
|
if r := []rune(text); len(r) > MaxLine {
|
||||||
|
text = string(r[:MaxLine]) + "…"
|
||||||
|
}
|
||||||
|
file := p
|
||||||
|
for _, f := range []*string{&text} {
|
||||||
|
if s, what := clean(*f); what != "" {
|
||||||
|
a.Withheld = append(a.Withheld, Withheld{At: "a matching line", Class: what})
|
||||||
|
*f = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.Matches = append(a.Matches, Match{File: file, Line: n, Text: text})
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- accounts ----
|
||||||
|
|
||||||
|
// Account is one account as the account file says it, never its password field.
|
||||||
|
type Account struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
UID int `json:"uid"`
|
||||||
|
GID int `json:"gid"`
|
||||||
|
Group string `json:"group,omitempty"`
|
||||||
|
Gecos string `json:"gecos,omitempty"`
|
||||||
|
Home string `json:"home"`
|
||||||
|
Shell string `json:"shell"`
|
||||||
|
Groups []string `json:"groups,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupEntry is one group.
|
||||||
|
type GroupEntry struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
GID int `json:"gid"`
|
||||||
|
Members []string `json:"members"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccountAnswer is what account answers.
|
||||||
|
type AccountAnswer struct {
|
||||||
|
Accounts []Account `json:"accounts,omitempty"`
|
||||||
|
Groups []GroupEntry `json:"groups,omitempty"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Cut string `json:"cut,omitempty"`
|
||||||
|
Withheld []Withheld `json:"withheld,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type accounts struct {
|
||||||
|
users []Account
|
||||||
|
groups []GroupEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a accounts) user(uid int) string {
|
||||||
|
for _, u := range a.users {
|
||||||
|
if u.UID == uid {
|
||||||
|
return u.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a accounts) group(gid int) string {
|
||||||
|
for _, g := range a.groups {
|
||||||
|
if g.GID == gid {
|
||||||
|
return g.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// names reads the account files; a file it cannot read gives no names, and a component says only numbers.
|
||||||
|
func (r Reader) names() accounts {
|
||||||
|
var a accounts
|
||||||
|
if raw, err := os.ReadFile(r.Passwd); err == nil {
|
||||||
|
for _, l := range strings.Split(string(raw), "\n") {
|
||||||
|
f := strings.Split(l, ":")
|
||||||
|
if len(f) < 7 || strings.HasPrefix(l, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
uid, e1 := strconv.Atoi(f[2])
|
||||||
|
gid, e2 := strconv.Atoi(f[3])
|
||||||
|
if e1 != nil || e2 != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
a.users = append(a.users, Account{Name: f[0], UID: uid, GID: gid, Gecos: f[4], Home: f[5], Shell: f[6]})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if raw, err := os.ReadFile(r.Group); err == nil {
|
||||||
|
for _, l := range strings.Split(string(raw), "\n") {
|
||||||
|
f := strings.Split(l, ":")
|
||||||
|
if len(f) < 4 || strings.HasPrefix(l, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
gid, err := strconv.Atoi(f[2])
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
members := []string{}
|
||||||
|
for _, m := range strings.Split(f[3], ",") {
|
||||||
|
if m = strings.TrimSpace(m); m != "" {
|
||||||
|
members = append(members, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.groups = append(a.groups, GroupEntry{Name: f[0], GID: gid, Members: members})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Reader) Account(name, group string) (AccountAnswer, error) {
|
||||||
|
if err := r.asItsOwn(); err != nil {
|
||||||
|
return AccountAnswer{}, err
|
||||||
|
}
|
||||||
|
all := r.names()
|
||||||
|
if len(all.users) == 0 {
|
||||||
|
return AccountAnswer{}, fmt.Errorf("%s gave no account: it is unreadable or empty", r.Passwd)
|
||||||
|
}
|
||||||
|
out := AccountAnswer{Source: r.Passwd + ", " + r.Group + " (an account kept elsewhere, such as a directory " +
|
||||||
|
"service or systemd's dynamic accounts, is not here)"}
|
||||||
|
complete := func(u Account) Account {
|
||||||
|
u.Group = all.group(u.GID)
|
||||||
|
for _, g := range all.groups {
|
||||||
|
if g.GID == u.GID {
|
||||||
|
u.Groups = append(u.Groups, g.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, m := range g.Members {
|
||||||
|
if m == u.Name {
|
||||||
|
u.Groups = append(u.Groups, g.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
name, group = strings.TrimSpace(name), strings.TrimSpace(group)
|
||||||
|
switch {
|
||||||
|
case name != "":
|
||||||
|
for _, u := range all.users {
|
||||||
|
if u.Name == name || strconv.Itoa(u.UID) == name {
|
||||||
|
out.Accounts = append(out.Accounts, complete(u))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out.Accounts) == 0 {
|
||||||
|
return AccountAnswer{}, fmt.Errorf("no account %q on this node", name)
|
||||||
|
}
|
||||||
|
case group != "":
|
||||||
|
for _, g := range all.groups {
|
||||||
|
if g.Name == group || strconv.Itoa(g.GID) == group {
|
||||||
|
// Its members as getent says them, and every account whose own group it is.
|
||||||
|
for _, u := range all.users {
|
||||||
|
if u.GID == g.GID && !contains(g.Members, u.Name) {
|
||||||
|
g.Members = append(g.Members, u.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.Groups = append(out.Groups, g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out.Groups) == 0 {
|
||||||
|
return AccountAnswer{}, fmt.Errorf("no group %q on this node", group)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
for _, u := range all.users {
|
||||||
|
out.Accounts = append(out.Accounts, complete(u))
|
||||||
|
}
|
||||||
|
out.Groups = all.groups
|
||||||
|
if len(out.Accounts) > MaxAccounts {
|
||||||
|
out.Cut = fmt.Sprintf("cut at %d of %d accounts", MaxAccounts, len(out.Accounts))
|
||||||
|
out.Accounts = out.Accounts[:MaxAccounts]
|
||||||
|
}
|
||||||
|
if len(out.Groups) > MaxAccounts {
|
||||||
|
out.Cut = strings.TrimPrefix(out.Cut+"; ", "; ") + fmt.Sprintf("cut at %d of %d groups", MaxAccounts, len(out.Groups))
|
||||||
|
out.Groups = out.Groups[:MaxAccounts]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range out.Accounts {
|
||||||
|
u := &out.Accounts[i]
|
||||||
|
for _, f := range []*string{&u.Gecos, &u.Home, &u.Shell} {
|
||||||
|
if s, what := clean(*f); what != "" {
|
||||||
|
out.Withheld = append(out.Withheld, Withheld{At: "account " + u.Name, Class: what})
|
||||||
|
*f = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.SliceStable(out.Accounts, func(i, j int) bool { return out.Accounts[i].UID < out.Accounts[j].UID })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(list []string, s string) bool {
|
||||||
|
for _, x := range list {
|
||||||
|
if x == s {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- mounts ----
|
||||||
|
|
||||||
|
// mounts reads the mount points this process sees, by path. /proc/self/mountinfo is readable by every account.
|
||||||
|
func (r Reader) mounts() map[string]Mount {
|
||||||
|
out := map[string]Mount{}
|
||||||
|
raw, err := os.ReadFile(r.MountInfo)
|
||||||
|
if err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for _, l := range strings.Split(string(raw), "\n") {
|
||||||
|
f := strings.Fields(l)
|
||||||
|
dash := -1
|
||||||
|
for i, x := range f {
|
||||||
|
if x == "-" {
|
||||||
|
dash = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(f) < 5 || dash < 0 || dash+2 >= len(f) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[unescape(f[4])] = Mount{FS: f[dash+1], Source: unescape(f[dash+2])}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// unescape reads mountinfo's octal escapes (\040 for a space).
|
||||||
|
func unescape(s string) string {
|
||||||
|
if !strings.Contains(s, `\`) {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if s[i] == '\\' && i+3 < len(s) {
|
||||||
|
if v, err := strconv.ParseUint(s[i+1:i+4], 8, 8); err == nil {
|
||||||
|
b.WriteByte(byte(v))
|
||||||
|
i += 3
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteByte(s[i])
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A token shaped as a secret, written into files the tests read.
|
||||||
|
const aToken = "ghp_abcdefghijklmnopqrstuvwxyz0123456789"
|
||||||
|
|
||||||
|
func testReader(t *testing.T, places ...string) (Reader, string) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
passwd := filepath.Join(dir, "passwd")
|
||||||
|
group := filepath.Join(dir, "group")
|
||||||
|
mountinfo := filepath.Join(dir, "mountinfo")
|
||||||
|
write(t, passwd, "root:x:0:0::/root:/usr/bin/bash\nops:x:1000:1000:The Operator:/home/ops:/usr/bin/zsh\n"+
|
||||||
|
"mesh-reads:x:961:961::/var/lib/mesh-reads:/usr/bin/nologin\n")
|
||||||
|
write(t, group, "root:x:0:\nwheel:x:998:ops\nops:x:1000:\nmesh-reads:x:961:\ndocker:x:970:ops,builder\n")
|
||||||
|
data := filepath.Join(dir, "data")
|
||||||
|
write(t, mountinfo, "22 1 0:21 / / rw,relatime - btrfs /dev/nvme0n1p2 rw\n"+
|
||||||
|
"40 22 0:40 / "+strings.ReplaceAll(data, " ", `\040`)+" rw - nfs4 home-server:/tank/data rw\n")
|
||||||
|
r := Reader{Refuser: Refuser{Places: places}, Passwd: passwd, Group: group, MountInfo: mountinfo,
|
||||||
|
Euid: func() int { return 961 }, Now: time.Now}
|
||||||
|
return r, dir
|
||||||
|
}
|
||||||
|
|
||||||
|
func write(t *testing.T, path, content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADR 0310 §3: run as root, every read is refused, loudly — its bound is an account of its own.
|
||||||
|
func TestEveryReadIsRefusedAsRoot(t *testing.T) {
|
||||||
|
r, dir := testReader(t)
|
||||||
|
r.Euid = func() int { return 0 }
|
||||||
|
write(t, filepath.Join(dir, "f"), "x\n")
|
||||||
|
if _, err := r.Read(filepath.Join(dir, "f"), ReadAsk{}); err == nil || !strings.Contains(err.Error(), "root") {
|
||||||
|
t.Errorf("a read as root: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := r.Stat([]string{dir}, false); err == nil {
|
||||||
|
t.Error("a stat as root was answered")
|
||||||
|
}
|
||||||
|
if _, err := r.Account("ops", ""); err == nil {
|
||||||
|
t.Error("an account lookup as root was answered")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADR 0310 §5: without the controller's list of secret places it does not start: a refusal it promises would be
|
||||||
|
// silent.
|
||||||
|
func TestItDoesNotStartWithoutTheNodesSecretPlaces(t *testing.T) {
|
||||||
|
if _, err := placesFrom(""); err == nil || !strings.Contains(err.Error(), PlacesWord) {
|
||||||
|
t.Errorf("no places: %v", err)
|
||||||
|
}
|
||||||
|
if p, err := placesFrom(`["/var/lib/mesh-vault"]`); err != nil || len(p) != 1 {
|
||||||
|
t.Errorf("a list: %v %v", p, err)
|
||||||
|
}
|
||||||
|
if p, err := placesFrom(`[]`); err != nil || len(p) != 0 {
|
||||||
|
t.Errorf("an empty list is a node with no secret place: %v %v", p, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADR 0310 §5: a path under the vault's directory, a module's own-secret directory and a known credential file
|
||||||
|
// are each refused by name before any read — though the account's modes would allow it — and a link to one is
|
||||||
|
// refused as the place is.
|
||||||
|
func TestSecretPlacesAreRefusedByNameBeforeAnyRead(t *testing.T) {
|
||||||
|
r0, dir := testReader(t)
|
||||||
|
vault := filepath.Join(dir, "var/lib/mesh-vault")
|
||||||
|
own := filepath.Join(dir, "var/lib/desk-channel")
|
||||||
|
r, _ := r0, dir
|
||||||
|
r.Places = []string{vault, own}
|
||||||
|
for _, p := range []string{
|
||||||
|
filepath.Join(vault, "ledger/entries.json"),
|
||||||
|
filepath.Join(own, "broker"),
|
||||||
|
filepath.Join(own, "settings.json"),
|
||||||
|
filepath.Join(dir, "home/ops/.claude/.credentials.json"),
|
||||||
|
filepath.Join(dir, "home/ops/.ssh/config"),
|
||||||
|
filepath.Join(dir, "home/ops/.pgpass"),
|
||||||
|
filepath.Join(dir, "etc/ssh/ssh_host_ed25519_key"),
|
||||||
|
filepath.Join(dir, "etc/shadow"),
|
||||||
|
filepath.Join(dir, "srv/tls/server.key"),
|
||||||
|
} {
|
||||||
|
write(t, p, "harmless words\n")
|
||||||
|
if _, err := r.Read(p, ReadAsk{}); err == nil || !strings.Contains(err.Error(), "refused by name") {
|
||||||
|
t.Errorf("%s was read: %v", p, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := r.List(vault); err == nil || !strings.Contains(err.Error(), "refused by name") {
|
||||||
|
t.Errorf("the vault's directory was listed: %v", err)
|
||||||
|
}
|
||||||
|
link := filepath.Join(dir, "tmp/innocent")
|
||||||
|
write(t, filepath.Join(dir, "tmp/x"), "")
|
||||||
|
if err := os.Symlink(filepath.Join(own, "broker"), link); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := r.Read(link, ReadAsk{}); err == nil || !strings.Contains(err.Error(), "leads to") {
|
||||||
|
t.Errorf("a link to a secret place was read: %v", err)
|
||||||
|
}
|
||||||
|
// A search under a directory holding a secret place skips it and names it.
|
||||||
|
write(t, filepath.Join(dir, "home/ops/notes.txt"), "harmless words\n")
|
||||||
|
a, err := r.Search(SearchAsk{Text: "harmless", Path: filepath.Join(dir, "home"), Depth: 8})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(a.Matches) != 1 || !strings.HasSuffix(a.Matches[0].File, "notes.txt") || len(a.Skipped) != 3 {
|
||||||
|
t.Errorf("search: matches %v, skipped %v", a.Matches, a.Skipped)
|
||||||
|
}
|
||||||
|
// What a public key or a certificate is, is not refused.
|
||||||
|
for _, p := range []string{filepath.Join(dir, "etc/ssh/ssh_host_ed25519_key.pub"), filepath.Join(dir, "etc/ssl/certs/ca.pem")} {
|
||||||
|
write(t, p, "harmless words\n")
|
||||||
|
if _, err := r.Read(p, ReadAsk{}); err != nil {
|
||||||
|
t.Errorf("%s was refused: %v", p, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADR 0310 §3: what the account's file modes refuse is refused — the first bound, below the names.
|
||||||
|
func TestAFileTheAccountMayNotReadIsRefusedByItsMode(t *testing.T) {
|
||||||
|
if os.Geteuid() == 0 {
|
||||||
|
t.Skip("run as root, a file's mode refuses nothing")
|
||||||
|
}
|
||||||
|
r, dir := testReader(t)
|
||||||
|
p := filepath.Join(dir, "operators-only")
|
||||||
|
write(t, p, "the operator's words\n")
|
||||||
|
if err := os.Chmod(p, 0o000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := r.Read(p, ReadAsk{}); err == nil || !strings.Contains(err.Error(), "may not reach it") {
|
||||||
|
t.Errorf("a file of mode 0000: %v", err)
|
||||||
|
}
|
||||||
|
closed := filepath.Join(dir, "closed")
|
||||||
|
write(t, filepath.Join(closed, "inside"), "x")
|
||||||
|
if err := os.Chmod(closed, 0o700&^0o100); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer os.Chmod(closed, 0o755)
|
||||||
|
a, err := r.Stat([]string{filepath.Join(closed, "inside")}, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := a.Paths[0]; !strings.Contains(got.Stopped, "may not reach") {
|
||||||
|
t.Errorf("a path through a directory it may not search: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADR 0310 §2 and the bounds: a read over the size bound is cut at a line and says so; more than the most is
|
||||||
|
// refused; lines and a tail are answered as asked.
|
||||||
|
func TestAReadIsBounded(t *testing.T) {
|
||||||
|
r, dir := testReader(t)
|
||||||
|
p := filepath.Join(dir, "big")
|
||||||
|
var b strings.Builder
|
||||||
|
for i := 1; i <= 20000; i++ {
|
||||||
|
fmt.Fprintf(&b, "line %d of the file\n", i)
|
||||||
|
}
|
||||||
|
write(t, p, b.String())
|
||||||
|
a, err := r.Read(p, ReadAsk{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(a.Text) > DefaultReadSize || !strings.Contains(a.Cut, fmt.Sprint(DefaultReadSize)) || a.From != 1 {
|
||||||
|
t.Errorf("a default read: %d bytes, cut %q", len(a.Text), a.Cut)
|
||||||
|
}
|
||||||
|
if _, err := r.Read(p, ReadAsk{Bytes: MaxReadSize + 1}); err == nil {
|
||||||
|
t.Error("more than the most a read answers was answered")
|
||||||
|
}
|
||||||
|
a, err = r.Read(p, ReadAsk{From: 10, Lines: 2})
|
||||||
|
if err != nil || a.Text != "line 10 of the file\nline 11 of the file\n" || a.From != 10 || a.Cut != "" {
|
||||||
|
t.Errorf("lines 10 and 11: %+v %v", a, err)
|
||||||
|
}
|
||||||
|
a, err = r.Read(p, ReadAsk{From: -2})
|
||||||
|
if err != nil || a.Text != "line 19999 of the file\nline 20000 of the file\n" || a.From != 19999 {
|
||||||
|
t.Errorf("the last two lines: %q from %d, %v", a.Text, a.From, err)
|
||||||
|
}
|
||||||
|
if _, err := r.Read(p, ReadAsk{From: 30000}); err == nil || !strings.Contains(err.Error(), "20000 lines") {
|
||||||
|
t.Errorf("a line past the end: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A binary file, a directory and a fifo are refused, and the fifo without waiting for a writer.
|
||||||
|
func TestOnlyATextFileIsRead(t *testing.T) {
|
||||||
|
r, dir := testReader(t)
|
||||||
|
bin := filepath.Join(dir, "bin")
|
||||||
|
write(t, bin, "ELF\x00\x01\x02")
|
||||||
|
if _, err := r.Read(bin, ReadAsk{}); err == nil || !strings.Contains(err.Error(), "binary") {
|
||||||
|
t.Errorf("a binary file: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := r.Read(dir, ReadAsk{}); err == nil || !strings.Contains(err.Error(), "directory") {
|
||||||
|
t.Errorf("a directory: %v", err)
|
||||||
|
}
|
||||||
|
fifo := filepath.Join(dir, "fifo")
|
||||||
|
if err := syscall.Mkfifo(fifo, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { _, err := r.Read(fifo, ReadAsk{}); done <- err }()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "fifo") {
|
||||||
|
t.Errorf("a fifo: %v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("a read of a fifo waits for a writer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADR 0310 §4: a line shaped like a secret is withheld, its number said, and the rest is answered.
|
||||||
|
func TestALineShapedLikeASecretIsWithheld(t *testing.T) {
|
||||||
|
r, dir := testReader(t)
|
||||||
|
p := filepath.Join(dir, "app.conf")
|
||||||
|
write(t, p, "listen = /run/app.sock\ntoken = "+aToken+"\nuser = app\n")
|
||||||
|
a, err := r.Read(p, ReadAsk{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(a.Text, aToken) || !strings.Contains(a.Text, "user = app") ||
|
||||||
|
!strings.Contains(a.Text, "listen = /run/app.sock") || fmt.Sprint(a.Withheld) != "[2]" {
|
||||||
|
t.Errorf("read: %q withheld %v", a.Text, a.Withheld)
|
||||||
|
}
|
||||||
|
m, err := r.Search(SearchAsk{Text: "token", Path: dir})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(m)
|
||||||
|
if strings.Contains(string(body), aToken) || len(m.Matches) != 1 || len(m.Withheld) != 1 {
|
||||||
|
t.Errorf("search: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bounds of a search: matches, depth, the most asked, and time — each met is said.
|
||||||
|
func TestASearchIsBounded(t *testing.T) {
|
||||||
|
r, dir := testReader(t)
|
||||||
|
for i := 0; i < 30; i++ {
|
||||||
|
write(t, filepath.Join(dir, fmt.Sprintf("f%02d", i)), "needle\nneedle\n")
|
||||||
|
}
|
||||||
|
a, err := r.Search(SearchAsk{Text: "needle", Path: dir, Matches: 7})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(a.Matches) != 7 || !strings.HasPrefix(a.Bound, "matches") {
|
||||||
|
t.Errorf("seven matches asked: %d, bound %q", len(a.Matches), a.Bound)
|
||||||
|
}
|
||||||
|
if _, err := r.Search(SearchAsk{Text: "needle", Path: dir, Matches: MaxMatches + 1}); err == nil {
|
||||||
|
t.Error("more matches than the most were asked and answered")
|
||||||
|
}
|
||||||
|
if _, err := r.Search(SearchAsk{Text: "needle", Path: dir, Depth: MaxDepth + 1}); err == nil {
|
||||||
|
t.Error("deeper than the most was asked and answered")
|
||||||
|
}
|
||||||
|
deep := t.TempDir()
|
||||||
|
write(t, filepath.Join(deep, "a/b/c/shallow"), "needle\n")
|
||||||
|
write(t, filepath.Join(deep, "a/b/c/d/e/deep"), "needle\n")
|
||||||
|
a, err = r.Search(SearchAsk{Text: "needle", Path: deep, Depth: 3})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(a.Matches) != 1 || !strings.HasSuffix(a.Matches[0].File, "shallow") {
|
||||||
|
t.Errorf("three deep: %+v", a.Matches)
|
||||||
|
}
|
||||||
|
// Case: as asked.
|
||||||
|
write(t, filepath.Join(deep, "case"), "NEEDLE\n")
|
||||||
|
if a, _ := r.Search(SearchAsk{Text: "needle", Path: filepath.Join(deep, "case")}); len(a.Matches) != 0 {
|
||||||
|
t.Error("a match whatever the case, unasked")
|
||||||
|
}
|
||||||
|
if a, _ := r.Search(SearchAsk{Text: "needle", Path: filepath.Join(deep, "case"), CaseInsensitive: true}); len(a.Matches) != 1 {
|
||||||
|
t.Error("no match whatever the case, asked")
|
||||||
|
}
|
||||||
|
// Time: a clock past the bound stops the walk and says so.
|
||||||
|
start := time.Now()
|
||||||
|
calls := 0
|
||||||
|
r.Now = func() time.Time { calls++; return start.Add(time.Duration(calls) * SearchTime / 3) }
|
||||||
|
a, err = r.Search(SearchAsk{Text: "needle", Path: dir})
|
||||||
|
if err != nil || !strings.HasPrefix(a.Bound, "time") {
|
||||||
|
t.Errorf("the time bound: %q %v", a.Bound, err)
|
||||||
|
}
|
||||||
|
// A fixed string: a pattern's characters are themselves.
|
||||||
|
write(t, filepath.Join(deep, "pattern"), "a.c\nabc\n")
|
||||||
|
if a, _ := r.Search(SearchAsk{Text: "a.c", Path: filepath.Join(deep, "pattern")}); len(a.Matches) != 1 || a.Matches[0].Line != 1 {
|
||||||
|
t.Errorf("a.c as a fixed string: %+v", a.Matches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The survey issue 418 was asked for: each component of a path with its owner, group, mode, whether it is a
|
||||||
|
// link and to what, and whether it is a mount point — the link not followed unless asked.
|
||||||
|
func TestStatSaysEachComponent(t *testing.T) {
|
||||||
|
r, dir := testReader(t)
|
||||||
|
data := filepath.Join(dir, "data")
|
||||||
|
write(t, filepath.Join(data, "real/file"), "x")
|
||||||
|
if err := os.Chmod(filepath.Join(data, "real"), os.ModeSetgid|0o750); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink("real", filepath.Join(data, "link")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
a, err := r.Stat([]string{filepath.Join(data, "link"), filepath.Join(data, "real/file")}, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
link := a.Paths[0]
|
||||||
|
last := link.Components[len(link.Components)-1]
|
||||||
|
if link.Components[0].Path != "/" || last.Type != "link" || last.Link != "real" || link.LeadsTo != "" {
|
||||||
|
t.Errorf("the link, not followed: %+v", link)
|
||||||
|
}
|
||||||
|
var dataC, realC Component
|
||||||
|
for _, c := range a.Paths[1].Components {
|
||||||
|
switch c.Path {
|
||||||
|
case data:
|
||||||
|
dataC = c
|
||||||
|
case filepath.Join(data, "real"):
|
||||||
|
realC = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dataC.Mount == nil || dataC.Mount.FS != "nfs4" {
|
||||||
|
t.Errorf("the mount point is not said: %+v", dataC)
|
||||||
|
}
|
||||||
|
if realC.Mode != "2750" || realC.Type != "directory" || realC.UID == nil || realC.Perms == "" {
|
||||||
|
t.Errorf("a component's mode: %+v", realC)
|
||||||
|
}
|
||||||
|
if uid := *realC.UID; uid == 1000 && realC.Owner != "ops" {
|
||||||
|
t.Errorf("owner %d named %q", uid, realC.Owner)
|
||||||
|
}
|
||||||
|
a, err = r.Stat([]string{filepath.Join(data, "link")}, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if a.Paths[0].LeadsTo != filepath.Join(data, "real") || a.Paths[0].Leads == nil || a.Paths[0].Leads.Type != "directory" {
|
||||||
|
t.Errorf("followed: %+v", a.Paths[0])
|
||||||
|
}
|
||||||
|
a, err = r.Stat([]string{filepath.Join(dir, "missing/deeper")}, false)
|
||||||
|
if err != nil || !strings.Contains(a.Paths[0].Stopped, "does not exist") {
|
||||||
|
t.Errorf("a missing path: %+v %v", a, err)
|
||||||
|
}
|
||||||
|
if _, err := r.Stat([]string{"relative/path"}, false); err == nil {
|
||||||
|
t.Error("a relative path was answered")
|
||||||
|
}
|
||||||
|
many := make([]string, MaxPaths+1)
|
||||||
|
for i := range many {
|
||||||
|
many[i] = "/"
|
||||||
|
}
|
||||||
|
if _, err := r.Stat(many, false); err == nil {
|
||||||
|
t.Error("more paths than the most were answered")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadlinkSaysTheTargetAndWhereItLeads(t *testing.T) {
|
||||||
|
r, dir := testReader(t)
|
||||||
|
write(t, filepath.Join(dir, "target"), "")
|
||||||
|
if err := os.Symlink("target", filepath.Join(dir, "l1")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink("l1", filepath.Join(dir, "l2")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink("nowhere", filepath.Join(dir, "dangling")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
a, err := r.Readlink(filepath.Join(dir, "l2"))
|
||||||
|
if err != nil || !a.IsLink || a.Link != "l1" || a.LeadsTo != filepath.Join(dir, "target") || !a.Exists {
|
||||||
|
t.Errorf("l2: %+v %v", a, err)
|
||||||
|
}
|
||||||
|
a, err = r.Readlink(filepath.Join(dir, "dangling"))
|
||||||
|
if err != nil || !a.IsLink || a.Exists || !strings.Contains(a.Stopped, "does not exist") {
|
||||||
|
t.Errorf("dangling: %+v %v", a, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListSaysEveryEntry(t *testing.T) {
|
||||||
|
r, dir := testReader(t)
|
||||||
|
write(t, filepath.Join(dir, "d/a"), "abc")
|
||||||
|
if err := os.Symlink("a", filepath.Join(dir, "d/b")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
a, err := r.List(filepath.Join(dir, "d"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if a.Count != 2 || a.Entries[0].Name != "a" || *a.Entries[0].Size != 3 || a.Entries[1].Type != "link" || a.Entries[1].Link != "a" {
|
||||||
|
t.Errorf("list: %+v", a)
|
||||||
|
}
|
||||||
|
for i := 0; i < MaxEntries+5; i++ {
|
||||||
|
write(t, filepath.Join(dir, "many", fmt.Sprintf("e%05d", i)), "")
|
||||||
|
}
|
||||||
|
a, err = r.List(filepath.Join(dir, "many"))
|
||||||
|
if err != nil || len(a.Entries) != MaxEntries || a.Count != MaxEntries+5 || a.Cut == "" {
|
||||||
|
t.Errorf("a long directory: %d of %d, cut %q, %v", len(a.Entries), a.Count, a.Cut, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getent passwd, getent group and id: an account with every group it is in, a group with its members, never a
|
||||||
|
// password field.
|
||||||
|
func TestAccountAndGroupLookup(t *testing.T) {
|
||||||
|
r, _ := testReader(t)
|
||||||
|
a, err := r.Account("ops", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
u := a.Accounts[0]
|
||||||
|
sort.Strings(u.Groups)
|
||||||
|
if u.UID != 1000 || u.Group != "ops" || u.Shell != "/usr/bin/zsh" || fmt.Sprint(u.Groups) != "[docker ops wheel]" {
|
||||||
|
t.Errorf("ops: %+v", u)
|
||||||
|
}
|
||||||
|
if a, err := r.Account("961", ""); err != nil || a.Accounts[0].Name != "mesh-reads" {
|
||||||
|
t.Errorf("by number: %+v %v", a, err)
|
||||||
|
}
|
||||||
|
g, err := r.Account("", "docker")
|
||||||
|
if err != nil || g.Groups[0].GID != 970 || fmt.Sprint(g.Groups[0].Members) != "[ops builder]" {
|
||||||
|
t.Errorf("docker: %+v %v", g, err)
|
||||||
|
}
|
||||||
|
if _, err := r.Account("nobody-here", ""); err == nil {
|
||||||
|
t.Error("a missing account was answered")
|
||||||
|
}
|
||||||
|
all, err := r.Account("", "")
|
||||||
|
if err != nil || len(all.Accounts) != 3 || len(all.Groups) != 5 {
|
||||||
|
t.Errorf("all: %+v %v", all, err)
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(all)
|
||||||
|
if strings.Contains(string(body), `"x"`) || strings.Contains(string(body), "password") {
|
||||||
|
t.Errorf("a password field is answered: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No answer is larger than the bound, whatever a verb made.
|
||||||
|
func TestNoAnswerIsLargerThanTheBound(t *testing.T) {
|
||||||
|
if _, err := bounded(strings.Repeat("x", MaxAnswer+1), nil); err == nil {
|
||||||
|
t.Error("an answer over the bound was given")
|
||||||
|
}
|
||||||
|
if _, err := bounded("small", nil); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The seat's six verbs, as the controller defines them (mesh-controller internal/catalogue/reads.go).
|
||||||
|
func TestItServesTheSeatsVerbs(t *testing.T) {
|
||||||
|
r, _ := testReader(t)
|
||||||
|
var names []string
|
||||||
|
for _, tool := range tools(r) {
|
||||||
|
names = append(names, tool.Name)
|
||||||
|
}
|
||||||
|
if fmt.Sprint(names) != "[node-reads.stat node-reads.readlink node-reads.list node-reads.read node-reads.search node-reads.account]" {
|
||||||
|
t.Errorf("verbs: %v", names)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/outward"
|
||||||
|
)
|
||||||
|
|
||||||
|
// What is refused by name, before any read (novox/hq ADR 0310 §5), and what an answer withholds (§4).
|
||||||
|
//
|
||||||
|
// **Two lists of places.** The places the mesh knows a secret is kept on this node — every module's own-secret
|
||||||
|
// directory, every credential file and grant directory, the vault's directories — come from the controller,
|
||||||
|
// which composes them from the manifests (MESH_SECRET_PLACES). The known credential files of the programs
|
||||||
|
// on a machine are this module's list, below: the mesh does not write them, so nothing else names them. Both
|
||||||
|
// hold whatever the account's file modes would allow: the file modes are the first bound, the names the second.
|
||||||
|
|
||||||
|
// knownDirs are directories that hold nothing but credentials, wherever they are: an account's keys.
|
||||||
|
var knownDirs = map[string]bool{
|
||||||
|
".ssh": true, ".gnupg": true, ".password-store": true, ".aws": true, ".kube": true, ".azure": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// knownNames are file names that are a credential whatever directory they are in.
|
||||||
|
var knownNames = map[string]bool{
|
||||||
|
".credentials.json": true, // the agent's credentials (Claude Code)
|
||||||
|
".netrc": true, ".pgpass": true, ".git-credentials": true, ".npmrc": true, ".pypirc": true,
|
||||||
|
".my.cnf": true, ".vault-token": true, "credentials": true, "credentials.json": true,
|
||||||
|
"id_rsa": true, "id_dsa": true, "id_ecdsa": true, "id_ed25519": true, "id_ecdsa_sk": true, "id_ed25519_sk": true,
|
||||||
|
"shadow": true, "gshadow": true, "shadow-": true, "gshadow-": true, "opasswd": true,
|
||||||
|
"broker": true, // the mesh's name for a module's bus credential
|
||||||
|
}
|
||||||
|
|
||||||
|
// knownSuffixes are file name endings a credential has.
|
||||||
|
var knownSuffixes = []string{".key", ".p12", ".pfx", ".jks", ".keystore", ".creds", ".nk", ".kdbx", "_key"}
|
||||||
|
|
||||||
|
// knownPaths are the places on a machine that hold credentials and are not a module's.
|
||||||
|
var knownPaths = []string{
|
||||||
|
"/etc/ssl/private", "/etc/wireguard", "/etc/NetworkManager/system-connections", "/etc/ipsec.d/private",
|
||||||
|
"/var/lib/iwd", "/etc/docker/key.json", "/root/.docker/config.json", "/var/lib/sss/secrets",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refuser says whether a path is a place a secret is kept.
|
||||||
|
type Refuser struct {
|
||||||
|
// Places are the mesh's, from the controller: refused with everything beneath them.
|
||||||
|
Places []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refused says why a path may not be read, or "" when it may. The path is clean and absolute.
|
||||||
|
func (r Refuser) Refused(path string) string {
|
||||||
|
path = filepath.Clean(path)
|
||||||
|
for _, p := range r.Places {
|
||||||
|
if within(path, p) {
|
||||||
|
return "it is a place the mesh keeps a secret (" + p + ")"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, p := range knownPaths {
|
||||||
|
if within(path, p) {
|
||||||
|
return "it is a place a machine keeps credentials (" + p + ")"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts := strings.Split(strings.TrimPrefix(path, "/"), "/")
|
||||||
|
for i, part := range parts {
|
||||||
|
if knownDirs[part] {
|
||||||
|
return "it is in " + part + ", which holds an account's credentials"
|
||||||
|
}
|
||||||
|
if i == len(parts)-1 {
|
||||||
|
if knownNames[part] {
|
||||||
|
return part + " is a known credential file"
|
||||||
|
}
|
||||||
|
for _, s := range knownSuffixes {
|
||||||
|
if strings.HasSuffix(part, s) && len(part) > len(s) {
|
||||||
|
return part + " is named as a key or a credential (" + s + ")"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(part, ".pem") && (strings.Contains(part, "key") || strings.Contains(part, "private")) {
|
||||||
|
return part + " is named as a private key"
|
||||||
|
}
|
||||||
|
if part == "config.json" && i > 0 && parts[i-1] == ".docker" {
|
||||||
|
return "a container registry login is kept there"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func within(path, place string) bool {
|
||||||
|
return path == place || strings.HasPrefix(path, strings.TrimSuffix(place, "/")+"/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Withheld is one piece of an answer the content rule kept back: where it was, and the class it carried.
|
||||||
|
type Withheld struct {
|
||||||
|
At string `json:"at"`
|
||||||
|
Class string `json:"why"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// clean is the content rule's secret class (mesh-sdk outward, novox/hq ADR 0310 §4) over one piece of an
|
||||||
|
// answer: the piece, or a mark in its place when it is shaped like a secret.
|
||||||
|
//
|
||||||
|
// **What a file holds, not what the file system names.** It judges a file's lines, a matching line, a link's
|
||||||
|
// target, a mount's source and an account's words. A path and an entry's name are not judged: they are what
|
||||||
|
// the caller named or the file system calls a thing, and the rule's random-run test takes a temporary
|
||||||
|
// directory's or a container layer's name for a token, which would withhold the very survey asked for. A
|
||||||
|
// place where a secret sits by name is refused before any read instead (Refuser).
|
||||||
|
func clean(s string) (string, string) {
|
||||||
|
if refusal, ok := outward.CheckSecret(s); !ok {
|
||||||
|
return "‹withheld: " + refusal.What + "›", refusal.What
|
||||||
|
}
|
||||||
|
return s, ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
module mesh-reads
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012115257-4dc5f29d3e23
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012115257-4dc5f29d3e23 h1:/F4NwABPAuFZlBx1yK4rdj6sVL5K1XvRlOnZZfNnlsQ=
|
||||||
|
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012115257-4dc5f29d3e23/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"module": "mesh-reads",
|
||||||
|
"version": "1",
|
||||||
|
"runs-as": "mesh-reads",
|
||||||
|
"claims": [
|
||||||
|
{
|
||||||
|
"name": "node-reads",
|
||||||
|
"scope": "node",
|
||||||
|
"serves": [
|
||||||
|
"stat",
|
||||||
|
"readlink",
|
||||||
|
"list",
|
||||||
|
"read",
|
||||||
|
"search",
|
||||||
|
"account"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"own-secrets": {
|
||||||
|
"broker": "${dir:state}/broker"
|
||||||
|
},
|
||||||
|
"secrets-owner": "mesh-reads",
|
||||||
|
"resources": [
|
||||||
|
{
|
||||||
|
"id": "account",
|
||||||
|
"type": "user",
|
||||||
|
"name": "mesh-reads",
|
||||||
|
"shell": "/usr/bin/nologin",
|
||||||
|
"home": "/var/lib/mesh-reads"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "state",
|
||||||
|
"type": "directory",
|
||||||
|
"mode": "0700",
|
||||||
|
"owner": "mesh-reads",
|
||||||
|
"place": "."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"build": {
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"name": "tools",
|
||||||
|
"kind": "bundle",
|
||||||
|
"language": "go",
|
||||||
|
"system": "arch",
|
||||||
|
"from": "cmd/mesh-reads",
|
||||||
|
"binary": "mesh-reads",
|
||||||
|
"loads": [
|
||||||
|
"mesh-reads"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user