Compare commits

..

8 Commits

Author SHA1 Message Date
79957f8ea7
fortify: test help message
All checks were successful
Test / Create distribution (push) Successful in 19s
Test / Run NixOS test (push) Successful in 50s
This helps catch regressions in "command".

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-02-23 02:51:35 +09:00
7e52463445
fortify: integrate command handler
All checks were successful
Test / Create distribution (push) Successful in 25s
Test / Run NixOS test (push) Successful in 3m24s
Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-02-23 02:35:02 +09:00
89970f5197
command/flag: implement repeatable flag
All checks were successful
Test / Create distribution (push) Successful in 25s
Test / Run NixOS test (push) Successful in 3m29s
Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-02-23 02:25:31 +09:00
35037705a9
command/flag: implement integer flag
All checks were successful
Test / Create distribution (push) Successful in 25s
Test / Run NixOS test (push) Successful in 3m23s
Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-02-23 02:02:01 +09:00
647c6ea21b
command: hide internal commands
All checks were successful
Test / Create distribution (push) Successful in 25s
Test / Run NixOS test (push) Successful in 3m26s
This marks commands as internal via a magic usage string.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-02-23 01:36:48 +09:00
416d93e880
command: expose print help
All checks were successful
Test / Create distribution (push) Successful in 26s
Test / Run NixOS test (push) Successful in 3m25s
This is useful for custom help commands.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-02-23 01:17:57 +09:00
312753924b
command: root early handler func special case
All checks were successful
Test / Create distribution (push) Successful in 27s
Test / Run NixOS test (push) Successful in 3m27s
This allows for early initialisation with access to flags on the root node. This can be useful for configuring global state used by subcommands.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-02-23 00:55:18 +09:00
54308f79d2
command: expose command with direct handling
All checks were successful
Test / Create distribution (push) Successful in 26s
Test / Run NixOS test (push) Successful in 3m28s
This exposes flag set on commands with direct handling.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-02-23 00:24:03 +09:00
12 changed files with 433 additions and 275 deletions

View File

@ -7,8 +7,10 @@ import (
) )
// New initialises a root Node. // New initialises a root Node.
func New(output io.Writer, logf LogFunc, name string) Command { func New(output io.Writer, logf LogFunc, name string, early HandlerFunc) Command {
return rootNode{newNode(output, logf, name, "")} c := rootNode{newNode(output, logf, name, "")}
c.f = early
return c
} }
func newNode(output io.Writer, logf LogFunc, name, usage string) *node { func newNode(output io.Writer, logf LogFunc, name, usage string) *node {
@ -31,6 +33,11 @@ func newNode(output io.Writer, logf LogFunc, name, usage string) *node {
} }
func (n *node) Command(name, usage string, f HandlerFunc) Node { func (n *node) Command(name, usage string, f HandlerFunc) Node {
n.NewCommand(name, usage, f)
return n
}
func (n *node) NewCommand(name, usage string, f HandlerFunc) Flag[Node] {
if f == nil { if f == nil {
panic("invalid handler") panic("invalid handler")
} }
@ -43,7 +50,7 @@ func (n *node) Command(name, usage string, f HandlerFunc) Node {
if !n.adopt(s) { if !n.adopt(s) {
panic("attempted to initialise subcommand with non-unique name") panic("attempted to initialise subcommand with non-unique name")
} }
return n return s
} }
func (n *node) New(name, usage string) Node { func (n *node) New(name, usage string) Node {

View File

@ -7,7 +7,7 @@ import (
) )
func TestBuild(t *testing.T) { func TestBuild(t *testing.T) {
c := command.New(nil, nil, "test") c := command.New(nil, nil, "test", nil)
stubHandler := func([]string) error { panic("unreachable") } stubHandler := func([]string) error { panic("unreachable") }
t.Run("nil direct handler", func(t *testing.T) { t.Run("nil direct handler", func(t *testing.T) {

View File

@ -6,6 +6,9 @@ import (
"strings" "strings"
) )
// UsageInternal causes the command to be hidden from help text when set as the usage string.
const UsageInternal = "internal"
type ( type (
// HandlerFunc is called when matching a directly handled subcommand tree. // HandlerFunc is called when matching a directly handled subcommand tree.
HandlerFunc = func(args []string) error HandlerFunc = func(args []string) error
@ -19,6 +22,11 @@ type (
Define(b *strings.Builder, set *flag.FlagSet, p any, name, usage string) Define(b *strings.Builder, set *flag.FlagSet, p any, name, usage string)
} }
Flag[T any] interface {
// Flag defines a generic flag type in Node's flag set.
Flag(p any, name string, value FlagDefiner, usage string) T
}
Command interface { Command interface {
Parse(arguments []string) error Parse(arguments []string) error
baseNode[Command] baseNode[Command]
@ -28,10 +36,15 @@ type (
baseNode[T any] interface { baseNode[T any] interface {
// Command appends a subcommand with direct command handling. // Command appends a subcommand with direct command handling.
Command(name, usage string, f HandlerFunc) T Command(name, usage string, f HandlerFunc) T
// Flag defines a generic flag type in Node's flag set.
Flag(p any, name string, value FlagDefiner, usage string) T
// New returns a new subcommand tree. // New returns a new subcommand tree.
New(name, usage string) (sub Node) New(name, usage string) (sub Node)
// NewCommand returns a new subcommand with direct command handling.
NewCommand(name, usage string, f HandlerFunc) (sub Flag[Node])
// PrintHelp prints a help message to the configured writer.
PrintHelp()
Flag[T]
} }
) )

View File

@ -28,6 +28,14 @@ func (v StringFlag) Define(b *strings.Builder, set *flag.FlagSet, p any, name, u
b.WriteString(" [" + prettyFlag(name) + " <value>]") b.WriteString(" [" + prettyFlag(name) + " <value>]")
} }
// IntFlag is the default value of an int flag.
type IntFlag int
func (v IntFlag) Define(b *strings.Builder, set *flag.FlagSet, p any, name, usage string) {
set.IntVar(p.(*int), name, int(v), usage)
b.WriteString(" [" + prettyFlag(name) + " <int>]")
}
// BoolFlag is the default value of a bool flag. // BoolFlag is the default value of a bool flag.
type BoolFlag bool type BoolFlag bool
@ -36,6 +44,26 @@ func (v BoolFlag) Define(b *strings.Builder, set *flag.FlagSet, p any, name, usa
b.WriteString(" [" + prettyFlag(name) + "]") b.WriteString(" [" + prettyFlag(name) + "]")
} }
// RepeatableFlag implements an ordered, repeatable string flag.
type RepeatableFlag []string
func (r *RepeatableFlag) String() string {
if r == nil {
return "<nil>"
}
return strings.Join(*r, " ")
}
func (r *RepeatableFlag) Set(v string) error {
*r = append(*r, v)
return nil
}
func (r *RepeatableFlag) Define(b *strings.Builder, set *flag.FlagSet, _ any, name, usage string) {
set.Var(r, name, usage)
b.WriteString(" [" + prettyFlag(name) + " <value>]")
}
// this has no effect on parse outcome // this has no effect on parse outcome
func prettyFlag(name string) string { func prettyFlag(name string) string {
switch len(name) { switch len(name) {

View File

@ -10,6 +10,8 @@ import (
var ErrHelp = errors.New("help requested") var ErrHelp = errors.New("help requested")
func (n *node) PrintHelp() { _ = n.writeHelp() }
func (n *node) writeHelp() error { func (n *node) writeHelp() error {
if _, err := fmt.Fprintf(n.out, if _, err := fmt.Fprintf(n.out,
"\nUsage:\t%s [-h | --help]%s COMMAND [OPTIONS]\n", "\nUsage:\t%s [-h | --help]%s COMMAND [OPTIONS]\n",
@ -42,8 +44,10 @@ func (n *node) writeCommands(w io.Writer) error {
if n == nil { if n == nil {
return nil return nil
} }
if _, err := fmt.Fprintf(w, "\t%s\t%s\n", n.name, n.usage); err != nil { if n.usage != UsageInternal {
return err if _, err := fmt.Fprintf(w, "\t%s\t%s\n", n.name, n.usage); err != nil {
return err
}
} }
return n.next.writeCommands(w) return n.next.writeCommands(w)
} }

View File

@ -34,9 +34,6 @@ func (n *node) Parse(arguments []string) error {
match: match:
if n.child != nil { if n.child != nil {
if n.f != nil {
panic("invalid subcommand tree state")
}
// propagate help prefix early: flag set usage dereferences help // propagate help prefix early: flag set usage dereferences help
n.child.prefix = append(n.prefix, n.name) n.child.prefix = append(n.prefix, n.name)
} }
@ -50,6 +47,17 @@ match:
args := n.set.Args() args := n.set.Args()
if n.child != nil { if n.child != nil {
if n.f != nil {
if n.usage != "" { // root node early special case
panic("invalid subcommand tree state")
}
// special case: root node calls HandlerFunc for initialisation
if err := n.f(nil); err != nil {
return err
}
}
if len(args) == 0 { if len(args) == 0 {
return n.writeHelp() return n.writeHelp()
} }

View File

@ -24,13 +24,13 @@ func TestParse(t *testing.T) {
}{ }{
{ {
"d=0 empty sub", "d=0 empty sub",
func(wout, wlog io.Writer) command.Command { return command.New(wout, newLogFunc(wlog), "root") }, func(wout, wlog io.Writer) command.Command { return command.New(wout, newLogFunc(wlog), "root", nil) },
[]string{""}, []string{""},
"", "test: \"root\" has no subcommands\n", command.ErrEmptyTree, "", "test: \"root\" has no subcommands\n", command.ErrEmptyTree,
}, },
{ {
"d=0 empty sub garbage", "d=0 empty sub garbage",
func(wout, wlog io.Writer) command.Command { return command.New(wout, newLogFunc(wlog), "root") }, func(wout, wlog io.Writer) command.Command { return command.New(wout, newLogFunc(wlog), "root", nil) },
[]string{"a", "b", "c", "d"}, []string{"a", "b", "c", "d"},
"", "test: \"root\" has no subcommands\n", command.ErrEmptyTree, "", "test: \"root\" has no subcommands\n", command.ErrEmptyTree,
}, },
@ -64,18 +64,42 @@ func TestParse(t *testing.T) {
[]string{"print", "0", "1", "2"}, []string{"print", "0", "1", "2"},
"012", "", nil, "012", "", nil,
}, },
{
"d=0 out of order string flag",
buildTestCommand,
[]string{"string", "--string", "64d3b4b7b21788585845060e2199a78f"},
"flag provided but not defined: -string\n\nUsage:\ttest string [-h | --help] COMMAND [OPTIONS]\n\n", "",
errors.New("flag provided but not defined: -string"),
},
{ {
"d=0 string flag", "d=0 string flag",
buildTestCommand, buildTestCommand,
[]string{"--val", "64d3b4b7b21788585845060e2199a78f", "flag"}, []string{"--string", "64d3b4b7b21788585845060e2199a78f", "string"},
"64d3b4b7b21788585845060e2199a78f", "", nil, "64d3b4b7b21788585845060e2199a78f", "", nil,
}, },
{ {
"d=0 out of order string flag", "d=0 int flag",
buildTestCommand, buildTestCommand,
[]string{"flag", "--val", "64d3b4b7b21788585845060e2199a78f"}, []string{"--int", "2147483647", "int"},
"flag provided but not defined: -val\n\nUsage:\ttest flag [-h | --help] COMMAND [OPTIONS]\n\n", "", "2147483647", "", nil,
errors.New("flag provided but not defined: -val"), },
{
"d=0 repeat flag",
buildTestCommand,
[]string{"--repeat", "0", "--repeat", "1", "--repeat", "2", "--repeat", "3", "--repeat", "4", "repeat"},
"[0 1 2 3 4]", "", nil,
},
{
"d=0 bool flag",
buildTestCommand,
[]string{"-v", "succeed"},
"", "test: verbose\n", nil,
},
{
"d=0 bool flag early error",
buildTestCommand,
[]string{"--fail", "succeed"},
"", "", errSuccess,
}, },
{ {
@ -126,12 +150,14 @@ func TestParse(t *testing.T) {
buildTestCommand, buildTestCommand,
[]string{}, []string{},
` `
Usage: test [-h | --help] [-v] [--val <value>] COMMAND [OPTIONS] Usage: test [-h | --help] [-v] [--fail] [--string <value>] [--int <int>] [--repeat <value>] COMMAND [OPTIONS]
Commands: Commands:
error return an error error return an error
print wraps Fprint print wraps Fprint
flag print value passed by flag string print string passed by flag
int print int passed by flag
repeat print repeated values passed by flag
empty empty subcommand empty empty subcommand
join wraps strings.Join join wraps strings.Join
succeed this command succeeds succeed this command succeeds
@ -144,21 +170,29 @@ Commands:
buildTestCommand, buildTestCommand,
[]string{"-h"}, []string{"-h"},
` `
Usage: test [-h | --help] [-v] [--val <value>] COMMAND [OPTIONS] Usage: test [-h | --help] [-v] [--fail] [--string <value>] [--int <int>] [--repeat <value>] COMMAND [OPTIONS]
Commands: Commands:
error return an error error return an error
print wraps Fprint print wraps Fprint
flag print value passed by flag string print string passed by flag
int print int passed by flag
repeat print repeated values passed by flag
empty empty subcommand empty empty subcommand
join wraps strings.Join join wraps strings.Join
succeed this command succeeds succeed this command succeeds
deep top level of command tree with various levels deep top level of command tree with various levels
Flags: Flags:
-v verbosity -fail
-val string fail early
store val for the "flag" command (default "default") -int int
store value for the "int" command (default -1)
-repeat value
store value for the "repeat" command
-string string
store value for the "string" command (default "default")
-v verbose output
`, "", flag.ErrHelp, `, "", flag.ErrHelp,
}, },
@ -239,11 +273,27 @@ var (
) )
func buildTestCommand(wout, wlog io.Writer) (c command.Command) { func buildTestCommand(wout, wlog io.Writer) (c command.Command) {
var val string var (
flagVerbose bool
flagFail bool
flagString string
flagInt int
flagRepeat command.RepeatableFlag
)
logf := newLogFunc(wlog) logf := newLogFunc(wlog)
c = command.New(wout, logf, "test"). c = command.New(wout, logf, "test", func([]string) error {
Flag(new(bool), "v", command.BoolFlag(false), "verbosity"). if flagVerbose {
logf("verbose")
}
if flagFail {
return errSuccess
}
return nil
}).
Flag(&flagVerbose, "v", command.BoolFlag(false), "verbose output").
Flag(&flagFail, "fail", command.BoolFlag(false), "fail early").
Command("error", "return an error", func([]string) error { Command("error", "return an error", func([]string) error {
return errSuccess return errSuccess
}). }).
@ -255,13 +305,15 @@ func buildTestCommand(wout, wlog io.Writer) (c command.Command) {
_, err := fmt.Fprint(wout, a...) _, err := fmt.Fprint(wout, a...)
return err return err
}). }).
Flag(&val, "val", command.StringFlag("default"), "store val for the \"flag\" command"). Flag(&flagString, "string", command.StringFlag("default"), "store value for the \"string\" command").
Command("flag", "print value passed by flag", func(args []string) error { Command("string", "print string passed by flag", func(args []string) error { _, err := fmt.Fprint(wout, flagString); return err }).
_, err := fmt.Fprint(wout, val) Flag(&flagInt, "int", command.IntFlag(-1), "store value for the \"int\" command").
return err Command("int", "print int passed by flag", func(args []string) error { _, err := fmt.Fprint(wout, flagInt); return err }).
}) Flag(nil, "repeat", &flagRepeat, "store value for the \"repeat\" command").
Command("repeat", "print repeated values passed by flag", func(args []string) error { _, err := fmt.Fprint(wout, flagRepeat); return err })
c.New("empty", "empty subcommand") c.New("empty", "empty subcommand")
c.New("hidden", command.UsageInternal)
c.New("join", "wraps strings.Join"). c.New("join", "wraps strings.Join").
Command("out", "write result to wout", func(args []string) error { Command("out", "write result to wout", func(args []string) error {

View File

@ -24,10 +24,10 @@ func TestParseUnreachable(t *testing.T) {
// a node with descendents must not have a direct handler // a node with descendents must not have a direct handler
t.Run("sub handle conflict", func(t *testing.T) { t.Run("sub handle conflict", func(t *testing.T) {
defer checkRecover(t, "Parse", "invalid subcommand tree state") defer checkRecover(t, "Parse", "invalid subcommand tree state")
n := newNode(panicWriter{}, nil, " ", "") n := newNode(panicWriter{}, nil, " ", " ")
n.adopt(newNode(panicWriter{}, nil, " ", " ")) n.adopt(newNode(panicWriter{}, nil, " ", " "))
n.f = func([]string) error { panic("unreachable") } n.f = func([]string) error { panic("unreachable") }
_ = n.Parse(nil) _ = n.Parse([]string{" "})
}) })
// this would only happen if a node was matched twice // this would only happen if a node was matched twice

423
main.go
View File

@ -3,19 +3,19 @@ package main
import ( import (
"context" "context"
_ "embed" _ "embed"
"flag" "errors"
"fmt" "fmt"
"io"
"log" "log"
"os" "os"
"os/signal" "os/signal"
"os/user" "os/user"
"strconv" "strconv"
"strings"
"sync" "sync"
"syscall" "syscall"
"text/tabwriter"
"time" "time"
"git.gensokyo.uk/security/fortify/command"
"git.gensokyo.uk/security/fortify/dbus" "git.gensokyo.uk/security/fortify/dbus"
"git.gensokyo.uk/security/fortify/fst" "git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/helper/seccomp" "git.gensokyo.uk/security/fortify/helper/seccomp"
@ -30,36 +30,16 @@ import (
) )
var ( var (
flagVerbose bool errSuccess = errors.New("success")
flagJSON bool
//go:embed LICENSE //go:embed LICENSE
license string license string
) )
func init() { func init() { fmsg.Prepare("fortify") }
fmsg.Prepare("fortify")
flag.BoolVar(&flagVerbose, "v", false, "Verbose output")
flag.BoolVar(&flagJSON, "json", false, "Format output in JSON when applicable")
}
var std sys.State = new(sys.Std) var std sys.State = new(sys.Std)
type gl []string
func (g *gl) String() string {
if g == nil {
return "<nil>"
}
return strings.Join(*g, " ")
}
func (g *gl) Set(v string) error {
*g = append(*g, v)
return nil
}
func main() { func main() {
// early init argv0 check, skips root check and duplicate PR_SET_DUMPABLE // early init argv0 check, skips root check and duplicate PR_SET_DUMPABLE
init0.TryArgv0() init0.TryArgv0()
@ -73,112 +53,56 @@ func main() {
log.Fatal("this program must not run as root") log.Fatal("this program must not run as root")
} }
flag.CommandLine.Usage = func() { err := buildCommand(os.Stderr).Parse(os.Args[1:])
fmt.Println() if errors.Is(err, errSuccess) || errors.Is(err, command.ErrHelp) {
fmt.Println("Usage:\tfortify [-v] [--json] COMMAND [OPTIONS]")
fmt.Println()
fmt.Println("Commands:")
w := tabwriter.NewWriter(os.Stdout, 0, 1, 4, ' ', 0)
commands := [][2]string{
{"app", "Launch app defined by the specified config file"},
{"run", "Configure and start a permissive default sandbox"},
{"show", "Show the contents of an app configuration"},
{"ps", "List active apps and their state"},
{"version", "Show fortify version"},
{"license", "Show full license text"},
{"template", "Produce a config template"},
{"help", "Show this help message"},
}
for _, c := range commands {
_, _ = fmt.Fprintf(w, "\t%s\t%s\n", c[0], c[1])
}
if err := w.Flush(); err != nil {
fmt.Printf("fortify: cannot write command list: %v\n", err)
}
fmt.Println()
}
flag.Parse()
fmsg.Store(flagVerbose)
args := flag.Args()
if len(args) == 0 {
flag.CommandLine.Usage()
internal.Exit(0) internal.Exit(0)
panic("unreachable")
}
if errors.Is(err, command.ErrNoMatch) || errors.Is(err, command.ErrEmptyTree) {
internal.Exit(1)
panic("unreachable")
}
if err == nil {
log.Fatal("unreachable")
} }
switch args[0] { var flagError command.FlagError
case "version": // print version string if !errors.As(err, &flagError) {
if v, ok := internal.Check(internal.Version); ok { log.Printf("command: %v", err)
fmt.Println(v) internal.Exit(1)
} else { panic("unreachable")
fmt.Println("impure") }
} fmsg.Verbose(flagError.Error())
if flagError.Success() {
internal.Exit(0) internal.Exit(0)
}
internal.Exit(1)
}
case "license": // print embedded license func buildCommand(out io.Writer) command.Command {
fmt.Println(license) var (
internal.Exit(0) flagVerbose bool
flagJSON bool
)
c := command.New(out, log.Printf, "fortify", func([]string) error { fmsg.Store(flagVerbose); return nil }).
Flag(&flagVerbose, "v", command.BoolFlag(false), "Print debug messages to the console").
Flag(&flagJSON, "json", command.BoolFlag(false), "Serialise output as JSON when applicable")
case "template": // print full template configuration c.Command("app", "Launch app defined by the specified config file", func(args []string) error {
printJSON(os.Stdout, false, fst.Template()) if len(args) < 1 {
internal.Exit(0)
case "help": // print help message
flag.CommandLine.Usage()
internal.Exit(0)
case "ps": // print all state info
set := flag.NewFlagSet("ps", flag.ExitOnError)
var short bool
set.BoolVar(&short, "short", false, "Print instance id")
// Ignore errors; set is set for ExitOnError.
_ = set.Parse(args[1:])
printPs(os.Stdout, time.Now().UTC(), state.NewMulti(std.Paths().RunDirPath), short)
internal.Exit(0)
case "show": // pretty-print app info
set := flag.NewFlagSet("show", flag.ExitOnError)
var short bool
set.BoolVar(&short, "short", false, "Omit filesystem information")
// Ignore errors; set is set for ExitOnError.
_ = set.Parse(args[1:])
switch len(set.Args()) {
case 0: // system
printShowSystem(os.Stdout, short)
case 1: // instance
name := set.Args()[0]
config, instance := tryShort(name)
if config == nil {
config = tryPath(name)
}
printShowInstance(os.Stdout, time.Now().UTC(), instance, config, short)
default:
log.Fatal("show requires 1 argument")
}
internal.Exit(0)
case "app": // launch app from configuration file
if len(args) < 2 {
log.Fatal("app requires at least 1 argument") log.Fatal("app requires at least 1 argument")
} }
// config extraArgs... // config extraArgs...
config := tryPath(args[1]) config := tryPath(args[0])
config.Command = append(config.Command, args[2:]...) config.Command = append(config.Command, args[1:]...)
// invoke app // invoke app
runApp(app.MustNew(std), config) runApp(app.MustNew(std), config)
panic("unreachable") panic("unreachable")
})
case "run": // run app in permissive defaults usage pattern {
set := flag.NewFlagSet("run", flag.ExitOnError)
var ( var (
dbusConfigSession string dbusConfigSession string
dbusConfigSystem string dbusConfigSystem string
@ -187,135 +111,188 @@ func main() {
fid string fid string
aid int aid int
groups gl groups command.RepeatableFlag
homeDir string homeDir string
userName string userName string
enablements [system.ELen]bool enablements [system.ELen]bool
) )
set.StringVar(&dbusConfigSession, "dbus-config", "builtin", "Path to D-Bus proxy config file, or \"builtin\" for defaults") c.NewCommand("run", "Configure and start a permissive default sandbox", func(args []string) error {
set.StringVar(&dbusConfigSystem, "dbus-system", "nil", "Path to system D-Bus proxy config file, or \"nil\" to disable") // initialise config from flags
set.BoolVar(&mpris, "mpris", false, "Allow owning MPRIS D-Bus path, has no effect if custom config is available") config := &fst.Config{
set.BoolVar(&dbusVerbose, "dbus-log", false, "Force logging in the D-Bus proxy") ID: fid,
Command: args,
}
set.StringVar(&fid, "id", "", "App ID, leave empty to disable security context app_id") if aid < 0 || aid > 9999 {
set.IntVar(&aid, "a", 0, "Fortify application ID") log.Fatalf("aid %d out of range", aid)
set.Var(&groups, "g", "Groups inherited by the app process") }
set.StringVar(&homeDir, "d", "os", "Application home directory")
set.StringVar(&userName, "u", "chronos", "Passwd name within sandbox")
set.BoolVar(&enablements[system.EWayland], "wayland", false, "Allow Wayland connections")
set.BoolVar(&enablements[system.EX11], "X", false, "Share X11 socket and allow connection")
set.BoolVar(&enablements[system.EDBus], "dbus", false, "Proxy D-Bus connection")
set.BoolVar(&enablements[system.EPulse], "pulse", false, "Share PulseAudio socket and cookie")
// Ignore errors; set is set for ExitOnError. // resolve home/username from os when flag is unset
_ = set.Parse(args[1:]) var (
passwd *user.User
// initialise config from flags passwdOnce sync.Once
config := &fst.Config{ passwdFunc = func() {
ID: fid, var us string
Command: set.Args(), if uid, err := std.Uid(aid); err != nil {
} fmsg.PrintBaseError(err, "cannot obtain uid from fsu:")
os.Exit(1)
if aid < 0 || aid > 9999 { } else {
log.Fatalf("aid %d out of range", aid) us = strconv.Itoa(uid)
} }
// resolve home/username from os when flag is unset if u, err := user.LookupId(us); err != nil {
var ( fmsg.Verbosef("cannot look up uid %s", us)
passwd *user.User passwd = &user.User{
passwdOnce sync.Once Uid: us,
passwdFunc = func() { Gid: us,
var us string Username: "chronos",
if uid, err := std.Uid(aid); err != nil { Name: "Fortify",
fmsg.PrintBaseError(err, "cannot obtain uid from fsu:") HomeDir: "/var/empty",
os.Exit(1) }
} else { } else {
us = strconv.Itoa(uid) passwd = u
}
if u, err := user.LookupId(us); err != nil {
fmsg.Verbosef("cannot look up uid %s", us)
passwd = &user.User{
Uid: us,
Gid: us,
Username: "chronos",
Name: "Fortify",
HomeDir: "/var/empty",
} }
} else {
passwd = u
} }
)
if homeDir == "os" {
passwdOnce.Do(passwdFunc)
homeDir = passwd.HomeDir
} }
)
if homeDir == "os" { if userName == "chronos" {
passwdOnce.Do(passwdFunc) passwdOnce.Do(passwdFunc)
homeDir = passwd.HomeDir userName = passwd.Username
}
if userName == "chronos" {
passwdOnce.Do(passwdFunc)
userName = passwd.Username
}
config.Confinement.AppID = aid
config.Confinement.Groups = groups
config.Confinement.Outer = homeDir
config.Confinement.Username = userName
// enablements from flags
for i := system.Enablement(0); i < system.Enablement(system.ELen); i++ {
if enablements[i] {
config.Confinement.Enablements.Set(i)
} }
}
// parse D-Bus config file from flags if applicable config.Confinement.AppID = aid
if enablements[system.EDBus] { config.Confinement.Groups = groups
if dbusConfigSession == "builtin" { config.Confinement.Outer = homeDir
config.Confinement.SessionBus = dbus.NewConfig(fid, true, mpris) config.Confinement.Username = userName
} else {
if c, err := dbus.NewConfigFromFile(dbusConfigSession); err != nil { // enablements from flags
log.Fatalf("cannot load session bus proxy config from %q: %s", dbusConfigSession, err) for i := system.Enablement(0); i < system.Enablement(system.ELen); i++ {
} else { if enablements[i] {
config.Confinement.SessionBus = c config.Confinement.Enablements.Set(i)
} }
} }
// system bus proxy is optional // parse D-Bus config file from flags if applicable
if dbusConfigSystem != "nil" { if enablements[system.EDBus] {
if c, err := dbus.NewConfigFromFile(dbusConfigSystem); err != nil { if dbusConfigSession == "builtin" {
log.Fatalf("cannot load system bus proxy config from %q: %s", dbusConfigSystem, err) config.Confinement.SessionBus = dbus.NewConfig(fid, true, mpris)
} else { } else {
config.Confinement.SystemBus = c if conf, err := dbus.NewConfigFromFile(dbusConfigSession); err != nil {
log.Fatalf("cannot load session bus proxy config from %q: %s", dbusConfigSession, err)
} else {
config.Confinement.SessionBus = conf
}
}
// system bus proxy is optional
if dbusConfigSystem != "nil" {
if conf, err := dbus.NewConfigFromFile(dbusConfigSystem); err != nil {
log.Fatalf("cannot load system bus proxy config from %q: %s", dbusConfigSystem, err)
} else {
config.Confinement.SystemBus = conf
}
}
// override log from configuration
if dbusVerbose {
config.Confinement.SessionBus.Log = true
config.Confinement.SystemBus.Log = true
} }
} }
// override log from configuration // invoke app
if dbusVerbose { runApp(app.MustNew(std), config)
config.Confinement.SessionBus.Log = true panic("unreachable")
config.Confinement.SystemBus.Log = true }).
} Flag(&dbusConfigSession, "dbus-config", command.StringFlag("builtin"),
} "Path to D-Bus proxy config file, or \"builtin\" for defaults").
Flag(&dbusConfigSystem, "dbus-system", command.StringFlag("nil"),
// invoke app "Path to system D-Bus proxy config file, or \"nil\" to disable").
runApp(app.MustNew(std), config) Flag(&mpris, "mpris", command.BoolFlag(false),
panic("unreachable") "Allow owning MPRIS D-Bus path, has no effect if custom config is available").
Flag(&dbusVerbose, "dbus-log", command.BoolFlag(false),
// internal commands "Force logging in the D-Bus proxy").
case "shim": Flag(&fid, "id", command.StringFlag(""),
shim.Main() "App ID, leave empty to disable security context app_id").
internal.Exit(0) Flag(&aid, "a", command.IntFlag(0),
case "init": "Fortify application ID").
init0.Main() Flag(nil, "g", &groups,
internal.Exit(0) "Groups inherited by the app process").
Flag(&homeDir, "d", command.StringFlag("os"),
default: "Application home directory").
log.Fatalf("%q is not a valid command", args[0]) Flag(&userName, "u", command.StringFlag("chronos"),
"Passwd name within sandbox").
Flag(&enablements[system.EWayland], "wayland", command.BoolFlag(false),
"Allow Wayland connections").
Flag(&enablements[system.EX11], "X", command.BoolFlag(false),
"Share X11 socket and allow connection").
Flag(&enablements[system.EDBus], "dbus", command.BoolFlag(false),
"Proxy D-Bus connection").
Flag(&enablements[system.EPulse], "pulse", command.BoolFlag(false),
"Share PulseAudio socket and cookie")
} }
panic("unreachable") var showFlagShort bool
c.NewCommand("show", "Show the contents of an app configuration", func(args []string) error {
switch len(args) {
case 0: // system
printShowSystem(os.Stdout, showFlagShort, flagJSON)
case 1: // instance
name := args[0]
config, instance := tryShort(name)
if config == nil {
config = tryPath(name)
}
printShowInstance(os.Stdout, time.Now().UTC(), instance, config, showFlagShort, flagJSON)
default:
log.Fatal("show requires 1 argument")
}
return errSuccess
}).Flag(&showFlagShort, "short", command.BoolFlag(false), "Omit filesystem information")
var psFlagShort bool
c.NewCommand("ps", "List active apps and their state", func(args []string) error {
printPs(os.Stdout, time.Now().UTC(), state.NewMulti(std.Paths().RunDirPath), psFlagShort, flagJSON)
return errSuccess
}).Flag(&psFlagShort, "short", command.BoolFlag(false), "Print instance id")
c.Command("version", "Show fortify version", func(args []string) error {
if v, ok := internal.Check(internal.Version); ok {
fmt.Println(v)
} else {
fmt.Println("impure")
}
return errSuccess
})
c.Command("license", "Show full license text", func(args []string) error {
fmt.Println(license)
return errSuccess
})
c.Command("template", "Produce a config template", func(args []string) error {
printJSON(os.Stdout, false, fst.Template())
return errSuccess
})
c.Command("help", "Show this help message", func([]string) error {
c.PrintHelp()
return errSuccess
})
// internal commands
c.Command("shim", command.UsageInternal, func([]string) error { shim.Main(); return errSuccess })
c.Command("init", command.UsageInternal, func([]string) error { init0.Main(); return errSuccess })
return c
} }
func runApp(a fst.App, config *fst.Config) { func runApp(a fst.App, config *fst.Config) {

81
main_test.go Normal file
View File

@ -0,0 +1,81 @@
package main
import (
"bytes"
"errors"
"flag"
"testing"
"git.gensokyo.uk/security/fortify/command"
)
func TestHelp(t *testing.T) {
testCases := []struct {
name string
args []string
want string
}{
{
"main", []string{}, `
Usage: fortify [-h | --help] [-v] [--json] COMMAND [OPTIONS]
Commands:
app Launch app defined by the specified config file
run Configure and start a permissive default sandbox
show Show the contents of an app configuration
ps List active apps and their state
version Show fortify version
license Show full license text
template Produce a config template
help Show this help message
`,
},
{
"run", []string{"run", "-h"}, `
Usage: fortify run [-h | --help] [--dbus-config <value>] [--dbus-system <value>] [--mpris] [--dbus-log] [--id <value>] [-a <int>] [-g <value>] [-d <value>] [-u <value>] [--wayland] [-X] [--dbus] [--pulse] COMMAND [OPTIONS]
Flags:
-X Share X11 socket and allow connection
-a int
Fortify application ID
-d string
Application home directory (default "os")
-dbus
Proxy D-Bus connection
-dbus-config string
Path to D-Bus proxy config file, or "builtin" for defaults (default "builtin")
-dbus-log
Force logging in the D-Bus proxy
-dbus-system string
Path to system D-Bus proxy config file, or "nil" to disable (default "nil")
-g value
Groups inherited by the app process
-id string
App ID, leave empty to disable security context app_id
-mpris
Allow owning MPRIS D-Bus path, has no effect if custom config is available
-pulse
Share PulseAudio socket and cookie
-u string
Passwd name within sandbox (default "chronos")
-wayland
Allow Wayland connections
`,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
out := new(bytes.Buffer)
c := buildCommand(out)
if err := c.Parse(tc.args); !errors.Is(err, command.ErrHelp) && !errors.Is(err, flag.ErrHelp) {
t.Errorf("Parse: error = %v; want %v",
err, command.ErrHelp)
}
if got := out.String(); got != tc.want {
t.Errorf("Parse: %s want %s", got, tc.want)
}
})
}
}

View File

@ -18,7 +18,7 @@ import (
"git.gensokyo.uk/security/fortify/internal/state" "git.gensokyo.uk/security/fortify/internal/state"
) )
func printShowSystem(output io.Writer, short bool) { func printShowSystem(output io.Writer, short, flagJSON bool) {
t := newPrinter(output) t := newPrinter(output)
defer t.MustFlush() defer t.MustFlush()
@ -43,7 +43,7 @@ func printShowSystem(output io.Writer, short bool) {
func printShowInstance( func printShowInstance(
output io.Writer, now time.Time, output io.Writer, now time.Time,
instance *state.State, config *fst.Config, instance *state.State, config *fst.Config,
short bool) { short, flagJSON bool) {
if flagJSON { if flagJSON {
if instance != nil { if instance != nil {
printJSON(output, short, instance) printJSON(output, short, instance)
@ -190,7 +190,7 @@ func printShowInstance(
} }
} }
func printPs(output io.Writer, now time.Time, s state.Store, short bool) { func printPs(output io.Writer, now time.Time, s state.Store, short, flagJSON bool) {
var entries state.Entries var entries state.Entries
if e, err := state.Join(s); err != nil { if e, err := state.Join(s); err != nil {
log.Fatalf("cannot join store: %v", err) log.Fatalf("cannot join store: %v", err)

View File

@ -448,14 +448,8 @@ App
for _, tc := range testCases { for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
{
v := flagJSON
t.Cleanup(func() { flagJSON = v })
flagJSON = tc.json
}
output := new(strings.Builder) output := new(strings.Builder)
printShowInstance(output, testTime, tc.instance, tc.config, tc.short) printShowInstance(output, testTime, tc.instance, tc.config, tc.short, tc.json)
if got := output.String(); got != tc.want { if got := output.String(); got != tc.want {
t.Errorf("printShowInstance: got\n%s\nwant\n%s", t.Errorf("printShowInstance: got\n%s\nwant\n%s",
got, tc.want) got, tc.want)
@ -645,14 +639,8 @@ func Test_printPs(t *testing.T) {
for _, tc := range testCases { for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
{
v := flagJSON
t.Cleanup(func() { flagJSON = v })
flagJSON = tc.json
}
output := new(strings.Builder) output := new(strings.Builder)
printPs(output, testTime, stubStore(tc.entries), tc.short) printPs(output, testTime, stubStore(tc.entries), tc.short, tc.json)
if got := output.String(); got != tc.want { if got := output.String(); got != tc.want {
t.Errorf("printPs: got\n%s\nwant\n%s", t.Errorf("printPs: got\n%s\nwant\n%s",
got, tc.want) got, tc.want)